commit f13d33af4d2fd9115faa4867d987c117094b07f0 Author: fish Date: Sat Nov 7 10:42:04 2026 +0800 init: 初始化 web_hgdjb_server 项目 Co-Authored-By: Claude Opus 4.7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a2a070 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +tool +.DS_Store +.project +.idea +.vscode +/temp +/run +*.tar.gz +*.tar.xz +# Google 服务账号私钥(Indexing API),只能随部署单独投放,禁止入库。 +composed-anvil-*.json +# 配置文件(含数据库/中间件明文凭据,禁止入库) +app/conf/app.yaml +web/conf/web.yaml +# 加解密密钥文件 +*.key 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..09363cc --- /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_sgdj_app + env GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_sgdj_app -v + tar czf web_sgdj_app.tar.gz web_sgdj_app + rm -f web_sgdj_app + +test: + @echo "test, GOPATH=$(GOPATH)" + GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_sgdj_app + tar czf web_sgdj_app.tar.gz web_sgdj_app docs/ + rm -f web_sgdj_app +localtest: + @echo "test, GOPATH=$(GOPATH)" + go build $(APP_BUILD_FLAG) -o app \ No newline at end of file diff --git a/app/api/appinfoapi/appinfo.go b/app/api/appinfoapi/appinfo.go new file mode 100644 index 0000000..ac69bad --- /dev/null +++ b/app/api/appinfoapi/appinfo.go @@ -0,0 +1,48 @@ +package appinfoapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/appser" + "videoweb/app/service/configser" + "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) + } + + common.ServerJSON(c, srvdef.Success, proto.AppListResp{ + Path: c.Request.URL.Path, + Config: configser.GetConfigInfo(), + Advitise: configser.GetAdvitise(), + CategoryList: categoryList, + Category: category, + }) +} diff --git a/app/api/authapi/auth.go b/app/api/authapi/auth.go new file mode 100644 index 0000000..c73e0dd --- /dev/null +++ b/app/api/authapi/auth.go @@ -0,0 +1,232 @@ +package authapi + +import ( + "strings" + "videoweb/app/service/authser" + "videoweb/app/service/wordsfilterser" + "videoweb/pub/common" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/useragent" + "videoweb/pub/pubdef" + + "github.com/gin-gonic/gin" +) + +func requestUA(ctx *gin.Context) useragent.UA { + ua, _ := common.GetUA(ctx) + return ua +} + +func applyRequestDevID(ua useragent.UA, devID string) useragent.UA { + if v := strings.TrimSpace(devID); v != "" { + ua.DevID = v + } + return ua +} + +type registerReq struct { + AccountNo string `json:"accountNo" binding:"required"` // 账号 + Email string `json:"email"` // 邮箱(可选) + Password string `json:"password" binding:"required"` // 明文密码 + AgentInviteCode string `json:"agentInviteCode"` // 邀请人的邀请码(可选) + DevID string `json:"devID"` // 设备唯一标示(可选) + Channel string `json:"channel"` // 渠道码(可选,用户无商区码时写入) +} + +type loginReq struct { + Account string `json:"account" binding:"required"` // 账号或邮箱 + Password string `json:"password" binding:"required"` // 明文密码 + DevID string `json:"devID"` // 设备唯一标示(可选) + Channel string `json:"channel"` // 渠道码(可选,用户无商区码时写入) +} + +type resetPasswordReq struct { + AccountNo string `json:"accountNo" binding:"required"` // 账号 + Email string `json:"email" binding:"required"` // 邮箱 + NewPassword string `json:"newPassword" binding:"required"` // 新密码 +} + +type updateProfileReq struct { + NickName *string `json:"nickName"` // 新昵称(可选) + Avatar *string `json:"avatar"` // 新头像 URL(可选) + Introduction *string `json:"introduction"` // 简介(可选) + PrivacyMode *bool `json:"privacyMode"` // 隐私模式:true-开启,false-关闭(可选) + OldPassword *string `json:"oldPassword"` // 旧密码明文(已设置密码时修改必填) + NewPassword *string `json:"newPassword"` // 新密码明文(设置或修改密码时必填) +} + +// GuestLogin 游客登录 +// @Tags 用户认证 +// @Summary 游客登录 +// @Description 按设备ID创建或找回游客账号;请求头可带 Authorization 复用已有游客会话。需传 X-User-Agent +// @Accept json +// @Produce json +// @Param param body authser.GuestLoginReq true "游客登录参数" +// @Success 200 {object} authser.LoginResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/auth/guest [POST] +func GuestLogin(ctx *gin.Context) { + var req authser.GuestLoginReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + ua, err := common.GetUA(ctx) + if err != nil { + common.ServerJSON(ctx, srvdef.NoUA, nil) + return + } + if req.DevID == "" { + common.ServerJSON(ctx, srvdef.ErrParamError, "devID不能为空") + return + } + ua.DevID = req.DevID + resp, serr := authser.GuestLogin(ctx, common.GetUserId(ctx), ipaddr.ResolveClientIP(ctx), &req, ua) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Register 用户注册 +// @Tags 用户认证 +// @Summary 注册 +// @Description 传入账号和明文密码,邮箱可选,请求头带游客 Authorization 时升级该游客为普通用户,否则新建用户 +// @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 + } + ua := applyRequestDevID(requestUA(ctx), req.DevID) + resp, serr := authser.Register(ctx, common.GetUserId(ctx), req.AccountNo, req.Email, req.Password, req.AgentInviteCode, ua, ipaddr.ResolveClientIP(ctx), req.Channel) + 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 + } + ua := applyRequestDevID(requestUA(ctx), req.DevID) + resp, serr := authser.Login(ctx, req.Account, req.Password, ua, ipaddr.ResolveClientIP(ctx), req.Channel) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// UpdateProfile 修改用户资料 +// @Tags 用户认证 +// @Summary 修改用户资料(头像 / 简介 / 密码,可任意组合) +// @Description 用户信息通过 token 获取,无需传账号;nickName、avatar、introduction、oldPassword+newPassword 均为可选,至少提供一项。未设置密码时传 newPassword 可设置密码,已设置密码时修改需传 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) { + userId := common.GetUserId(ctx) + if userId <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, nil) + return + } + var req updateProfileReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if req.NickName != nil && !wordsfilterser.Validate(*req.NickName) { + common.ServerJSON(ctx, srvdef.ErrContainIllegalWord, "包含非法字符") + return + } + serr := authser.UpdateProfile(ctx, &authser.UpdateProfileReq{ + UserId: userId, + NickName: req.NickName, + Avatar: req.Avatar, + Introduction: req.Introduction, + PrivacyMode: req.PrivacyMode, + OldPassword: req.OldPassword, + NewPassword: req.NewPassword, + }) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// GetInfo 获取当前用户信息 +// @Tags 用户认证 +// @Summary 获取当前用户信息 +// @Description 通过 token 获取最新用户信息,包含金币余额、VIP状态等 +// @Produce json +// @Success 200 {object} authser.LoginResp "用户信息" +// @Failure 400 {string} string "失败" +// @Router /api/app/auth/getInfo [GET] +func GetInfo(ctx *gin.Context) { + userId := common.GetUserId(ctx) + if pubdef.IsCrawlerUser(userId) { + common.ServerJSON(ctx, srvdef.Success, authser.CrawlerLoginResp("")) + return + } + if userId <= 0 { + common.ServerJSON(ctx, srvdef.InvalidToken, nil) + return + } + resp, serr := authser.GetInfo(ctx, userId, requestUA(ctx)) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ResetPassword 找回密码 +// @Tags 用户认证 +// @Summary 找回密码 +// @Description 通过账号和邮箱双重校验后重置密码,两者必须同时匹配同一用户 +// @Accept json +// @Produce json +// @Param param body resetPasswordReq true "找回密码参数" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/auth/resetPassword [POST] +func ResetPassword(ctx *gin.Context) { + var req resetPasswordReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + serr := authser.ResetPassword(ctx, req.AccountNo, req.Email, req.NewPassword) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/app/api/coinapi/coin.go b/app/api/coinapi/coin.go new file mode 100644 index 0000000..bf275b5 --- /dev/null +++ b/app/api/coinapi/coin.go @@ -0,0 +1,145 @@ +package coinapi + +import ( + "fmt" + "net/url" + "videoweb/app/service/coinser" + "videoweb/app/service/rechargeser" + "videoweb/pub/common" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +type buyCoinReq struct { + PackageId int64 `json:"packageId" binding:"required"` // 购买套餐ID(金币充值) + PayMode string `json:"payMode" binding:"required"` // 支付方式:alipay/wechat + SourcePageKey string `json:"sourcePageKey"` // 下单来源页面标识 + SourcePageName string `json:"sourcePageName"` // 下单来源页面名称 +} + +// BuyCoin 购买金币(发起第三方支付) +// @Tags 金币 +// @Summary 购买金币 +// @Description 根据指定金币套餐发起第三方支付,返回支付链接 +// @Accept json +// @Produce json +// @Param param body buyCoinReq true "购买参数" +// @Success 200 {object} coinser.BuyCoinResp "支付链接" +// @Security ApiKeyAuth +// @Router /api/app/coin/buy [POST] +func BuyCoin(ctx *gin.Context) { + var req buyCoinReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + userId := common.GetUserId(ctx) + sysType := ctx.GetString(consts.CtxSysType) + devType := ctx.GetString(consts.CtxDevType) + ip := ipaddr.ResolveClientIP(ctx) + + user, serr := coinser.GetUserInfo(ctx, userId) + if serr != nil || user == nil { + common.ServerJSON(ctx, srvdef.ErrUserNotExsit, nil) + return + } + + deviceID := rechargeser.PayDeviceID(ctx.GetString(consts.CtxDevId), user.DevID, user.DevV2) + resp, serr := coinser.CreateCoinOrder(ctx, userId, user.NickName, req.PackageId, req.PayMode, sysType, devType, deviceID, ip, user.DistrictCode, req.SourcePageKey, req.SourcePageName) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ExportCoinTrans 导出当前用户金币余额明细 +// @Tags 金币 +// @Summary 导出金币余额明细 -- by 王鸣 +// @Description 导出当前登录用户的金币收入、支出流水CSV;type=0全部 1充值 2消费 3奖励 +// @Produce text/csv +// @Param type query int false "类型:0全部 1充值 2消费 3奖励" +// @Success 200 {file} file "CSV文件" +// @Security ApiKeyAuth +// @Router /api/app/coin/trans/export [GET] +func ExportCoinTrans(ctx *gin.Context) { + var req coinTransListReq + _ = ctx.ShouldBindQuery(&req) + data, fileName, err := coinser.ExportCoinTrans(ctx, common.GetUserId(ctx), req.Type) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + ctx.Header("Content-Type", "text/csv; charset=utf-8") + ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.QueryEscape(fileName))) + ctx.Data(200, "text/csv; charset=utf-8", data) +} + +type coinOrderHistoryReq struct { + Page int64 `json:"page" form:"page"` + PageSize int64 `json:"pageSize" form:"pageSize"` +} + +// GetCoinOrderHistory 查询当前用户金币购买记录 +// @Tags 金币 +// @Summary 查询金币购买记录 +// @Produce json +// @Param page query int false "页码(默认1)" +// @Param pageSize query int false "每页数量(默认10)" +// @Success 200 {object} object "订单列表" +// @Security ApiKeyAuth +// @Router /api/app/coin/orders [GET] +func GetCoinOrderHistory(ctx *gin.Context) { + var req coinOrderHistoryReq + _ = ctx.ShouldBindQuery(&req) + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + + userId := common.GetUserId(ctx) + list, total, serr := coinser.GetCoinOrderHistory(ctx, userId, req.Page, req.PageSize) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, gin.H{"total": total, "list": list}) +} + +type coinTransListReq struct { + // 页码,默认1 + Page int64 `json:"page" form:"page"` + // 每页数量,默认10,最大100 + PageSize int64 `json:"pageSize" form:"pageSize"` + // 类型:0-全部 1-充值 2-消费 3-奖励 + Type int64 `json:"type" form:"type"` +} + +// GetCoinTransList 查询当前用户金币余额明细 +// @Tags 金币 +// @Summary 查询金币余额明细 -- by 旺鸣 +// @Description 返回当前登录用户的金币收入、支出流水,按交易时间倒序;type=0全部 1充值 2消费 3奖励 +// @Produce json +// @Param page query int false "页码(默认1)" +// @Param pageSize query int false "每页数量(默认10,最大100)" +// @Param type query int false "类型:0全部 1充值 2消费 3奖励" +// @Success 200 {object} response.BasePageResp{list=[]proto.CoinTransListItemResp} "成功" +// @Security ApiKeyAuth +// @Router /api/app/coin/trans/list [GET] +func GetCoinTransList(ctx *gin.Context) { + var req coinTransListReq + _ = ctx.ShouldBindQuery(&req) + userId := common.GetUserId(ctx) + resp, err := coinser.GetCoinTransList(ctx, userId, req.Page, req.PageSize, req.Type) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/app/api/commentapi/comment.go b/app/api/commentapi/comment.go new file mode 100644 index 0000000..ca8b351 --- /dev/null +++ b/app/api/commentapi/comment.go @@ -0,0 +1,86 @@ +package commentapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/commentser" + "videoweb/pub/common" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// SendComment 发布评论 +// @Tags 评论APP +// @Summary 发布评论 --by 王鸣 +// @Description 发布短剧、短剧剧集或视频评论,parentsId为0发布一级评论,大于0发布二级评论,replyCommentId表示实际回复的评论,objectType 10-短剧 11-短剧剧集 20-视频 +// @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 + } + ip := ipaddr.ResolveClientIP(ctx) + err := commentser.SendComment(ctx, common.GetUserId(ctx), ip, &in) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "success") +} + +// List 评论列表 +// @Tags 评论APP +// @Summary 评论列表 --by 王鸣 +// @Description 分页查询短剧、短剧剧集或视频评论树,一级评论分页返回,二级评论挂在children中,objectType 10-短剧 11-短剧剧集 20-视频 +// @Accept json +// @Produce json +// @Param params query proto.GetCommentListReq true "评论列表请求" +// @Success 200 {object} proto.GetCommentListResp{list=[]proto.CommentInfo} "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Router /api/app/comment/list [get] +func List(ctx *gin.Context) { + var in proto.GetCommentListReq + if err := ctx.ShouldBindQuery(&in); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := commentser.GetCommentPage(ctx, common.GetUserId(ctx), &in) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetLike 评论点赞/取消点赞 +// @Tags 评论APP +// @Summary 评论点赞/取消点赞 --by 王鸣 +// @Description 设置当前用户对短剧、短剧剧集或视频评论的点赞状态 +// @Accept json +// @Produce json +// @Param param body proto.CommentLikeSetReq true "评论点赞设置请求" +// @Success 200 {object} proto.CommentLikeSetResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/app/comment/like/set [post] +func SetLike(ctx *gin.Context) { + var in proto.CommentLikeSetReq + if err := ctx.ShouldBindJSON(&in); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := commentser.SetCommentLike(ctx, common.GetUserId(ctx), &in) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + 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..d6b3312 --- /dev/null +++ b/app/api/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// Info 查询APP公共配置 +// @Tags APP配置相关接口 +// @Summary 查询APP公共配置 --by 王鸣 +// @Description 返回最新地址信息、完整系统配置、广告配置和全站SEO配置 +// @Accept json +// @Produce json +// @Success 200 {object} proto.ConfigInfoResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/config/info [get] +func Info(ctx *gin.Context) { + configInfo := configser.GetConfigInfo() + resp := &proto.ConfigInfoResp{ + NewAddressInfo: configser.GetNewAddress(), + Config: configInfo, + Advitise: configser.GetAdvitise(), + SeoInfo: configser.GetSiteSeoInfo(), + SeoNavInfo: configser.GetNavSeoInfo(), + NavPageList: configser.GetNavPageList(), + ReportSDKConfig: configInfo.ReportSDKConfig, + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// 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) { + common.ServerJSON(ctx, srvdef.Success, configser.GetAppRichTextList(ctx.Query("ids"), ctx.Query("titles"))) +} diff --git a/app/api/contactusapi/contactus.go b/app/api/contactusapi/contactus.go new file mode 100644 index 0000000..50916ab --- /dev/null +++ b/app/api/contactusapi/contactus.go @@ -0,0 +1,36 @@ +package contactusapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/contactusser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// AddContactUs 提交联系我们 +// @Tags 联系我们相关接口 +// @Summary 提交联系我们 -- by 旺鸣 +// @Description 用户提交联系我们表单,问题类型:1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他 +// @Accept json +// @Produce json +// @Param param body proto.ContactUsAddReq true "联系我们请求" +// @Success 200 {object} proto.ContactUsAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/contactus/add [post] +func AddContactUs(ctx *gin.Context) { + var req proto.ContactUsAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + userId := common.GetUserId(ctx) + resp, err := contactusser.AddContactUs(ctx, userId, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/app/api/feedbackapi/feedback.go b/app/api/feedbackapi/feedback.go new file mode 100644 index 0000000..19f2098 --- /dev/null +++ b/app/api/feedbackapi/feedback.go @@ -0,0 +1,36 @@ +package feedbackapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/feedbackser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// AddFeedback 提交意见反馈 +// @Tags 意见反馈相关接口 +// @Summary 提交意见反馈 -- by 旺鸣 +// @Description 用户提交意见反馈,问题类型,1-功能建议、2-播放问题、3-账号问题、4-其他 +// @Accept json +// @Produce json +// @Param param body proto.FeedbackReq true "意见反馈请求" +// @Success 200 {object} proto.FeedbackResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/feedback/add [post] +func AddFeedback(ctx *gin.Context) { + var req proto.FeedbackReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + userId := common.GetUserId(ctx) + resp, err := feedbackser.AddFeedback(ctx, userId, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} 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..7f30927 --- /dev/null +++ b/app/api/home/home.go @@ -0,0 +1,305 @@ +package home + +import ( + "fmt" + "net/http" + "strconv" + "videoweb/app/api/proto" + "videoweb/app/service/commentser" + "videoweb/app/service/configser" + "videoweb/app/service/translationser" + "videoweb/app/service/videoser" + "videoweb/pub/common" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/srvdef" + v10 "videoweb/pub/common/v10" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "github.com/gin-gonic/gin" +) + +// VideoTagApi 标签搜索 +// @Tags 标签搜索 +// @Summary 标签搜索 +// @Description 标签搜索 +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {object} proto.HomeHtmlData "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /richtext/:id [get] +func RichtextApi(c *gin.Context) { + data := proto.RichTextHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetConfigInfo(), + Advitise: configser.GetAdvitise(), + } + richtextStr := c.Param("id") + richtextId, err := strconv.ParseInt(richtextStr, 10, 64) + if err != nil || richtextId < 1 { + common.ServerJSON(c, srvdef.NotFound, "") + return + } + richtextDate, serr := videoser.RichtextData(c, richtextId) + if serr != nil { + common.ServerJSON(c, serr.Code, "richtext param error") + return + } + data.CompletePath = fmt.Sprintf("/richtext/%d", richtextId) + data.RichTextInfo = richtextDate + + common.JSONWriterCache(c, srvdef.Success, data) +} + +// PageNotFound 404 +// @Tags 404 +// @Summary 404 +// @Description 404 +// @Accept json +// @Produce json +// @Param param body string true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +func PageNotFound(c *gin.Context) { + data := proto.NotFoundHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetConfigInfo(), + Advitise: configser.GetAdvitise(), + } + data.CompletePath = c.Request.URL.Path + c.JSON(http.StatusOK, data) + //common.ServerJSON(c, srvdef.Success, data) + + //c.HTML(404, "404", gin.H{ + // "data": data, + //}) +} + +func UrlRedirect(c *gin.Context) { + data := proto.UrlRedirectData{ + Path: c.Request.URL.Path, + CompletePath: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetConfigInfo(), + Advitise: configser.GetAdvitise(), + } + url := c.Query("url") + data.UrlRedirect = url + common.ServerJSON(c, srvdef.Success, data) +} + +func GetTagCategoryCache(ctx *gin.Context) { + data := configser.GetVideoTagCategoryDataList() + common.ServerJSON(ctx, srvdef.Success, data) +} + +// VideoDetailInfo 新版视频详情页 +func VideoDetailInfo(c *gin.Context) { + data := proto.VideoDetailsHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetConfigInfo(), + Advitise: configser.GetAdvitise(), + } + ua := "" + if v, b := c.Get(consts.CtxUAH5); b { + ua = v.(string) + } + ip := ipaddr.ResolveClientIP(c) + idStr := c.Param("id") + videoId, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || videoId < 1 { + c.HTML(http.StatusNotFound, "404", gin.H{"data": data}) + return + } + renderVideoDetailPage(c, data, videoId, ip, ua) +} + +func renderVideoDetailPage(c *gin.Context, data proto.VideoDetailsHtmlData, videoId int64, ip, ua string) { + videoDate, serr := videoser.VideoDetails(c, videoId, common.GetUserId(c), ip, ua) + if serr != nil { + if c.Query("format") == "json" { + common.ServerJSON(c, srvdef.ErrNoData, data) + return + } + c.HTML(http.StatusNotFound, "404", gin.H{"data": data}) + return + } + data.Video = videoDate + data.Path = fmt.Sprintf("/videodetail/%d/", videoId) + + data.Breadcrumbs = []proto.BreadcrumbItem{ + {Name: "首页", Url: "/"}, + {Name: "视频", Url: "/videoList"}, + } + if len(videoDate.Categorys) > 0 { + cat := videoDate.Categorys[0] + data.Breadcrumbs = append(data.Breadcrumbs, proto.BreadcrumbItem{ + Name: cat.Name, + Url: fmt.Sprintf("/category/%d/", cat.Id), + }) + } + data.Breadcrumbs = append(data.Breadcrumbs, proto.BreadcrumbItem{ + Name: videoDate.Title, + Url: "", + }) + + switch videoDate.Type { + case proto.VideoTypeVideo, proto.VideoTypeVideoV2: + comment, commentErr := commentser.GetCommentList(c, videoId, commentser.ObjectTypeVideo, 1) + data.CommentList = proto.GetCommentListResp{ + ObjectId: videoId, + TotalCount: 0, + PageSize: 20, + PageNum: 1, + List: make([]proto.CommentInfo, 0), + } + if commentErr == nil { + data.CommentList = *comment + } + if c.Query("format") == "json" { + common.ServerJSON(c, srvdef.Success, data) + return + } + c.HTML(http.StatusOK, "videoDetail", gin.H{ + "data": data, + }) + default: + if c.Query("format") == "json" { + common.ServerJSON(c, srvdef.ErrNoData, data) + return + } + c.HTML(http.StatusNotFound, "404", gin.H{"data": data}) + } +} + +// VideoDetailPage 视频详情预览入口(不走模板缓存) +func VideoDetailPage(c *gin.Context) { + id := c.DefaultQuery("id", "") + if id == "" { + c.HTML(http.StatusBadRequest, "404", gin.H{"data": proto.VideoDetailsHtmlData{}}) + return + } + c.Params = gin.Params{{Key: "id", Value: id}} + c.Request.URL.Path = fmt.Sprintf("/videoDetail?id=%s", id) + VideoDetailInfo(c) +} + +// VideoListPage 视频列表预览入口(不走模板缓存) +func VideoListPage(c *gin.Context) { + id := c.DefaultQuery("id", "0") + sort := c.DefaultQuery("sort", "1") + page := c.DefaultQuery("page", "1") + c.Params = gin.Params{ + {Key: "id", Value: id}, + {Key: "sortType", Value: sort}, + {Key: "page", Value: page}, + } + c.Request.URL.Path = fmt.Sprintf("/videoList?id=%s&sort=%s&page=%s", id, sort, page) + VideoCategoryInfo(c) +} + +// VideoCategoryInfo 新版视频列表页 +func VideoCategoryInfo(c *gin.Context) { + data := proto.VideoTopicHtmlData{ + Path: c.Request.URL.Path, + Translations: translationser.GetTranslationData(), + BottonTranslations: translationser.GetTranslationBottomList(), + Config: configser.GetConfigInfo(), + Advitise: configser.GetAdvitise(), + } + topicStr := c.Param("id") + sortTypeStr := c.Param("sortType") + pageStr := c.Param("page") + + pageNum, err := strconv.ParseInt(pageStr, 10, 64) + if err != nil || pageNum < 1 { + pageNum = 1 + } + sortType, err := strconv.ParseInt(sortTypeStr, 10, 64) + if err != nil || sortType < 1 { + sortType = 0 + } + + if topicStr == "" { + renderVideoListPage(c, data, "0", 0, sortType, pageNum) + return + } + + matche := v10.ParamIsNumber(topicStr) + if matche { + publish, err := strconv.ParseInt(topicStr, 10, 64) + if err != nil { + c.HTML(http.StatusNotFound, "404", gin.H{"data": data}) + return + } + if publish == 0 { + renderVideoListPage(c, data, "0", 0, sortType, pageNum) + return + } + if publish < 1 { + c.HTML(http.StatusNotFound, "404", gin.H{"data": data}) + return + } + sort := videoTopicSortType(sortType) + newPath := fmt.Sprintf("/videocategory/%d/%d/%d/", publish, sort, pageNum) + c.Redirect(http.StatusMovedPermanently, newPath) + return + } + + topicId, err2 := strconv.ParseInt(topicStr, 10, 64) + if err2 != nil || topicId < 1 { + c.HTML(http.StatusNotFound, "404", gin.H{"data": data}) + return + } + topicSegment := topicStr + if topicSegment == "" { + topicSegment = "0" + } + renderVideoListPage(c, data, topicSegment, topicId, sortType, pageNum) +} + +func videoTopicSortType(sortType int64) int64 { + if sortType < 1 { + return int64(videoinformationmod.VideoSortType_New) + } + return sortType +} + +func renderVideoListPage(c *gin.Context, data proto.VideoTopicHtmlData, topicSegment string, topicId, sortType, pageNum int64) { + sort := videoTopicSortType(sortType) + data.CompletePath = fmt.Sprintf("/videocategory/%s/%d/%d/", topicSegment, sort, pageNum) + totalSize, videoInfos, _, serr := videoser.VideoTopicData(c, false, topicId, sortType, pageNum) + data.VideoInfos = videoInfos + data.PageNum = pageNum + data.PageSize = pubdef.PageSize + data.TotalSize = pubdef.GetTotalPage(totalSize, pubdef.PageSize) + if data.VideoInfos == nil { + data.VideoInfos = []proto.VideoInfoData{} + } + if serr != nil { + if c.Query("format") == "json" { + common.ServerJSON(c, srvdef.ErrNoData, data) + return + } + c.HTML(http.StatusNotFound, "404", gin.H{"data": data}) + return + } + if c.Query("format") == "json" { + common.ServerJSON(c, srvdef.Success, data) + return + } + c.HTML(http.StatusOK, "videoList", gin.H{ + "data": 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..bb4ff6a --- /dev/null +++ b/app/api/playletapi/playlet.go @@ -0,0 +1,766 @@ +package playletapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/playletser" + "videoweb/pub/common" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// HomePlaylet app端首页短剧接口 +// @Tags app短剧相关接口 +// @Summary app端首页短剧接口 --by 王鸣 +// @Description 无请求参数,返回首页banner短剧、公告和SEO信息 +// @Accept json +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// HomeConfigList app端分页查询首页配置内容 +// @Tags app短剧相关接口 +// @Summary app端分页查询首页配置内容 +// @Description 根据首页配置ID或slug分页查询短剧或题材,slug 优先 +// @Accept json +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// AllList app端分页查询全部短剧 +// @Tags app短剧相关接口 +// @Summary app端分页查询全部短剧 --by 王鸣 +// @Description 分页查询已上架短剧,支持题材、更新状态和排序筛选 +// @Accept json +// @Produce json +// @Param params query proto.PlayletAllListReq true "全部短剧分页请求" +// @Success 200 {object} proto.PlayletAllListResp{list=[]proto.CommonData} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/all/list [get] +func AllList(ctx *gin.Context) { + var req proto.PlayletAllListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.AllList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// HotList app端分页查询热门短剧 +// @Tags app短剧相关接口 +// @Summary app端分页查询热门短剧 +// @Description 按热度查询已上架短剧,可排除指定短剧 +// @Accept json +// @Produce json +// @Param params query proto.PlayletHotListReq true "热门短剧分页请求" +// @Success 200 {object} proto.PlayletHotListResp{list=[]proto.CommonData} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/hot/list [get] +func HotList(ctx *gin.Context) { + var req proto.PlayletHotListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.HotList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TopicList app端分页查询专题剧单 +// @Tags app短剧相关接口 +// @Summary app端分页查询专题剧单 --by 王鸣 +// @Description 分页查询各启用区块下有已上架短剧的专题卡片 +// @Accept json +// @Produce json +// @Param params query proto.PlayletTopicPageListReq true "专题剧单分页请求" +// @Success 200 {object} proto.PlayletTopicPageListResp{list=[]proto.PlayletTopicGroupPageItem} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/topic/list [get] +func TopicList(ctx *gin.Context) { + var req proto.PlayletTopicPageListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.TopicList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TopicDetail app端查询专题详情 +// @Tags app短剧相关接口 +// @Summary app端查询专题详情 --by 王鸣 +// @Description 按专题ID查询专题信息和专题短剧列表 +// @Accept json +// @Produce json +// @Param params query proto.PlayletTopicDetailReq true "专题详情请求" +// @Success 200 {object} proto.PlayletTopicDetailResp{list=[]proto.CommonData,moreTopics=[]proto.PlayletTopicPageItem} "成功" +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Rank app端分页查询短剧排行榜 +// @Tags app短剧相关接口 +// @Summary 短剧排行榜 --by 王鸣 +// @Description 分页查询已上架短剧排行榜;sortOrder=1按热度值倒序,2按上架时间倒序,3按收藏数倒序 +// @Accept json +// @Produce json +// @Param params query proto.PlayletRankReq true "分页查询短剧排行榜请求" +// @Success 200 {object} proto.PlayletRankResp{list=[]proto.CommonData} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/rank [get] +func Rank(ctx *gin.Context) { + var req proto.PlayletRankReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.Rank(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// HotRankList app端分页查询热榜 +// @Tags app短剧相关接口 +// @Summary app端分页查询热榜 --by 王鸣 +// @Description 分页查询已上架短剧热榜;rankType=1热播榜 2新剧榜 3追剧榜 +// @Accept json +// @Produce json +// @Param params query proto.PlayletHotRankListReq true "热榜分页请求" +// @Success 200 {object} proto.PlayletHotRankListResp{list=[]proto.CommonData} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/hot/rank/list [get] +func HotRankList(ctx *gin.Context) { + var req proto.PlayletHotRankListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.HotRankList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// OperateList app端按类型分页查询短剧 +// @Tags app短剧相关接口 +// @Summary app端按类型分页查询短剧 +// @Description type=1最新更新 2当前热播 3独家原创(筛选推荐短剧) 4随机推荐,随机推荐每日更新顺序 +// @Accept json +// @Produce json +// @Param params query proto.PlayletOperateListReq true "短剧分类列表分页请求" +// @Success 200 {object} proto.PlayletOperateListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/operate/list [get] +func OperateList(ctx *gin.Context) { + var req proto.PlayletOperateListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.OperateList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TodayUpdate app端分页查询今日更新短剧 +// @Tags app短剧相关接口 +// @Summary app端分页查询今日更新短剧 --by 王鸣 +// @Description 分页查询已上架短剧 +// @Accept json +// @Produce json +// @Param params query proto.PlayletTodayUpdateReq true "分页查询今日更新短剧请求" +// @Success 200 {object} proto.PlayletTodayUpdateResp{list=[]proto.CommonData} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/today-update [get] +func TodayUpdate(ctx *gin.Context) { + var req proto.PlayletTodayUpdateReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.TodayUpdate(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TagVideoList app端根据标签ID分页查询短剧 +// @Tags app短剧相关接口 +// @Summary app端根据标签ID分页查询短剧 --by 王鸣 +// @Description 根据标签ID或标签slug分页查询已上架短剧;slug非空时优先按slug解析标签ID;sortType=1最新 2最热 3收藏最多 +// @Accept json +// @Produce json +// @Param params query proto.PlayletTagVideoReq true "根据标签ID分页查询短剧请求" +// @Success 200 {object} proto.PlayletTagVideoResp{list=[]proto.CommonData} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/tag/list [get] +func TagVideoList(ctx *gin.Context) { + var req proto.PlayletTagVideoReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.TagVideoList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// GuessLikeList app端分页查询猜你喜欢短剧 +// @Tags app短剧相关接口 +// @Summary app端分页查询猜你喜欢短剧 --by 王鸣 +// @Description 根据当前短剧同题材查询猜你喜欢,排除当前短剧 +// @Accept json +// @Produce json +// @Param params query proto.PlayletGuessLikeListReq true "猜你喜欢分页请求" +// @Success 200 {object} proto.PlayletGuessLikeListResp{list=[]proto.CommonData} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/guess-like/list [get] +func GuessLikeList(ctx *gin.Context) { + var req proto.PlayletGuessLikeListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.GuessLikeList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Search app端ES分页搜索短剧 +// @Tags app短剧相关接口 +// @Summary app端ES分页搜索短剧 --by 王鸣 +// @Description 使用ES分页搜索已上架短剧,支持剧名、题材名、简介和标签名检索 +// @Accept json +// @Produce json +// @Param params query proto.PlayletSearchReq true "短剧ES分页搜索请求" +// @Success 200 {object} proto.PlayletSearchResp{list=[]proto.CommonData} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/search [get] +func Search(ctx *gin.Context) { + var req proto.PlayletSearchReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.Search(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SearchSuggest app端短剧搜索联想 +// @Tags app短剧相关接口 +// @Summary app端短剧搜索联想 --by 王鸣 +// @Description 搜索框下拉联想,返回短剧列表 +// @Accept json +// @Produce json +// @Param params query proto.PlayletSearchSuggestReq true "短剧搜索联想请求" +// @Success 200 {object} proto.PlayletSearchSuggestResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/search/suggest [get] +func SearchSuggest(ctx *gin.Context) { + var req proto.PlayletSearchSuggestReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.SearchSuggest(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// HotLike app端热搜词和猜你喜欢 +// @Tags app短剧相关接口 +// @Summary app端热搜词和猜你喜欢 +// @Description 返回缓存的前10个热搜词,以及按收藏量最多的15部猜你喜欢短剧 +// @Accept json +// @Produce json +// @Success 200 {object} proto.PlayletHotLikeResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/hot-like [get] +func HotLike(ctx *gin.Context) { + common.ServerJSON(ctx, srvdef.Success, playletser.HotLike(ctx)) +} + +// LikeList app端我的短剧点赞列表 +// @Tags app短剧相关接口 +// @Summary app端我的短剧点赞列表 +// @Description 按当前用户点赞时间倒序分页返回短剧 +// @Accept json +// @Produce json +// @Param params query proto.PlayletLikeListReq true "我的短剧点赞列表请求" +// @Success 200 {object} proto.PlayletLikeListResp{list=[]proto.PlayletLikeData} "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/like/list [get] +func LikeList(ctx *gin.Context) { + var req proto.PlayletLikeListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.LikeList(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetLike app端短剧点赞/取消点赞 +// @Tags app短剧相关接口 +// @Summary app端短剧点赞/取消点赞 --by 王鸣 +// @Description 设置当前用户短剧点赞状态,点赞数只在状态真实变化时增减 +// @Accept json +// @Produce json +// @Param param body proto.PlayletLikeSetReq true "短剧点赞设置请求" +// @Success 200 {object} proto.PlayletLikeSetResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/like/set [post] +func SetLike(ctx *gin.Context) { + var req proto.PlayletLikeSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.SetLike(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetCollect app端短剧收藏/取消收藏 +// @Tags app短剧相关接口 +// @Summary app端短剧收藏/取消收藏 --by 王鸣 +// @Description 设置当前用户短剧收藏状态,收藏数只在状态真实变化时增减 +// @Accept json +// @Produce json +// @Param param body proto.PlayletCollectSetReq true "短剧收藏设置请求" +// @Success 200 {object} proto.PlayletCollectSetResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/collect/set [post] +func SetCollect(ctx *gin.Context) { + var req proto.PlayletCollectSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.SetCollect(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ForwardHeat app端短剧转发增加热度 +// @Tags app短剧相关接口 +// @Summary app端短剧转发增加热度 --by 王鸣 +// @Description 请求一次短剧热度值增加5,仅支持已上架短剧 +// @Accept json +// @Produce json +// @Param param body proto.PlayletForwardHeatReq true "短剧转发加热度请求" +// @Success 200 {object} proto.PlayletForwardHeatResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/forward/heat [post] +func ForwardHeat(ctx *gin.Context) { + var req proto.PlayletForwardHeatReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.ForwardHeat(ctx, common.GetUserId(ctx), ipaddr.ResolveClientIP(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// CollectList app端我的短剧收藏列表 +// @Tags app短剧相关接口 +// @Summary app端我的短剧收藏列表 --by 王鸣 +// @Description 按当前用户收藏时间倒序分页返回短剧收藏列表,并返回已看/未看集数和最近观看位置;watchStatus=0全部 1追剧中 2已看完 +// @Accept json +// @Produce json +// @Param params query proto.PlayletCollectListReq true "我的短剧收藏列表请求" +// @Success 200 {object} proto.PlayletCollectListResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/collect/list [get] +func CollectList(ctx *gin.Context) { + var req proto.PlayletCollectListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.CollectList(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// BoughtList app端我的已购短剧列表 +// @Tags app短剧相关接口 +// @Summary app端我的已购短剧列表 --by 王鸣 +// @Description 按当前用户最近购买时间倒序分页返回已购短剧列表,并返回已购集数、消耗金币和最近观看位置 +// @Accept json +// @Produce json +// @Param params query proto.PlayletBoughtListReq true "我的已购短剧列表请求" +// @Success 200 {object} proto.PlayletBoughtListResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/bought/list [get] +func BoughtList(ctx *gin.Context) { + var req proto.PlayletBoughtListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.BoughtList(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// HistoryList app端我的短剧观看历史列表 +// @Tags app短剧相关接口 +// @Summary app端我的短剧观看历史列表 --by 王鸣 +// @Description 按当前用户最近观看时间倒序分页返回短剧观看历史;pageSize默认20、最大100 +// @Accept json +// @Produce json +// @Param params query proto.PlayletHistoryListReq true "观看历史列表请求" +// @Success 200 {object} proto.PlayletHistoryListResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/history/list [get] +func HistoryList(ctx *gin.Context) { + var req proto.PlayletHistoryListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.HistoryList(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// DeleteHistory app端删除单条短剧观看历史 +// @Tags app短剧相关接口 +// @Summary app端删除单条短剧观看历史 --by 王鸣 +// @Description 删除当前用户指定短剧的观看历史,不影响观看进度、收藏和已购记录 +// @Accept json +// @Produce json +// @Param param body proto.PlayletHistoryDeleteReq true "删除单条观看历史请求" +// @Success 200 {object} proto.PlayletHistoryDeleteResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/history/delete [post] +func DeleteHistory(ctx *gin.Context) { + var req proto.PlayletHistoryDeleteReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.DeleteHistory(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ClearHistory app端清空我的短剧观看历史 +// @Tags app短剧相关接口 +// @Summary app端清空我的短剧观看历史 --by 王鸣 +// @Description 仅清空观看历史,不影响收藏列表和观看进度 +// @Accept json +// @Produce json +// @Success 200 {object} proto.PlayletHistoryClearResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/history/clear [post] +func ClearHistory(ctx *gin.Context) { + resp, err := playletser.ClearHistory(ctx, common.GetUserId(ctx)) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetChapterLike app端短剧剧集点赞/取消点赞 +// @Tags app短剧相关接口 +// @Summary app端短剧剧集点赞/取消点赞 --by 王鸣 +// @Description 设置当前用户短剧剧集点赞状态,点赞数只在状态真实变化时增减 +// @Accept json +// @Produce json +// @Param param body proto.PlayletChapterLikeSetReq true "短剧剧集点赞设置请求" +// @Success 200 {object} proto.PlayletChapterLikeSetResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/chapter/like/set [post] +func SetChapterLike(ctx *gin.Context) { + var req proto.PlayletChapterLikeSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.SetChapterLike(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetChapterCollect app端短剧剧集收藏/取消收藏 +// @Tags app短剧相关接口 +// @Summary app端短剧剧集收藏/取消收藏 --by 王鸣 +// @Description 设置当前用户短剧剧集收藏状态,收藏数只在状态真实变化时增减 +// @Accept json +// @Produce json +// @Param param body proto.PlayletChapterCollectSetReq true "短剧剧集收藏设置请求" +// @Success 200 {object} proto.PlayletChapterCollectSetResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/chapter/collect/set [post] +func SetChapterCollect(ctx *gin.Context) { + var req proto.PlayletChapterCollectSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.SetChapterCollect(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ChapterCollectList app端我的短剧剧集收藏列表 +// @Tags app短剧相关接口 +// @Summary app端我的短剧剧集收藏列表 --by 王鸣 +// @Description 按当前用户收藏时间倒序分页返回短剧剧集收藏列表 +// @Accept json +// @Produce json +// @Param params query proto.PlayletChapterCollectListReq true "我的短剧剧集收藏列表请求" +// @Success 200 {object} proto.PlayletChapterCollectListResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/chapter/collect/list [get] +func ChapterCollectList(ctx *gin.Context) { + var req proto.PlayletChapterCollectListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.ChapterCollectList(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ReportChapterWatch app端短剧剧集观看上报 +// @Tags app短剧相关接口 +// @Summary app端短剧剧集观看上报 --by 王鸣 +// @Description 上报当前用户短剧剧集播放进度,达到已看条件时用于我的收藏未看集数计算 +// @Accept json +// @Produce json +// @Param param body proto.PlayletChapterWatchReportReq true "短剧剧集观看上报请求" +// @Success 200 {object} proto.PlayletChapterWatchReportResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/chapter/watch/report [post] +func ReportChapterWatch(ctx *gin.Context) { + var req proto.PlayletChapterWatchReportReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.ReportChapterWatch(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// UnlockChapter app端使用金币逐集解锁短剧剧集 +// @Tags app短剧相关接口 +// @Summary app端使用金币逐集解锁短剧剧集 --by 王鸣 +// @Description 仅金币付费剧集可调用;接口具备幂等性,已解锁剧集重复调用不会重复扣金币 +// @Accept json +// @Produce json +// @Param param body proto.PlayletChapterUnlockReq true "短剧剧集金币解锁请求" +// @Success 200 {object} proto.PlayletChapterUnlockResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/playlet/chapter/unlock [post] +func UnlockChapter(ctx *gin.Context) { + var req proto.PlayletChapterUnlockReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err1 := playletser.UnlockChapter(ctx, common.GetUserId(ctx), &req) + if err1 != nil { + common.ServerJSON(ctx, err1.Code, err1.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Detail app端根据ID查询短剧详情 +// @Tags app短剧相关接口 +// @Summary app端根据ID查询短剧详情 --by 王鸣 +// @Description 根据短剧ID或slug查询已上架短剧详情;slug非空时优先按slug解析短剧ID +// @Produce json +// @Param params query proto.PlayletInfoReq true "短剧详情请求" +// @Success 200 {object} proto.PlayletInfoResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/detail [get] +func Detail(ctx *gin.Context) { + var req proto.PlayletInfoReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.Detail(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ChapterDetail app端根据ID查询短剧剧集详情 +// @Tags app短剧相关接口 +// @Summary app端根据ID查询短剧剧集详情 --by 王鸣 +// @Description 根据短剧剧集ID查询播放页当前剧集详情;可传playletId或playletSlug校验剧集归属,slug非空时优先解析短剧ID +// @Produce json +// @Param params query proto.PlayletChapterInfoReq true "短剧剧集详情请求" +// @Success 200 {object} proto.PlayletChapterInfoResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/chapter/detail [get] +func ChapterDetail(ctx *gin.Context) { + var req proto.PlayletChapterInfoReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := playletser.ChapterDetail(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ChapterList app端根据短剧ID查询剧集列表 +// @Tags app短剧相关接口 +// @Summary app端根据短剧ID查询剧集列表 --by 王鸣 +// @Description 根据短剧ID或slug查询右侧选集列表;slug非空时优先按slug解析短剧ID +// @Produce json +// @Param params query proto.PlayletChapterListReq true "短剧剧集列表请求" +// @Success 200 {object} proto.PlayletChapterListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/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 := playletser.ChapterList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/app/api/playlettagapi/tag.go b/app/api/playlettagapi/tag.go new file mode 100644 index 0000000..713fdd2 --- /dev/null +++ b/app/api/playlettagapi/tag.go @@ -0,0 +1,27 @@ +package playlettagapi + +import ( + "videoweb/app/service/playlettagser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// List app端短剧标签列表 +// @Tags app短剧标签相关接口 +// @Summary app端短剧标签列表 --by 王鸣 +// @Description 无请求参数,不分页,返回已上架短剧标签列表 +// @Accept json +// @Produce json +// @Success 200 {object} proto.PlayletTagListResponse "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/playlet/tag/options [get] +func List(ctx *gin.Context) { + resp, err := playlettagser.List(ctx) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/app/api/playletthemeapi/theme.go b/app/api/playletthemeapi/theme.go new file mode 100644 index 0000000..9facaf1 --- /dev/null +++ b/app/api/playletthemeapi/theme.go @@ -0,0 +1,27 @@ +package playletthemeapi + +import ( + "videoweb/app/service/playletthemeser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// List app端短剧题材列表 +// @Tags app短剧题材相关接口 +// @Summary app端短剧题材列表 --by 王鸣 +// @Description 无请求参数,不分页,返回已上架题材列表 +// @Accept json +// @Produce json +// @Success 200 {object} proto.PlayletThemeListResponse "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/theme/list [get] +func List(ctx *gin.Context) { + resp, err := playletthemeser.List(ctx) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/app/api/proto/about.go b/app/api/proto/about.go new file mode 100644 index 0000000..2f04774 --- /dev/null +++ b/app/api/proto/about.go @@ -0,0 +1,28 @@ +package proto + +// AboutNodeItem 关于我们节点 +type AboutNodeItem struct { + NodeId int64 `json:"nodeId"` + Title string `json:"title"` + SubTitle string `json:"subTitle"` + Content string `json:"content"` // JSON字符串 + //类型描述: + //文本:录入纯文本信息 + //声明:图片、文本信息 + //条目:可以录入多条,可删可增 + //收录:收录类型(文章、网址、工具)、价格、单位、时效、描述 + //图文:图片、文本信息 + //注意:图片、文本信息 + Type int `json:"type"` + ServiceDuration string `json:"serviceDuration"` // 服务时效 + DurationIcon string `json:"durationIcon"` // 时效图标(CDN已处理) + Sort int `json:"sort"` +} + +// AboutData 关于我们页面数据 +type AboutData struct { + Intro string `json:"intro"` // 简介 + Nodes []*AboutNodeItem `json:"nodes"` // 节点列表 + Categories []*ToolCategoryItem `json:"categories"` // 分类列表 + Site SiteCommonData `json:"site"` // 通用站点数据 +} diff --git a/app/api/proto/ads.go b/app/api/proto/ads.go new file mode 100644 index 0000000..ce87080 --- /dev/null +++ b/app/api/proto/ads.go @@ -0,0 +1,34 @@ +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"` // 弹窗广告 + BottomBanner []proxy.AdvitiseInfo `json:"bottomBanner" bson:"bottomBanner"` // 底部banner + + VideoInfoList []proxy.AdvitiseInfo `json:"videoInfoList" bson:"videoInfoList"` // 视频详情 + DownList []proxy.AdvitiseInfo `json:"downList" bson:"downList"` // 底部广告 + FriendList []proxy.AdvitiseInfo `json:"friendList" bson:"friendList"` // 友链 + TitleList []proxy.AdvitiseInfo `json:"titleList" bson:"titleList"` // 标题广告 + PlayList []proxy.AdvitiseInfo `json:"playList" bson:"playList"` // 播放强制跳转 + PauseyList []proxy.AdvitiseInfo `json:"pauseyList" bson:"pauseyList"` // 暂停广告 +} diff --git a/app/api/proto/aitools.go b/app/api/proto/aitools.go new file mode 100644 index 0000000..82029f4 --- /dev/null +++ b/app/api/proto/aitools.go @@ -0,0 +1,261 @@ +package proto + +import "videoweb/pub/pubdef" + +// -------- 公共站点数据 -------- + +// FriendlyLinkItem 友情链接 +type FriendlyLinkItem struct { + Id int64 `json:"id"` + Name string `json:"name"` + Url string `json:"url"` + Sort int `json:"sort"` +} + +// ContactInfoItem 联系方式 +type ContactInfoItem struct { + Id int64 `json:"id"` + Icon string `json:"icon"` + Type int `json:"type"` // 1=邮箱 2=URL 3=文字 + Address string `json:"address"` + Sort int `json:"sort"` +} + +// SiteCommonData 每个接口都会携带的通用站点信息 +type SiteCommonData struct { + Config ConfigInfo `json:"config"` + FriendlyLinks []*FriendlyLinkItem `json:"friendlyLinks"` + ContactInfos []*ContactInfoItem `json:"contactInfos"` + Advitise AdvitiseData `json:"advitise"` + ToolCount int64 `json:"toolCount"` // 审核通过的工具总数 +} + +// -------- 分类 -------- + +// ToolSubCategoryItem 子分类简要信息 +type ToolSubCategoryItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + CategoryId int64 `json:"categoryId"` + SeoInfo pubdef.SeoValue `json:"seoInfo"` +} + +// ToolCategoryItem 主分类简要信息 +type ToolCategoryItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Icon string `json:"icon"` + Sort int `json:"sort"` + SeoInfo pubdef.SeoValue `json:"seoInfo"` + SubCategories []*ToolSubCategoryItem `json:"subCategories"` +} + +// -------- 工具 -------- + +// ToolBriefItem 工具简要信息(列表卡片) +type ToolBriefItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Logo string `json:"logo"` + Snapshot string `json:"snapshot"` + Intro string `json:"intro"` + OfficialUrl string `json:"officialUrl"` + Hot int64 `json:"hot"` +} + +// CategorySection 首页分类区块:分类标题 + 子分类列表 + 前10条工具 +type CategorySection struct { + CategoryId int64 `json:"categoryId"` + CategorySlug string `json:"categorySlug"` + CategoryName string `json:"categoryName"` + CategoryIcon string `json:"categoryIcon"` + CategorySort int `json:"categorySort"` + SubCategories []*ToolSubCategoryItem `json:"subCategories"` + Tools []*ToolBriefItem `json:"tools"` +} + +// ToolNodeItem 工具节点 +type ToolNodeItem struct { + NodeId int64 `json:"nodeId"` + Title string `json:"title"` + Type int `json:"type"` // 1=文本 2=条目 3=QA 4=图片 + Content string `json:"content"` // JSON字符串,格式因类型而异 + Sort int `json:"sort"` + ReviewStatus int `json:"reviewStatus"` // 0=待审核 1=通过 2=下架 +} + +// ToolRecommendItem 推荐工具简要信息 +type ToolRecommendItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Intro string `json:"intro"` + Logo string `json:"logo"` +} + +// ArticleRecommendItem 推荐文章简要信息 +type ArticleRecommendItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Intro string `json:"intro"` + Cover string `json:"cover"` + CreatedAt string `json:"createdAt"` // 格式:yyyy/MM/dd +} + +// ToolDetailItem 工具详情 +type ToolDetailItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + CategoryId int64 `json:"categoryId"` + CategoryName string `json:"categoryName"` + SubCateId int64 `json:"subCateId"` + SubCateName string `json:"subCateName"` + Title string `json:"title"` + Logo string `json:"logo"` + Snapshot string `json:"snapshot"` + Intro string `json:"intro"` + Desc string `json:"desc"` + SeoInfo pubdef.SeoValue `json:"seoInfo"` + OfficialUrl string `json:"officialUrl"` + Hot int64 `json:"hot"` + ViewCount int64 `json:"viewCount"` + Nodes []*ToolNodeItem `json:"nodes"` + RelatedTools []*ToolRecommendItem `json:"relatedTools"` // 相关推荐(优先同子分类,不足补其他分类) + RecommendTools []*ToolRecommendItem `json:"recommendTools"` // 工具推荐(随机6个) + RecommendArticles []*ArticleRecommendItem `json:"recommendArticles"` // 文章推荐(随机6个) + CommentList GetCommentListResp `json:"commentList"` // 评论 + Categories []*ToolCategoryItem `json:"categories"` // 分类列表 + Site SiteCommonData `json:"site"` +} + +// -------- 文章 -------- + +// ArticleBriefItem 文章简要信息(列表卡片) +type ArticleBriefItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Intro string `json:"intro"` + Author string `json:"author"` + AuthorAvatar string `json:"authorAvatar"` + Cover string `json:"cover"` + ViewCount int64 `json:"viewCount"` + Sort int `json:"sort"` + CreatedAt string `json:"createdAt"` // 格式:yyyy/MM/dd +} + +// ArticleNodeItem 文章节点 +type ArticleNodeItem struct { + NodeId int64 `json:"nodeId"` + Title string `json:"title"` + Type int `json:"type"` // 1=文本 2=条目 3=QA 4=图片 + Content string `json:"content"` // JSON字符串,格式因类型而异 + Sort int `json:"sort"` +} + +// ArticleNavItem 文章导航(上一篇/下一篇) +type ArticleNavItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` +} + +// ArticleDetailItem 文章详情 +type ArticleDetailItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Intro string `json:"intro"` + Author string `json:"author"` + AuthorAvatar string `json:"authorAvatar"` + Cover string `json:"cover"` + SeoInfo pubdef.SeoValue `json:"seoInfo"` + ViewCount int64 `json:"viewCount"` + Sort int `json:"sort"` + ReviewStatus int `json:"reviewStatus"` + CreatedAt string `json:"createdAt"` // 格式:yyyy/MM/dd + Nodes []*ArticleNodeItem `json:"nodes"` + Prev *ArticleNavItem `json:"prev"` // 上一篇(无则为 null) + Next *ArticleNavItem `json:"next"` // 下一篇(无则为 null) + RecommendTools []*ToolRecommendItem `json:"recommendTools"` // 工具推荐(随机6个) + RecommendArticles []*ArticleRecommendItem `json:"recommendArticles"` // 文章推荐(随机6个) + CommentList GetCommentListResp `json:"commentList"` // 评论 + Categories []*ToolCategoryItem `json:"categories"` // 分类列表 + Site SiteCommonData `json:"site"` +} + +// -------- 页面级响应 -------- + +// AIToolsHomeData 工具首页数据 +type AIToolsHomeData struct { + Categories []*ToolCategoryItem `json:"categories"` + HotTools []*ToolBriefItem `json:"hotTools"` // 热门推荐工具(按设置时间倒序) + Sections []*CategorySection `json:"sections"` + Articles []*ArticleBriefItem `json:"articles"` + Site SiteCommonData `json:"site"` +} + +// AIToolsListData 工具列表页数据 +type AIToolsListData struct { + Categories []*ToolCategoryItem `json:"categories"` + Total int64 `json:"total"` + List []*ToolBriefItem `json:"list"` + Site SiteCommonData `json:"site"` +} + +// SearchArticleItem 搜索命中的文章 +type SearchArticleItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Intro string `json:"intro"` + Author string `json:"author"` + AuthorAvatar string `json:"authorAvatar"` + Cover string `json:"cover"` + CreatedAt string `json:"createdAt"` // 格式:yyyy/MM/dd + Score float32 `json:"score"` // 相关度分数 +} + +// SearchToolItem 搜索命中的工具 +type SearchToolItem struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + CategoryId int64 `json:"categoryId"` + SubCateId int64 `json:"subCateId"` + Title string `json:"title"` + Logo string `json:"logo"` + Snapshot string `json:"snapshot"` + Intro string `json:"intro"` + OfficialUrl string `json:"officialUrl"` + Hot int64 `json:"hot"` + Score float32 `json:"score"` // 相关度分数 +} + +// AISearchData 搜索结果 +type AISearchData struct { + Keyword string `json:"keyword"` + Articles []*SearchArticleItem `json:"articles"` + Tools []*SearchToolItem `json:"tools"` + Site SiteCommonData `json:"site"` +} + +// AIRecommendData 推荐展示数据 +type AIRecommendData struct { + Articles []*ArticleBriefItem `json:"articles"` // 随机2篇文章 + Tools []*ToolBriefItem `json:"tools"` // 随机20个工具 + Site SiteCommonData `json:"site"` +} + +// AIArticleListData 文章列表页数据 +type AIArticleListData struct { + Categories []*ToolCategoryItem `json:"categories"` + Total int64 `json:"total"` + List []*ArticleBriefItem `json:"list"` + Site SiteCommonData `json:"site"` + RecommendTools []*ToolRecommendItem `json:"recommendTools"` // 工具推荐(随机6个) + RecommendArticles []*ArticleRecommendItem `json:"recommendArticles"` // 文章推荐(随机6个) +} diff --git a/app/api/proto/appinfo.go b/app/api/proto/appinfo.go new file mode 100644 index 0000000..3aee214 --- /dev/null +++ b/app/api/proto/appinfo.go @@ -0,0 +1,55 @@ +package proto + +// AppListResp 应用列表接口响应 +type AppListResp struct { + // 当前路径 + Path string `json:"path"` + // 配置信息 + Config ConfigInfo `json:"config"` + // 广告 + Advitise AdvitiseData `json:"advitise"` + // 应用分类列表(含各分类下的应用列表) + 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/coin.go b/app/api/proto/coin.go new file mode 100644 index 0000000..6bc9a7f --- /dev/null +++ b/app/api/proto/coin.go @@ -0,0 +1,35 @@ +package proto + +import "videoweb/pub/models/cointransmod" + +// CoinTransListItemResp 金币余额明细列表项 +type CoinTransListItemResp struct { + // 流水ID + Id int64 `json:"id"` + // 标题,优先使用备注,备注为空时使用交易类型名称 + Title string `json:"title"` + // 收支方向:1-收入,-1-支出 + FlowDir cointransmod.FlowDir `json:"flowDir"` + // 收支方向名称:收入/支出 + FlowDirName string `json:"flowDirName"` + // 交易类型 1-金币充值(购买金币)2-购买会员赠送金币 3-历史遗留 4-视频解锁 5-管理员手动调整 + TranType cointransmod.TranType `json:"tranType"` + // 交易类型名称 + TranTypeName string `json:"tranTypeName"` + // 本次交易金币数,始终为正数 + Amount int64 `json:"amount"` + // 带方向的金币变动数,收入为正数,支出为负数 + ChangeAmount int64 `json:"changeAmount"` + // 交易前余额 + BalanceBefore int64 `json:"balanceBefore"` + // 交易后余额 + BalanceAfter int64 `json:"balanceAfter"` + // 关联订单号 + OrderNo string `json:"orderNo"` + // 关联内容ID + RelatedId int64 `json:"relatedId"` + // 备注 + Remark string `json:"remark"` + // 交易时间,格式:2006-01-02 15:04:05 + CreatedAt string `json:"createdAt"` +} diff --git a/app/api/proto/comment.go b/app/api/proto/comment.go new file mode 100644 index 0000000..e5e244c --- /dev/null +++ b/app/api/proto/comment.go @@ -0,0 +1,65 @@ +package proto + +import ( + "time" + "videoweb/pub/pubdef" +) + +// GetCommentListReq 评论列表请求 +type GetCommentListReq struct { + ObjectId int64 `form:"objectId" json:"objectId" binding:"required"` // 对象ID(短剧id 短剧剧集id 视频id) + ObjectType int64 `form:"objectType" json:"objectType" binding:"required"` // 对象类型 10-短剧 11-短剧剧集 20-视频 + SortOrder int64 `form:"sortOrder" json:"sortOrder"` // 排序 1-热门,按点赞数倒序 2-最新,按创建时间倒序 + pubdef.Page +} + +// GetCommentListResp 评论列表响应 +type GetCommentListResp struct { + ObjectId int64 `json:"objectId"` // 对象ID + TotalCount int64 `json:"totalCount"` // 总数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []CommentInfo `json:"list"` // 数据 +} + +// CommentSendReq 评论发布请求 +type CommentSendReq struct { + ObjectId int64 `json:"objectId"` // 对象ID,短剧评论传短剧ID,剧集评论传剧集ID,视频评论传视频ID + ObjectType int64 `json:"objectType"` // 对象类型 10-短剧 11-短剧剧集 20-视频 + ParentsId int64 `json:"parentsId"` // 一级评论ID,0-发布一级评论,大于0-发布二级评论 + ReplyCommentId int64 `json:"replyCommentId"` // 被回复评论ID,0时默认回复parentsId + Text string `json:"text"` // 评论内容 +} + +// CommentLikeSetReq 评论点赞设置请求 +type CommentLikeSetReq struct { + CommentId int64 `json:"commentId" binding:"required"` // 评论ID + IsLike *bool `json:"isLike" binding:"required"` // 是否点赞,true-点赞,false-取消点赞 +} + +// CommentLikeSetResp 评论点赞设置响应 +type CommentLikeSetResp struct { + CommentId int64 `json:"commentId"` // 评论ID + IsLike int64 `json:"isLike"` // 点赞状态 1-已点赞 2-未点赞 + CountLike int64 `json:"countLike"` // 评论点赞数 +} + +// CommentInfo 评论信息 +type CommentInfo struct { + Id int64 `json:"id"` // 评论ID + ObjectId int64 `json:"objectId"` // 对象ID + ObjectType int64 `json:"objectType"` // 对象类型 10-短剧 11-短剧剧集 20-视频 + UserId int64 `json:"userId"` // 评论用户ID + UserName string `json:"userName"` // 用户名称 + UserAvatar string `json:"userAvatar"` // 用户头像,已拼接图片域名 + Text string `json:"text"` // 评论内容 + Level int64 `json:"level"` // 评论级别 1-一级评论 2-二级评论 + ParentsId int64 `json:"parentsId"` // 父评论ID,一级评论为0 + ReplyCommentId int64 `json:"replyCommentId"` // 被回复评论ID,一级评论为0 + ReplyUserName string `json:"replyUserName"` // 被回复用户昵称 + CommentCount int64 `json:"commentCount"` // 二级评论数量 + CountLike int64 `json:"countLike"` // 评论点赞数 + IsLike int64 `json:"isLike"` // 当前用户点赞状态 1-已点赞 2-未点赞 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + Children []CommentInfo `json:"children"` // 二级评论列表 +} diff --git a/app/api/proto/config.go b/app/api/proto/config.go new file mode 100644 index 0000000..caa2490 --- /dev/null +++ b/app/api/proto/config.go @@ -0,0 +1,125 @@ +package proto + +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下载地址(落地页) + OfficialAppAddr string `json:"officialAppAddr" bson:"officialAppAddr"` //官网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"` // 所有使用中的域名 + AdvitiesTopicNum int64 `json:"advitiesTopicNum" bson:"advitiesTopicNum"` // 广告间隔 + 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网站名称 + ActressBackGround string `json:"actressBackGround" bson:"actressBackGround"` // 女优通用背景图 + ReportSDKConfig string `json:"reportSdkConfig"` // 数据上报SDK域名配置 + WatermarkImage string `json:"watermarkImage"` // 图片水印文件 + CustomerServiceHost string `json:"customerServiceHost"` // 在线客服 + ChannelCode string `json:"channelCode"` // 渠道码 + EnableH265 bool `json:"enableH265"` // 是否允许客户端使用 H265 地址播放 +} + +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"` // 链接 +} + +// ConfigInfoResp APP配置响应 +type ConfigInfoResp struct { + // 最新地址信息 + NewAddressInfo NewAddressInfo `json:"newAddressInfo"` + // 完整系统配置(与首页等接口的 config 一致,含 potaoGroup 等其余字段) + Config ConfigInfo `json:"config"` + // 广告配置 + Advitise AdvitiseData `json:"advitise"` + // 全站SEO信息 + SeoInfo SeoInfo `json:"seoInfo"` + // 导航SEO信息 + SeoNavInfo SeoNavInfo `json:"seoNavInfo"` + // 导航页线路集合 + NavPageList []NavPageItem `json:"navPageList"` + // 数据上报SDK域名配置 + ReportSDKConfig string `json:"reportSdkConfig"` +} + +// SeoNavInfo 导航SEO信息 +type SeoNavInfo struct { + DHTitle string `json:"dhTitle"` // 导航标题 + DHKeyworld []string `json:"dhKeyworld"` // 关键词 + DHDescription string `json:"dhDescription"` // 导航描述 + DHSiteName string `json:"dhSiteName"` // 导航名称 +} + +// NavPageItem 导航页线路 +type NavPageItem struct { + Line int64 `json:"line"` // 线路序号 + Url string `json:"url"` // 线路地址 +} + +// 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"` // 联系方式值 +} + +// NewAddressInfo 最新地址 +type NewAddressInfo struct { + NewUrlAddr string `json:"newUrlAddr"` // 新国内地址 + NewUrlAddr1 string `json:"newUrlAddr1"` // 备用地址1 + NewUrlAddr2 string `json:"newUrlAddr2"` // 备用地址1 + ForeverUrlAddr string `json:"foreverUrlAddr"` // 永久海外地址(翻墙访问 永不迷路) + GmailAddr string `json:"gmailAddr"` // 邮箱地址 + TgGroup string `json:"tgGroup"` // tg群 + Telegram string `json:"telegram" bson:"telegram"` // 商务tg + CustomerServiceHost string `json:"customerServiceHost"` // 在线客服 + TwitterAddr string `json:"twitterAddr"` // 推特地址 + PdfAddr string `json:"pdfAddr"` // 回家的路PDF + AppAddr string `json:"appAddr"` // APP下载地址(落地页) + OfficialAppAddr string `json:"officialAppAddr"` // 官网app地址 +} diff --git a/app/api/proto/contactus.go b/app/api/proto/contactus.go new file mode 100644 index 0000000..16b7757 --- /dev/null +++ b/app/api/proto/contactus.go @@ -0,0 +1,21 @@ +package proto + +// ContactUsAddReq 联系我们请求 +type ContactUsAddReq struct { + // 邮箱 + Email string `json:"email" binding:"required"` + // 问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他 + Type int32 `json:"type" binding:"required"` + // 标题 + Title string `json:"title" binding:"required"` + // 内容 + Content string `json:"content" binding:"required"` + // 附件地址,可存放多张图片或文件地址 + Attachments []string `json:"attachments"` +} + +// ContactUsAddResp 联系我们响应 +type ContactUsAddResp struct { + // 提示信息 + Message string `json:"message"` +} 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..b34cbfb --- /dev/null +++ b/app/api/proto/dh.go @@ -0,0 +1,37 @@ +package proto + +// 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"` // 配置文件 + RichTextInfo RichTextInfo `json:"richTextInfo" form:"richTextInfo"` // 富文本 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 +} + +// 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"` // 配置文件 + 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"` // 配置文件 + RichTextInfo RichTextInfo `json:"richTextInfo" form:"richTextInfo"` // 富文本 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Advitise AdvitiseData `json:"advitise" form:"advitise"` // 广告 +} diff --git a/app/api/proto/feedback.go b/app/api/proto/feedback.go new file mode 100644 index 0000000..94b596f --- /dev/null +++ b/app/api/proto/feedback.go @@ -0,0 +1,19 @@ +package proto + +// FeedbackReq 意见反馈请求 +type FeedbackReq struct { + // 问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他 + Type int32 `json:"type" binding:"required"` + // 描述内容,最多500字 + Content string `json:"content" binding:"required"` + // 问题截图,可存放多张图片地址 + Screenshots []string `json:"screenshots"` + // 联系方式,可为空 + Contact string `json:"contact"` +} + +// FeedbackResp 意见反馈响应 +type FeedbackResp struct { + // 提示信息 + Message string `json:"message"` +} diff --git a/app/api/proto/findVideo.go b/app/api/proto/findVideo.go new file mode 100644 index 0000000..2fbf1d1 --- /dev/null +++ b/app/api/proto/findVideo.go @@ -0,0 +1,5 @@ +package proto + +type FindVideoData struct { + RichText []RichTextInfo `json:"richText" form:"richText"` // 富文本内容 +} 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/lawandtrust.go b/app/api/proto/lawandtrust.go new file mode 100644 index 0000000..a220011 --- /dev/null +++ b/app/api/proto/lawandtrust.go @@ -0,0 +1,12 @@ +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"` // 广告 + 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..ccd4be5 --- /dev/null +++ b/app/api/proto/playlet.go @@ -0,0 +1,663 @@ +package proto + +import ( + "time" + "videoweb/pub/pubdef" +) + +// HomePlayletResponse 首页响应 +type HomePlayletResponse struct { + HomeBannerData []*CommonData `json:"homeBannerData"` // 首页banner短剧信息列表 + Modules []*HomeModuleData `json:"modules"` // 首页短剧和题材模块 + Announcements []*AnnouncementItem `json:"announcements"` // 启用中的公告,来自独立缓存 + Advitise AdvitiseData `json:"advitise"` // 广告 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` // 页面SEO信息(短剧列表页SEO) +} + +// HomeConfigData 首页配置数据 +type HomeConfigData struct { + Id int64 `json:"id"` // ID + Name string `json:"name"` // 名称 + Slug string `json:"slug"` // slug,H5 更多页优先用它 + Desc string `json:"desc"` // 描述 + Keyword []string `json:"keyword"` // 页面关键字 + Label string `json:"label"` + Style string `json:"style"` + CardBackgroundImage string `json:"cardBackgroundImage"` + TopicIds []int64 `json:"topicIds"` + CategoryId int64 `json:"categoryId"` + RecommendType int64 `json:"recommendType"` + Type int32 `json:"type"` // 类型 1-短剧 3-题材 + Rank int64 `json:"rank"` // 排序,越大越靠前 + Limit int64 `json:"limit"` // 首页展示数量 + SortType int32 `json:"sortType"` // 短剧模块排序 1-推荐 2-热播 3-最近更新 4-上架时间 5-分类,题材模块预览按专题详情默认排序 + Status bool `json:"status"` // 上下架状态 true-上架 false-下架 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 修改时间 + List []*CommonData `json:"list"` // 配置对应短剧集合,type=1时返回 + ThemeList []*HomeConfigThemeData `json:"themeList"` // 题材集合,type=3时返回 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +type HomeModuleData struct { + Id int64 `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Desc string `json:"desc"` + Keyword []string `json:"keyword"` // 页面关键字 + Label string `json:"label"` + Style string `json:"style"` + CardBackgroundImage string `json:"cardBackgroundImage"` + ModuleType int32 `json:"moduleType"` // 1 短剧列表,2 题材 + DataType int32 `json:"dataType"` // 固定为 1 短剧 + RecommendType int64 `json:"recommendType"` + CategoryId int64 `json:"categoryId"` + Rank int64 `json:"rank"` + Limit int64 `json:"limit"` // 实际生效的首页展示数量 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // 首页配置表中的SEO信息 + List []*CommonData `json:"list"` + TopicList []*HomeConfigThemeData `json:"topicList"` +} + +type PlayletHomeConfigListReq struct { + pubdef.Page + Id int64 `form:"id"` + Slug string `form:"slug"` +} +type PlayletHomeConfigListResp struct { + Config *HomeModuleData `json:"config"` + TotalCount int64 `json:"totalCount"` + PageSize int `json:"pageSize"` + PageNum int `json:"pageNum"` + List []*CommonData `json:"list"` + TopicList []*HomeConfigThemeData `json:"topicList"` + Advitise AdvitiseData `json:"advitise"` +} + +// HomeConfigThemeData 首页配置题材数据 +type HomeConfigThemeData struct { + Id int64 `json:"id"` // 题材ID + Name string `json:"name"` // 题材名称 + Slug string `json:"slug"` // 题材slug + Icon string `json:"icon"` // 题材图标,已拼接图片域名 + Rank int64 `json:"rank"` // 排序,越大越靠前 + PlayletCount int64 `json:"playletCount"` // 题材下已上架短剧总数 + List []*CommonData `json:"list"` // 题材下短剧集合 + SeoInfo *SeoInfo `json:"seoInfo"` // 题材对应页面SEO信息 +} + +// AnnouncementItem 首页公告 +type AnnouncementItem struct { + Id int64 `json:"id"` // 公告ID + Title string `json:"title"` // 标题 + Content string `json:"content"` // 内容 +} + +// PlayletAllListReq 全部短剧分页请求 +type PlayletAllListReq struct { + pubdef.Page + ThemeId int64 `form:"themeId" json:"themeId"` // 题材ID + ThemeSlug string `form:"themeSlug" json:"themeSlug"` // 题材slug,非空时优先按slug解析题材ID + UpdateStatus int `form:"updateStatus" json:"updateStatus"` // 更新状态 0-全部 1-连载中 2-已完结 + SortType int64 `form:"sortType" json:"sortType"` // 排序 1-最新 2-最热 3-收藏最多 +} + +// PlayletAllListResp 全部短剧分页响应 +type PlayletAllListResp struct { + TotalCount int64 `json:"totalCount"` // 总共数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []CommonData `json:"list"` // 短剧集合 + Advitise AdvitiseData `json:"advitise"` // 广告 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` // 页面SEO信息 +} + +// PlayletHotListReq 热门短剧分页请求 +type PlayletHotListReq struct { + pubdef.Page + ExcludePlayletId int64 `form:"excludePlayletId" json:"excludePlayletId"` // 排除的短剧ID,默认不排除 +} + +// PlayletHotListResp 热门短剧分页响应 +type PlayletHotListResp struct { + TotalCount int64 `json:"totalCount"` // 总共数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []CommonData `json:"list"` // 短剧集合 +} + +// CommonData 首页通用数据 +type CommonData struct { + Id int64 `json:"id"` // 短剧id + Status int32 `json:"status"` // 状态: 0下架 1上架 + IsFeatured bool `json:"isFeatured"` // 是否爆剧(true-是,false-否) + IsOriginal bool `json:"isOriginal"` // 是否原创 + ShowOriginal bool `json:"showOriginal"` // 页面是否展示原创标识 + IsRecommend bool `json:"isRecommend"` // 是否推荐 true-推荐,false-不推荐 + IsNew bool `json:"isNew"` // 是否上新,本周内上架为true + OperateType int32 `json:"operateType"` // 运营类型 1-短剧 2-漫剧 + ShowOnBanner bool `json:"showOnBanner"` // 是否显示在首页banner + Banner string `json:"banner"` // banner图,已拼接图片域名 + MobileBanner string `json:"mobileBanner"` // 移动端banner图,已拼接图片域名 + OnlineAt time.Time `json:"onlineAt"` // 上架时间 + MediaCreatedAt time.Time `json:"mediaCreatedAt"` // 媒资中心创建时间 + MediaUpdatedAt time.Time `json:"mediaUpdatedAt"` // 媒资中心更新时间 + ThemeIds []int64 `json:"themeIds"` // 题材ID集合 + ThemeNames []string `json:"themeNames"` // 题材名称集合 + ThemeSlugs []string `json:"themeSlugs"` // 题材slug集合 + CategoryIds []int64 `json:"categoryIds"` // 分类ID集合 + Title string `json:"title"` // 标题 + PlayName string `json:"playName"` // 剧名 + Description string `json:"description"` // SEO Description + Slug string `json:"slug"` // slug + Desc string `json:"desc"` // 简介 + Anchor string `json:"anchor"` // 作者/演员 + Cover string `json:"cover"` // 竖版封面,已拼接图片域名 + HorizontalCover string `json:"horizontalCover"` // 横版封面,已拼接图片域名 + Preview string `json:"preview"` // 预告地址,详情接口返回时带播放签名 + TagIds []int64 `json:"tagIds"` // 标签ID集合 + TagNames []string `json:"tagNames"` // 标签名称集合 + TagSlugs []string `json:"tagSlugs"` // 标签slug集合 + 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"` // 点赞数,包含真实点赞数和后台虚拟点赞数 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 修改时间 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletRankReq 短剧排行榜请求 +type PlayletRankReq struct { + // 普通分页参数,pageNum默认1,pageSize默认10、最大100 + pubdef.Page + SortOrder int64 `form:"sortOrder" json:"sortOrder"` // 排序 1-热播榜,按热度值倒序 2-新剧榜,按上架时间倒序 3-追剧榜,按收藏数倒序 +} + +// PlayletRankResp 短剧排行榜响应 +type PlayletRankResp struct { + TotalCount int64 `json:"totalCount"` // 总共数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []CommonData `json:"list"` // 短剧集合 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` // 页面SEO信息(短剧列表页SEO) +} + +// PlayletHotRankListReq 热榜分页请求 +type PlayletHotRankListReq struct { + pubdef.Page + RankType int64 `form:"rankType" json:"rankType"` // 榜单类型 1-热播榜 2-新剧榜 3-追剧榜 +} + +// PlayletHotRankListResp 热榜分页响应 +type PlayletHotRankListResp struct { + TotalCount int64 `json:"totalCount"` // 总共数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + RankType int64 `json:"rankType"` // 榜单类型 1-热播榜 2-新剧榜 3-追剧榜 + List []CommonData `json:"list"` // 短剧集合 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` // 页面SEO信息 +} + +// PlayletOperateListReq 短剧分类列表分页请求 +type PlayletOperateListReq struct { + pubdef.Page + Type int64 `form:"type" json:"type"` // 类型 1-最新更新 2-当前热播 3-独家原创 4-随机推荐 +} + +// PlayletOperateListResp 短剧分类列表响应 +type PlayletOperateListResp struct { + TotalCount int64 `json:"totalCount"` // 符合条件的短剧总数 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + Type int64 `json:"type"` // 类型 1-最新更新 2-当前热播 3-独家原创 4-随机推荐 + List []CommonData `json:"list"` // 当前页短剧 +} + +// PlayletTodayUpdateReq 今日更新短剧请求 +type PlayletTodayUpdateReq struct { + // 普通分页参数,pageNum默认1,pageSize默认10、最大100 + pubdef.Page +} + +// PlayletTodayUpdateResp 今日更新短剧响应 +type PlayletTodayUpdateResp struct { + TotalCount int64 `json:"totalCount"` // 总共数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []CommonData `json:"list"` // 短剧集合 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` // 页面SEO信息(短剧列表页SEO) +} + +// PlayletGuessLikeListReq 猜你喜欢分页请求 +type PlayletGuessLikeListReq struct { + // 普通分页参数,pageNum默认1,pageSize默认6、最大100 + pubdef.Page + PlayletId int64 `form:"playletId" json:"playletId" binding:"required"` // 当前短剧ID +} + +// PlayletGuessLikeListResp 猜你喜欢分页响应 +type PlayletGuessLikeListResp struct { + TotalCount int64 `json:"totalCount"` // 总共数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []CommonData `json:"list"` // 猜你喜欢短剧集合 +} + +// PlayletTagVideoReq 根据标签ID分页查询短剧请求 +type PlayletTagVideoReq struct { + // 普通分页参数,pageNum默认1,pageSize默认10、最大100 + pubdef.Page + TagId int64 `form:"tagId" json:"tagId"` // 标签ID,tagSlug为空时必传 + TagSlug string `form:"tagSlug" json:"tagSlug"` // 标签slug,非空时优先按slug解析标签ID + SortType int64 `form:"sortType" json:"sortType"` // 排序 1-最新 2-最热 3-收藏最多 +} + +// PlayletTagVideoResp 根据标签ID分页查询短剧响应 +type PlayletTagVideoResp struct { + TotalCount int64 `json:"totalCount"` // 总共数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []CommonData `json:"list"` // 短剧集合 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` // 页面SEO信息(标签SEO) +} + +// PlayletSearchReq 短剧ES分页搜索请求 +type PlayletSearchReq struct { + // 普通分页参数,pageNum默认1,pageSize默认10、最大100 + pubdef.Page + Keyword string `form:"keyword" json:"keyword"` // 搜索关键词,支持剧名、题材名、简介和标签名 +} + +// PlayletSearchResp 短剧ES分页搜索响应 +type PlayletSearchResp struct { + TotalCount int64 `json:"totalCount"` // 总共数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + Keyword string `json:"keyword"` // 搜索词 + List []CommonData `json:"list"` // 搜索结果 + EveryoneWatching []*CommonData `json:"everyoneWatching"` // 大家都在看,搜索无结果时返回 +} + +// PlayletSearchSuggestReq 短剧搜索联想请求 +type PlayletSearchSuggestReq struct { + Keyword string `form:"keyword" json:"keyword" binding:"required"` // 搜索关键词 +} + +// PlayletSearchSuggestResp 短剧搜索联想响应 +type PlayletSearchSuggestResp struct { + Keyword string `json:"keyword"` // 搜索关键词 + List []CommonData `json:"list"` // 短剧列表 +} + +// PlayletHotSearchItem 热搜词 +type PlayletHotSearchItem struct { + Keyword string `json:"keyword"` // 搜索词 + Count int64 `json:"count"` // 搜索次数 +} + +// PlayletHotLikeResp 热搜词和猜你喜欢 +type PlayletHotLikeResp struct { + HotSearch []PlayletHotSearchItem `json:"hotSearch"` // 前10个热搜词 + GuessLike []*CommonData `json:"guessLike"` // 按收藏量最多的15部上架短剧 +} + +// PlayletLikeListReq 我的短剧点赞列表请求 +type PlayletLikeListReq struct { + pubdef.Page // 分页参数 +} + +// PlayletLikeData 我的短剧点赞列表项 +type PlayletLikeData struct { + CommonData + IsLike int64 `json:"isLike"` // 点赞状态,固定为1-已点赞 + LikedAt time.Time `json:"likedAt"` // 点赞时间 +} + +// PlayletLikeListResp 我的短剧点赞列表响应 +type PlayletLikeListResp struct { + TotalCount int64 `json:"totalCount"` // 点赞记录总数 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []PlayletLikeData `json:"list"` // 点赞短剧集合 +} + +// PlayletLikeSetReq 短剧点赞设置请求 +type PlayletLikeSetReq struct { + Id int64 `json:"id" binding:"required"` // 短剧ID + IsLike *bool `json:"isLike" binding:"required"` // 是否点赞,true-点赞,false-取消点赞 +} + +// PlayletLikeSetResp 短剧点赞设置响应 +type PlayletLikeSetResp struct { + Id int64 `json:"id"` // 短剧ID + IsLike int64 `json:"isLike"` // 点赞状态 1-已点赞 2-未点赞 + CountLike int64 `json:"countLike"` // 短剧点赞数 +} + +// PlayletCollectSetReq 短剧收藏设置请求 +type PlayletCollectSetReq struct { + Id int64 `json:"id" binding:"required"` // 短剧ID + IsCollect *bool `json:"isCollect" binding:"required"` // 是否收藏,true-收藏,false-取消收藏 +} + +// PlayletCollectSetResp 短剧收藏设置响应 +type PlayletCollectSetResp struct { + Id int64 `json:"id"` // 短剧ID + IsCollect int64 `json:"isCollect"` // 收藏状态 1-已收藏 2-未收藏 + CountCollect int64 `json:"countCollect"` // 短剧收藏数 +} + +// PlayletForwardHeatReq 短剧转发加热度请求 +type PlayletForwardHeatReq struct { + Id int64 `json:"id" binding:"required"` // 短剧ID +} + +// PlayletForwardHeatResp 短剧转发加热度响应 +type PlayletForwardHeatResp struct { + Id int64 `json:"id"` // 短剧ID + HeatValue int64 `json:"heatValue"` // 加热后的热度值 +} + +// PlayletCollectListReq 我的收藏列表请求 +type PlayletCollectListReq struct { + // 普通分页参数,pageNum默认1,pageSize默认10、最大100 + pubdef.Page + WatchStatus int64 `form:"watchStatus" json:"watchStatus"` // 观看状态 0-全部 1-追剧中 2-已看完 +} + +// PlayletCollectData 我的收藏列表项 +type PlayletCollectData struct { + Id int64 `json:"id"` // 短剧ID + Title string `json:"title"` // 标题 + PlayName string `json:"playName"` // 剧名 + Slug string `json:"slug"` // slug + Cover string `json:"cover"` // 封面,已拼接图片域名 + ThemeIds []int64 `json:"themeIds"` // 题材ID集合 + ThemeNames []string `json:"themeNames"` // 题材名称集合 + ThemeSlugs []string `json:"themeSlugs"` // 题材slug集合 + TagIds []int64 `json:"tagIds"` // 标签ID集合 + TagNames []string `json:"tagNames"` // 标签名称集合 + TagSlugs []string `json:"tagSlugs"` // 标签slug集合 + TotalEpisode int64 `json:"totalEpisode"` // 总集数 + UpdateStatus int `json:"updateStatus"` // 更新状态 1-连载中 2-已完结 + CountBrowse int64 `json:"countBrowse"` // 观看量 + HeatValue int64 `json:"heatValue"` // 热度值 + CountCollect int64 `json:"countCollect"` // 收藏数 + IsCollect int64 `json:"isCollect"` // 收藏状态,收藏列表固定返回1-已收藏 + CollectedAt time.Time `json:"collectedAt"` // 收藏时间 + WatchedCount int64 `json:"watchedCount"` // 当前用户已看集数 + UnwatchedCount int64 `json:"unwatchedCount"` // 当前用户未看集数,按totalEpisode-watchedCount计算 + LastChapterId int64 `json:"lastChapterId"` // 最近观看剧集ID,无观看进度时为0 + LastEpisode int64 `json:"lastEpisode"` // 最近观看集数,无观看进度时为0 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletCollectListResp 我的收藏列表响应 +type PlayletCollectListResp struct { + TotalCount int64 `json:"totalCount"` // 总收藏数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []PlayletCollectData `json:"list"` // 收藏短剧集合 +} + +// PlayletBoughtListReq 我的已购短剧列表请求 +type PlayletBoughtListReq struct { + // 普通分页参数,pageNum默认1,pageSize默认10、最大100 + pubdef.Page +} + +// PlayletBoughtData 我的已购短剧列表项 +type PlayletBoughtData struct { + CommonData + BoughtChapterCount int64 `json:"boughtChapterCount"` // 已购剧集数量 + CostCoins int64 `json:"costCoins"` // 已消耗金币 + BoughtAt time.Time `json:"boughtAt"` // 最近购买时间 + LastChapterId int64 `json:"lastChapterId"` // 最近观看剧集ID,无观看进度时为0 + LastEpisode int64 `json:"lastEpisode"` // 最近观看集数,无观看进度时为0 + WatchedCount int64 `json:"watchedCount"` // 当前用户已看集数 + UnwatchedCount int64 `json:"unwatchedCount"` // 当前用户未看集数,按totalEpisode-watchedCount计算 + IsFinished bool `json:"isFinished"` // 当前用户是否已看完 +} + +// PlayletBoughtListResp 我的已购短剧列表响应 +type PlayletBoughtListResp struct { + TotalCount int64 `json:"totalCount"` // 总已购短剧数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []PlayletBoughtData `json:"list"` // 已购短剧集合 +} + +// PlayletHistoryListReq 观看历史列表请求 +type PlayletHistoryListReq struct { + // 普通分页参数,pageNum默认1,pageSize默认20、最大100 + pubdef.Page +} + +// PlayletHistoryData 观看历史列表项 +type PlayletHistoryData struct { + CommonData + LastChapterId int64 `json:"lastChapterId"` // 最近观看剧集ID + LastEpisode int64 `json:"lastEpisode"` // 最近观看集数 + WatchedCount int64 `json:"watchedCount"` // 当前用户已看集数 + UnwatchedCount int64 `json:"unwatchedCount"` // 当前用户未看集数 + IsFinished bool `json:"isFinished"` // 是否已看完 + HistoryAt string `json:"historyAt"` // 最近观看时间 +} + +// PlayletHistoryListResp 观看历史列表响应 +type PlayletHistoryListResp struct { + TotalCount int64 `json:"totalCount"` // 总历史数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []PlayletHistoryData `json:"list"` // 观看历史集合 +} + +// PlayletHistoryDeleteReq 删除单条观看历史请求 +type PlayletHistoryDeleteReq struct { + PlayletId int64 `json:"playletId" binding:"required"` // 短剧ID +} + +// PlayletHistoryDeleteResp 删除单条观看历史响应 +type PlayletHistoryDeleteResp struct { + DeletedCount int64 `json:"deletedCount"` // 删除记录数量 +} + +// PlayletHistoryClearResp 清空观看历史响应 +type PlayletHistoryClearResp struct { + DeletedCount int64 `json:"deletedCount"` // 删除记录数量 +} + +// PlayletChapterLikeSetReq 短剧剧集点赞设置请求 +type PlayletChapterLikeSetReq struct { + ChapterId int64 `json:"chapterId" binding:"required"` // 短剧剧集ID + IsLike *bool `json:"isLike" binding:"required"` // 是否点赞,true-点赞,false-取消点赞 +} + +// PlayletChapterLikeSetResp 短剧剧集点赞设置响应 +type PlayletChapterLikeSetResp struct { + ChapterId int64 `json:"chapterId"` // 短剧剧集ID + IsLike int64 `json:"isLike"` // 点赞状态 1-已点赞 2-未点赞 + CountLike int64 `json:"countLike"` // 短剧剧集点赞数 +} + +// PlayletChapterCollectSetReq 短剧剧集收藏设置请求 +type PlayletChapterCollectSetReq struct { + ChapterId int64 `json:"chapterId" binding:"required"` // 短剧剧集ID + IsCollect *bool `json:"isCollect" binding:"required"` // 是否收藏,true-收藏,false-取消收藏 +} + +// PlayletChapterCollectSetResp 短剧剧集收藏设置响应 +type PlayletChapterCollectSetResp struct { + ChapterId int64 `json:"chapterId"` // 短剧剧集ID + IsCollect int64 `json:"isCollect"` // 收藏状态 1-已收藏 2-未收藏 + CountCollect int64 `json:"countCollect"` // 短剧剧集收藏数 +} + +// PlayletChapterCollectListReq 我的剧集收藏列表请求 +type PlayletChapterCollectListReq struct { + // 普通分页参数,pageNum默认1,pageSize默认10、最大100 + pubdef.Page +} + +// PlayletChapterCollectData 我的剧集收藏列表项 +type PlayletChapterCollectData struct { + PlayletId int64 `json:"playletId"` // 短剧ID + PlayletTitle string `json:"playletTitle"` // 短剧标题 + PlayletCover string `json:"playletCover"` // 短剧封面,已拼接图片域名 + TotalEpisode int64 `json:"totalEpisode"` // 短剧总集数 + UpdateStatus int `json:"updateStatus"` // 更新状态 1-连载中 2-已完结 + ChapterId int64 `json:"chapterId"` // 短剧剧集ID + CurrentEpisode int64 `json:"currentEpisode"` // 第几集 + ChapterTitle string `json:"chapterTitle"` // 剧集标题 + VideoUrl string `json:"videoUrl"` // 播放地址 + MediaTime int64 `json:"mediaTime"` // 影片长度(秒) + PayType int32 `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币 + CountBrowse int64 `json:"countBrowse"` // 剧集观看数 + CountCollect int64 `json:"countCollect"` // 剧集收藏数 + IsCollect int64 `json:"isCollect"` // 收藏状态,收藏列表固定返回1-已收藏 + CollectedAt time.Time `json:"collectedAt"` // 收藏时间 +} + +// PlayletChapterCollectListResp 我的剧集收藏列表响应 +type PlayletChapterCollectListResp struct { + TotalCount int64 `json:"totalCount"` // 总收藏数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []PlayletChapterCollectData `json:"list"` // 收藏剧集集合 +} + +// PlayletChapterWatchReportReq 短剧剧集观看上报请求 +type PlayletChapterWatchReportReq struct { + PlayletId int64 `json:"playletId"` // 短剧ID,传入时用于校验剧集归属 + ChapterId int64 `json:"chapterId" binding:"required"` // 短剧剧集ID +} + +// PlayletChapterWatchReportResp 短剧剧集观看上报响应 +type PlayletChapterWatchReportResp struct { + PlayletId int64 `json:"playletId"` // 短剧ID + ChapterId int64 `json:"chapterId"` // 短剧剧集ID + CurrentEpisode int64 `json:"currentEpisode"` // 当前集数 + WatchedCount int64 `json:"watchedCount"` // 当前用户已看集数 + UnwatchedCount int64 `json:"unwatchedCount"` // 当前用户未看集数 + LastChapterId int64 `json:"lastChapterId"` // 最近观看剧集ID + LastEpisode int64 `json:"lastEpisode"` // 最近观看集数 +} + +// PlayletInfoReq 详情请求 +type PlayletInfoReq struct { + Id int64 `form:"id" json:"id"` // 短剧id + Slug string `form:"slug" json:"slug"` // 短剧slug,非空时优先按slug解析短剧ID +} + +// PlayletChapterInfoReq 短剧剧集详情请求 +type PlayletChapterInfoReq struct { + PlayletId int64 `form:"playletId" json:"playletId"` // 短剧ID,传入时用于校验剧集归属 + PlayletSlug string `form:"playletSlug" json:"playletSlug"` // 短剧slug,非空时优先按slug解析短剧ID + ChapterId int64 `form:"chapterId" json:"chapterId" binding:"required"` // 短剧剧集ID +} + +// PlayletChapterListReq 短剧剧集列表请求 +type PlayletChapterListReq struct { + PlayletId int64 `form:"playletId" json:"playletId"` // 短剧ID + PlayletSlug string `form:"playletSlug" json:"playletSlug"` // 短剧slug,非空时优先按slug解析短剧ID +} + +// PlayletChapterListResp 短剧剧集列表响应 +type PlayletChapterListResp struct { + PlayletId int64 `json:"playletId"` // 短剧ID + List []PlayletChapterData `json:"list"` // 短剧剧集集合 +} + +// PlayletChapterInfoResp 短剧剧集详情响应 +type PlayletChapterInfoResp struct { + Id int64 `json:"id"` // 短剧剧集ID + PlayletId int64 `json:"playletId"` // 短剧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"` // 影片长度(秒) + CountComment int64 `json:"countComment"` // 短剧剧集评论数 + CountBrowse int64 `json:"countBrowse"` // 短剧剧集观看数 + CountCollect int64 `json:"countCollect"` // 短剧剧集收藏数 + CountLike int64 `json:"countLike"` // 短剧剧集点赞数 + IsLike int64 `json:"isLike"` // 点赞状态 1-已点赞 2-未点赞 + IsCollect int64 `json:"isCollect"` // 收藏状态 1-已收藏 2-未收藏 + PayType int32 `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币(支付方式为1-金币时候生效) + CanWatch bool `json:"canWatch"` // 是否可观看 + NeedUnlock bool `json:"needUnlock"` // 是否需要金币解锁 + NeedVip bool `json:"needVip"` // 是否需要开通VIP + CostCoins int64 `json:"costCoins"` // 本次解锁消耗金币 + Balance int64 `json:"balance"` // 当前金币余额 + VipValid bool `json:"vipValid"` // 当前用户VIP是否有效 + IsUnlocked bool `json:"isUnlocked"` // 当前用户是否已解锁本集 + // 页面SEO信息 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` +} + +// PlayletChapterUnlockReq 短剧剧集金币解锁请求 +type PlayletChapterUnlockReq struct { + PlayletId int64 `json:"playletId" binding:"required"` // 短剧ID + ChapterId int64 `json:"chapterId" binding:"required"` // 短剧剧集ID +} + +// PlayletChapterUnlockResp 短剧剧集金币解锁响应 +type PlayletChapterUnlockResp struct { + PlayletId int64 `json:"playletId"` // 短剧ID + ChapterId int64 `json:"chapterId"` // 短剧剧集ID + Unlocked bool `json:"unlocked"` // 是否已解锁 + CanWatch bool `json:"canWatch"` // 是否可观看 + Balance int64 `json:"balance"` // 当前金币余额 + CostCoins int64 `json:"costCoins"` // 本次解锁消耗金币 + ChangeCoin int64 `json:"changeCoin"` // 本次实际扣减金币,幂等命中时为0 +} + +// PlayletInfoResp 详情响应 +type PlayletInfoResp struct { + CommonData + // 是否点赞(1-已点赞,2-未点赞) + IsLike int64 `json:"isLike"` + // 是否收藏(1-已收藏,2-未收藏) + IsCollect int64 `json:"isCollect"` + // 短剧章节集合 + PlayletChapterInfo []PlayletChapterData `json:"playletChapterInfo"` + // 页面SEO信息 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` + // 广告 + Advitise AdvitiseData `json:"advitise"` +} + +// PlayletChapterData 短剧章节信息 +type PlayletChapterData struct { + Id int64 `json:"id"` // 短剧剧集ID + PlayletId int64 `json:"playletId"` // 短剧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 int32 `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币(支付方式为1-金币时候生效) + CanWatch bool `json:"canWatch"` // 是否可观看 + NeedUnlock bool `json:"needUnlock"` // 是否需要金币解锁 + NeedVip bool `json:"needVip"` // 是否需要开通VIP + CostCoins int64 `json:"costCoins"` // 本次解锁消耗金币 + Balance int64 `json:"balance"` // 当前金币余额 + VipValid bool `json:"vipValid"` // 当前用户VIP是否有效 + IsUnlocked bool `json:"isUnlocked"` // 当前用户是否已解锁本集 +} diff --git a/app/api/proto/playlet_theme.go b/app/api/proto/playlet_theme.go new file mode 100644 index 0000000..6a3390e --- /dev/null +++ b/app/api/proto/playlet_theme.go @@ -0,0 +1,102 @@ +package proto + +import "videoweb/pub/pubdef" + +// PlayletTopicPageListReq 专题剧单分页请求 +type PlayletTopicPageListReq struct { + pubdef.Page // 专题分页参数 +} + +// PlayletTopicGroupPageItem 专题区块分页数据 +type PlayletTopicGroupPageItem struct { + Id int64 `json:"id"` // 专题区块ID + Name string `json:"name"` // 专题区块名称 + Desc string `json:"desc"` // 专题区块描述 + Rank int64 `json:"rank"` // 区块排序,越大越靠前 + TotalCount int64 `json:"totalCount"` // 区块下专题总数 + List []*PlayletTopicPageItem `json:"list"` // 当前页专题 +} + +// PlayletTopicPageListResp 专题剧单分页响应 +type PlayletTopicPageListResp struct { + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []*PlayletTopicGroupPageItem `json:"list"` // 专题区块集合 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` // 页面SEO信息 +} + +// PlayletTopicPageItem 专题剧单分页项 +type PlayletTopicPageItem struct { + Id int64 `json:"id"` // 专题ID + 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"` // 专题列表为已上架总数,更多专题为预览数量 + Covers []string `json:"covers,omitempty"` // 专题列表为一张短剧封面 + List []*CommonData `json:"list,omitempty"` // 专题列表为一部短剧 + SeoInfo *SeoInfo `json:"seoInfo"` // 专题SEO +} + +// PlayletTopicDetailReq 专题详情请求 +type PlayletTopicDetailReq struct { + pubdef.Page + Id int64 `form:"id" json:"id"` // 专题ID + TopicSlug string `form:"topicSlug" json:"topicSlug"` + DataType int64 `form:"dataType" json:"dataType"` // 仅支持 1 短剧 + SortType int64 `form:"sortType" json:"sortType"` // 排序 1-编辑排序 2-最新 3-最热 +} + +// PlayletTopicDetailResp 专题详情响应 +type PlayletTopicDetailResp struct { + Id int64 `json:"id"` // 专题ID + DataType int64 `json:"dataType"` + 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"` // 当前页 list 中的短剧数量 + Covers []string `json:"covers"` // 顶部封面集合 + TotalCount int64 `json:"totalCount"` // 短剧总数 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []CommonData `json:"list"` // 专题短剧列表 + MoreTopics []*PlayletTopicPageItem `json:"moreTopics"` // 更多专题 + SeoInfo *SeoInfo `json:"seoInfo"` // 专题SEO + Advitise AdvitiseData `json:"advitise"` +} + +// PlayletThemeListResponse app端题材列表响应 +type PlayletThemeListResponse struct { + List []*PlayletThemeItem `json:"list"` // 题材列表 +} + +// PlayletThemeItem app端题材列表项 +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"` // 排序,越大越靠前 + Status bool `json:"status"` // 上架状态 + IsRecommend bool `json:"isRecommend"` // 是否推荐 + PlayletCount int64 `json:"playletCount"` // 该题材绑定的上架短剧数量 + SeoInfo *SeoInfo `json:"seoInfo"` // 题材对应页面 SEO 信息 +} + +// PlayletTagListResponse app端短剧标签列表响应 +type PlayletTagListResponse struct { + List []*PlayletTagItem `json:"list"` // 标签列表 +} + +// PlayletTagItem app端短剧标签列表项 +type PlayletTagItem struct { + Id int64 `json:"id"` // 标签ID + Name string `json:"name"` // 标签名称 + Slug string `json:"slug"` // slug + Rank int64 `json:"rank"` // 排序,越大越靠前 + Status bool `json:"status"` // 上架状态 + SeoInfo *SeoInfo `json:"seoInfo"` // 标签对应页面 SEO 信息 +} diff --git a/app/api/proto/recharge_package.go b/app/api/proto/recharge_package.go new file mode 100644 index 0000000..df20d81 --- /dev/null +++ b/app/api/proto/recharge_package.go @@ -0,0 +1,32 @@ +package proto + +import "videoweb/pub/models/rechargepackagemod" + +// PayMethodInfo 单个支付方式 +type PayMethodInfo struct { + Type string `json:"type"` // alipay / wechat / union + TypeName string `json:"typeName"` // 支付宝 / 微信 / 银联 +} + +// RechargePackageItem App购买套餐列表项 +type RechargePackageItem struct { + Id int64 `json:"id"` // 套餐ID + PackageType rechargepackagemod.PackageType `json:"packageType"` // 套餐类型:1-会员畅享 2-金币充值 + Name string `json:"name"` // 展示标题 + SubTitle string `json:"subTitle"` // 展示副标题 + Price int64 `json:"price"` // 售价,单位分 + OriginalPrice int64 `json:"originalPrice"` // 原价,单位分 + DurationDays int64 `json:"durationDays"` // 会员有效天数 + DurationHours int64 `json:"durationHours"` // 会员有效小时数,由durationDays计算 + BaseCoin int64 `json:"baseCoin"` // 基础金币数 + GiftCoin int64 `json:"giftCoin"` // 赠送金币数 + TotalCoin int64 `json:"totalCoin"` // 到账总金币数,由baseCoin+giftCoin计算 + Sort int32 `json:"sort"` // 排序,越小越靠前 + PayMethods []PayMethodInfo `json:"payMethods"` // 该套餐支持的支付方式 +} + +// RechargePackageListResp App购买套餐列表响应。 +type RechargePackageListResp struct { + VipList []*RechargePackageItem `json:"vipList"` // 会员畅享列表 + CoinList []*RechargePackageItem `json:"coinList"` // 金币充值列表 +} diff --git a/app/api/proto/richtext.go b/app/api/proto/richtext.go new file mode 100644 index 0000000..3ce0fc6 --- /dev/null +++ b/app/api/proto/richtext.go @@ -0,0 +1,29 @@ +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 + Id int64 `json:"id"` + // 标题 + Title string `json:"title"` + // 内容 + Content string `json:"content"` + // 标题(app) + Desc string `json:"desc"` +} + +// 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..07b1c68 --- /dev/null +++ b/app/api/proto/search.go @@ -0,0 +1,85 @@ +package proto + +import "time" + +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"` // 广告 + 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"` // 热度值 + Images string `json:"images" form:"images"` // 封面 + Works int64 `json:"works" bson:"works,omitempty"` // 作品数量 + Desc string `json:"desc" bson:"desc"` // 描述 + AddedTime time.Time `json:"addedTime" bson:"addedTime"` // 上架shi时间 +} + +type SearchResp struct { + Path string `json:"path"` + CompletePath string `json:"completePath" form:"completePath"` + InTitle string `json:"inTitle"` //输入标题 + InType int64 `json:"inType"` // 输入类型 0=视频 1=女优 2=影评 3=帖子 + 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"` // 搜索推荐 + Config ConfigInfo `json:"config" form:"config"` // 配置文件 +} + +type SearchSortItem struct { + Key string `json:"key"` // 值 + Title string `json:"title"` // 标题 +} diff --git a/app/api/proto/seo.go b/app/api/proto/seo.go new file mode 100644 index 0000000..3329e99 --- /dev/null +++ b/app/api/proto/seo.go @@ -0,0 +1,15 @@ +package proto + +// SeoInfo 页面SEO信息,用于前端渲染 title、meta 和 canonical。 +type SeoInfo struct { + // 页面标题 + Title string `json:"title"` + // 页面关键词 + Keywords []string `json:"keywords"` + // 页面描述 + Description string `json:"description"` + // 网站名称 + WebSiteName string `json:"webSiteName"` + // 当前内容的标准URL + CanonicalURL string `json:"canonicalUrl"` +} 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..024e287 --- /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"` // 导航类型 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +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:"-"` // 导航分类 +} diff --git a/app/api/proto/user_center.go b/app/api/proto/user_center.go new file mode 100644 index 0000000..36cb4fc --- /dev/null +++ b/app/api/proto/user_center.go @@ -0,0 +1,9 @@ +package proto + +// UserCenterDataResp app个人中心数据响应 +type UserCenterDataResp struct { + WatchCount int64 `json:"watchCount"` // 观看数量 + CollectCount int64 `json:"collectCount"` // 收藏数量 + ChasingCount int64 `json:"chasingCount"` // 追剧中数量 + BoughtChapterCount int64 `json:"boughtChapterCount"` // 已购剧集数量 +} diff --git a/app/api/proto/video.go b/app/api/proto/video.go new file mode 100644 index 0000000..96679cc --- /dev/null +++ b/app/api/proto/video.go @@ -0,0 +1,428 @@ +package proto + +import ( + "time" + "videoweb/pub/common/netutil" + "videoweb/pub/common/utils" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" +) + +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 // 广告 + VideoTypeVideoV2 VideoType = 3 // 视频 +) + +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"` // 帖子上架时间 + CreatorId int64 `json:"creatorId"` // 作者Id + CreatorName string `json:"creatorName"` // 作者名称 + 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 + Link string `json:"link"` // 跳转链接 + Location int64 `json:"location" bson:"location"` // 位置id + CategoryIds []int64 `json:"categoryIds"` // 分类信息 + Categorys []VideoCategoryInfo `json:"categorys"` // 分类信息 + TopicIds []int64 `json:"topicIds"` // 专题信息 + Topics []VideoTopicInfo `json:"topics"` // 专题信息 + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 观看次数 + ShowTimes int64 `json:"showTimes" bson:"showTimes"` // 展示次数 + UpdatedAt time.Time `json:"updatedAt"` //更新时间 + Desc string `bson:"desc,omitempty" json:"desc"` // 详细描述 + Content string `json:"content"` // 富文本 + ImgCdn string `json:"imgCdn" bson:"imgCdn"` // 图片CDN + PreviewVtt string `json:"previewVtt,omitempty"` // 雪碧图 vtt +} + +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 + ContentType int64 `json:"contentType" bson:"contentType"` // 内容:0: 默认 1:首页 2:今日 + SortType int64 `json:"sortType" bson:"sortType"` // 排序方式 0:上线时间 1:观看次数 2排序 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + Title string `json:"title" bson:"title"` // APP显示 + Desc string `json:"desc" bson:"desc"` // 描述 +} + +// 面包屑 +type BreadcrumbItem struct { + Name string `json:"name"` // 名称 + Url string `json:"url"` // 跳转链接 +} + +// 详情页导航 +type DetailNavItem struct { + Name string `json:"name"` // 导航名称 + Url string `json:"url"` // 导航链接 +} + +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相关信息 +} + +func VideoInfoToData(videoInfo *videoinformationmod.VideoInformation, imgCdn string) 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_VideoV2: + t = VideoTypeVideoV2 + } + videotemp := VideoInfoData{ + Id: videoInfo.Id, + Type: t, + Title: videoInfo.Title, + Slug: videoInfo.Slug, + Keyworld: videoInfo.Keyworld, + AddedTime: videoInfo.AddedTime, + Desc: videoInfo.Desc, + UpdatedAt: videoInfo.PageUpdatedAt, + CreatorId: videoInfo.CreatorId, + TopicIds: videoInfo.TopicIds, + Topics: make([]VideoTopicInfo, 0), + CreatorName: videoInfo.CreatorName, + CoverImg: netutil.MergeURL(imgCdn, videoInfo.CoverImg), + Nodes: make([]videoinformationmod.Node, 0), + Video: videoInfo.Video, + IsHot: videoInfo.IsHot, + CategoryIds: videoInfo.CategoryIds, + Categorys: make([]VideoCategoryInfo, 0), + Tags: make([]TagsInfoData, 0), + ShowTimes: videoInfo.Real.ShowTimes, + WatchTimes: videoInfo.Real.WatchTimes, + Content: videoInfo.Content, + ImgCdn: imgCdn, + PreviewVtt: videoInfo.PreviewVtt, + } + ti := time.Date(2020, 1, 1, 0, 0, 0, 0, utils.LocBJ) + if videotemp.UpdatedAt.Before(ti) { + videotemp.UpdatedAt = videoInfo.UpdatedAt + } + for _, nodeInfo := range videoInfo.Nodes { + nodeTmp := videoinformationmod.Node{ + Images: make([]string, 0), + Url: nodeInfo.Url, + Text: nodeInfo.Text, + Videos: nodeInfo.Videos, + Title: nodeInfo.Title, + } + for _, image := range nodeInfo.Images { + nodeTmp.Images = append(nodeTmp.Images, netutil.MergeURL(imgCdn, image)) + } + videotemp.Nodes = append(videotemp.Nodes, nodeTmp) + } + return videotemp +} + +// StripH265Url 清空主视频与 nodes 内视频的 H265 地址。 +func StripH265Url(data *VideoInfoData) { + if data == nil { + return + } + data.Video.H265Url = "" + for i := range data.Nodes { + for j := range data.Nodes[i].Videos { + data.Nodes[i].Videos[j].H265Url = "" + } + } +} + +// VideoReq 普通分页查询视频请求 +type VideoReq struct { + // 普通分页参数,pageNum默认1,pageSize默认10、最大100 + pubdef.Page +} + +// VideoAllListReq 视频分页列表请求 +type VideoAllListReq struct { + pubdef.Page + MediaType int64 `form:"mediaType" json:"mediaType"` // 类型 0-全部 1-影集 2-动画 + TopicId int64 `form:"topicId" json:"topicId"` // 题材ID,对应video_topic + TopicSlug string `form:"topicSlug" json:"topicSlug"` // 题材slug,非空时优先按slug解析题材ID + SortType int64 `form:"sortType" json:"sortType"` // 排序 1-最新 2-最热 3-收藏最多 +} + +// VideoItemResp 查询视频列表项 +type VideoItemResp struct { + // id + Id int64 `json:"id"` + // slug + Slug string `json:"slug"` + // 标题 + Title string `json:"title"` + // 上架时间 + AddedTime time.Time `json:"addedTime"` + // 作者Id + CreatorId int64 `json:"creatorId"` + // 作者名称 + CreatorName string `json:"creatorName"` + // 封面 + CoverImg string `json:"coverImg"` + // 关键字 + Keyworld []string `json:"keyworld"` + // 标签 + Tags []int64 `json:"tags"` + // 观看数(真假之和) + WatchTimes int64 `json:"watchTimes"` + // 影片长度 + VideoTime int32 `json:"videoTime"` +} + +// VideoLikeSetReq 视频点赞设置请求 +type VideoLikeSetReq struct { + Id int64 `json:"id" binding:"required"` // 视频ID + IsLike *bool `json:"isLike" binding:"required"` // 是否点赞,true-点赞,false-取消点赞 +} + +// VideoLikeSetResp 视频点赞设置响应 +type VideoLikeSetResp struct { + Id int64 `json:"id"` // 视频ID + IsLike int64 `json:"isLike"` // 点赞状态 1-已点赞 2-未点赞 + CountLike int64 `json:"countLike"` // 视频点赞数 +} + +// VideoCollectSetReq 视频收藏设置请求 +type VideoCollectSetReq struct { + Id int64 `json:"id" binding:"required"` // 视频ID + IsCollect *bool `json:"isCollect" binding:"required"` // 是否收藏,true-收藏,false-取消收藏 +} + +// VideoCollectSetResp 视频收藏设置响应 +type VideoCollectSetResp struct { + Id int64 `json:"id"` // 视频ID + IsCollect int64 `json:"isCollect"` // 收藏状态 1-已收藏 2-未收藏 + CountCollect int64 `json:"countCollect"` // 视频收藏数 +} + +// VideoHistoryListReq 视频观看历史列表请求 +type VideoHistoryListReq struct { + // 普通分页参数,pageNum默认1,pageSize默认10、最大100 + pubdef.Page +} + +// VideoHistoryData 视频观看历史列表项 +type VideoHistoryData struct { + Id int64 `json:"id"` // 视频ID + Slug string `json:"slug"` // slug + Title string `json:"title"` // 标题 + CoverImg string `json:"coverImg"` // 封面 + Keyworld []string `json:"keyworld"` // 关键字 + Tags []int64 `json:"tags"` // 标签 + WatchTimes int64 `json:"watchTimes"` // 观看数 + VideoTime int32 `json:"videoTime"` // 影片长度 + CountLike int64 `json:"countLike"` // 点赞数 + CountCollect int64 `json:"countCollect"` // 收藏数 + CountComment int64 `json:"countComment"` // 评论数 + IsCollect int64 `json:"isCollect"` // 收藏状态 1-已收藏 2-未收藏 + HistoryAt time.Time `json:"historyAt"` // 最近观看时间 +} + +// VideoHistoryListResp 视频观看历史列表响应 +type VideoHistoryListResp struct { + TotalCount int64 `json:"totalCount"` // 总历史数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []VideoHistoryData `json:"list"` // 观看历史集合 +} + +// VideoHistoryDeleteReq 删除单条视频观看历史请求 +type VideoHistoryDeleteReq struct { + VideoId int64 `json:"videoId" binding:"required"` // 视频ID +} + +// VideoHistoryDeleteResp 删除单条视频观看历史响应 +type VideoHistoryDeleteResp struct { + DeletedCount int64 `json:"deletedCount"` // 删除记录数量 +} + +// VideoHistoryClearResp 清空视频观看历史响应 +type VideoHistoryClearResp struct { + DeletedCount int64 `json:"deletedCount"` // 删除记录数量 +} + +// VideoCollectListReq 视频收藏列表请求 +type VideoCollectListReq struct { + // 普通分页参数,pageNum默认1,pageSize默认10、最大100 + pubdef.Page +} + +// VideoCollectData 视频收藏列表项 +type VideoCollectData struct { + Id int64 `json:"id"` // 视频ID + Slug string `json:"slug"` // slug + Title string `json:"title"` // 标题 + CoverImg string `json:"coverImg"` // 封面 + Keyworld []string `json:"keyworld"` // 关键字 + Tags []int64 `json:"tags"` // 标签 + WatchTimes int64 `json:"watchTimes"` // 观看数 + VideoTime int32 `json:"videoTime"` // 影片长度 + CountLike int64 `json:"countLike"` // 点赞数 + CountCollect int64 `json:"countCollect"` // 收藏数 + CountComment int64 `json:"countComment"` // 评论数 + IsCollect int64 `json:"isCollect"` // 收藏状态,收藏列表固定返回1-已收藏 + CollectedAt time.Time `json:"collectedAt"` // 收藏时间 +} + +// VideoCollectListResp 视频收藏列表响应 +type VideoCollectListResp struct { + TotalCount int64 `json:"totalCount"` // 总收藏数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []VideoCollectData `json:"list"` // 收藏视频集合 +} + +// VideoAllListItemResp 视频分页列表项 +type VideoAllListItemResp struct { + Id int64 `json:"id"` // id + Slug string `json:"slug"` // slug + Title string `json:"title"` // 标题 + MediaType int64 `json:"mediaType"` // 类型 1-影集 2-动画 + AddedTime time.Time `json:"addedTime"` // 上架时间 + CreatorId int64 `json:"creatorId"` // 作者Id + CreatorName string `json:"creatorName"` // 作者名称 + CoverImg string `json:"coverImg"` // 封面 + Keyworld []string `json:"keyworld"` // 关键字 + Tags []int64 `json:"tags"` // 标签 + TopicIds []int64 `json:"topicIds"` // 题材ID集合 + TopicNames []string `json:"topicNames"` // 题材名称集合 + Topics []VideoTopicInfo `json:"topics"` // 题材集合 + WatchTimes int64 `json:"watchTimes"` // 观看数 + VideoTime int32 `json:"videoTime"` // 影片长度 + VideoCount int64 `json:"videoCount"` // 视频数量 +} + +// VideoResp 普通分页查询视频响应 +type VideoResp struct { + // 总共数量 + TotalCount int64 `json:"totalCount"` + // 每页数 + PageSize int `json:"pageSize"` + // 页数 + PageNum int `json:"pageNum"` + // 视频列表 + List []*VideoItemResp `json:"list"` +} + +// VideoAllListResp 视频分页列表响应 +type VideoAllListResp struct { + TotalCount int64 `json:"totalCount"` // 总共数量 + PageSize int `json:"pageSize"` // 每页数 + PageNum int `json:"pageNum"` // 页数 + List []*VideoAllListItemResp `json:"list"` // 视频集合 + Advitise AdvitiseData `json:"advitise"` // 广告 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` // 页面SEO信息 +} + +// VideoInfoReq 查询视频详情请求 +type VideoInfoReq struct { + Id int64 `json:"id" form:"id"` // 视频id + Slug string `json:"slug" form:"slug"` // 视频slug,非空时优先按slug解析视频ID +} + +// VideoInfoResp 查询视频详情响应 +type VideoInfoResp struct { + // id + Id int64 `json:"id"` + // slug + Slug string `json:"slug"` + // 标题 + Title string `json:"title"` + // 封面 + CoverImg string `json:"coverImg"` + // 关键字 + Keyworld []string `json:"keyworld"` + // 标签 + Tags []int64 `json:"tags"` + // 标签名称 + TagsName []string `json:"tagsName"` + // 题材ID集合 + TopicIds []int64 `json:"topicIds"` + // 题材集合 + Topics []VideoTopicInfo `json:"topics"` + // 观看数(真假之和) + WatchTimes int64 `json:"watchTimes"` + // 点赞数 + CountLike int64 `json:"countLike"` + // 收藏数 + CountCollect int64 `json:"countCollect"` + // 评论数 + CountComment int64 `json:"countComment"` + // 当前用户点赞状态 1-已点赞 2-未点赞 + IsLike int64 `json:"isLike"` + // 当前用户收藏状态 1-已收藏 2-未收藏 + IsCollect int64 `json:"isCollect"` + // 视频 + Video VideoData `json:"video"` + // 广告 + Advitise AdvitiseData `json:"advitise"` + // 页面SEO信息 + SeoInfo *SeoInfo `json:"seoInfo,omitempty"` +} + +type VideoData struct { + // 视频ID(视频才会存在) + TaskId string `json:"taskId"` + // 媒资库ID + FileId string `json:"fileId"` + // 视频URl + Url string `json:"url"` + // 视频封面 + Cover string `json:"cover"` + // 分辨率高(视频才会存在) + Height int64 `json:"height"` + // 分辨率宽(视频才会存在) + Width int64 `json:"width"` + // 影片长度 + VideoTime int32 `json:"videoTime"` + // 预览MP4文件 + PreMp4FileNameFs string `json:"preMp4FileNameFs"` + // 不加密的预览MP4 + UnencryptedPreMp4 string `json:"unencryptedPreMp4"` +} diff --git a/app/api/proto/videotopic.go b/app/api/proto/videotopic.go new file mode 100644 index 0000000..ef422c7 --- /dev/null +++ b/app/api/proto/videotopic.go @@ -0,0 +1,27 @@ +package proto + +import ( + "videoweb/pub/pubdef" +) + +// VideoTopicListReq 视频题材列表请求 +type VideoTopicListReq struct { + MediaType int64 `form:"mediaType" json:"mediaType"` // 类型 0-全部 1-影集 2-动画 +} + +// VideoTopicListResp 视频题材列表响应 +type VideoTopicListResp struct { + List []VideoTopicInfo `json:"list"` // 题材集合 +} + +type VideoTopicInfo 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"` // 描述 + Image string `json:"image" bson:"image"` // 背景 + VideoCategoryId int64 `json:"videoCategoryId" bson:"videoCategoryId"` // 视频分类id + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + Works int64 `json:"works" bson:"works"` // 作品数 +} diff --git a/app/api/proto/webdata.go b/app/api/proto/webdata.go new file mode 100644 index 0000000..d32340b --- /dev/null +++ b/app/api/proto/webdata.go @@ -0,0 +1,215 @@ +package proto + +import ( + "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"` // 广告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 +} + +type VideoCategoryData struct { + Id int64 `json:"id" form:"id"` + Name string `json:"name" form:"name"` + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` + TopicList []VideoTopicData `json:"topicList" form:"topicList"` // 专题列表 +} + +type VideoTopicData struct { + Id int64 `json:"id" form:"id"` + Name string `json:"name" form:"name"` + Image string `json:"image" form:"image"` + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + Words int64 `json:"words" form:"words"` + ShowType int64 `json:"showType" bson:"showType"` // 展示类型 0=默认 1=横版左右滑动 2=竖版左右滑动 + TopicType int64 `json:"topicType" bson:"topicType"` // 专题类型 0视频专题 1绑定专题 2绑定标签 3=绑定女优 4=随便看 + LinkUrl string `json:"linkUrl" bson:"linkUrl"` // 跳转URL +} + +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"` // 广告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + VideoCategory VideoCategoryData `json:"videoCategory" form:"videoCategory"` // 当前分类数据 +} + +type VideoTopicHtmlData 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"` // 广告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + PageNum int64 `json:"pageNum" form:"pageNum"` + PageSize int64 `json:"pageSize" form:"pageSize"` + TotalSize int64 `json:"totalSize" form:"totalSize"` + VideoInfos []VideoInfoData `json:"videoInfos" form:"videoInfos"` // 视频列表 +} + +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"` // 广告 + 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"` // 广告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + Translation TranslationInfo `json:"translation" form:"translation"` // 当前导航数据 + 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"` + Video VideoInfoData `json:"video" form:"video"` // 视频详情 + Breadcrumbs []BreadcrumbItem `json:"breadcrumbs" form:"breadcrumbs"` // 面包屑 + 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"` // 广告 + CommentList GetCommentListResp `json:"commentList" form:"commentList"` // 评论 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜标签 +} + +type PostDetailsHtmlData 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"` // 广告 + CommentList GetCommentListResp `json:"commentList" form:"commentList"` // 评论 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜标签 + CreateInfo PostCreateInfo `json:"createInfo" form:"createInfo"` // 作者信息 +} + +type PostCreateInfo struct { + Id int64 `json:"id" form:"id"` + Name string `json:"name" form:"name"` + 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"` // 背景 +} + +// TagHtmlData 首页 +type TagHtmlData 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"` // 广告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + 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"` // 广告 + PageNum int64 `json:"pageNum" form:"pageNum"` + PageSize int64 `json:"pageSize" form:"pageSize"` + TotalSize int64 `json:"totalSize" form:"totalSize"` +} +type VideoTagCategoryHtmlData 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"` // 广告 + AllCategory []VideoTagCategoryData `json:"allCategory" form:"allCategory"` //所有分类 + +} +type VideoTagCategoryData struct { + Id int64 `json:"id" form:"id"` + Name string `json:"name" form:"name"` + SeoInfo pubdef.SeoValue `json:"seoInfo" form:"seoInfo"` + Image string `json:"image" form:"image"` + TotalTags int64 `json:"totalTags" form:"totalTags"` // 总标签数量 +} +type VideoTagCategoryInfoData struct { + 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"` // 广告 + 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"` // 配置文件 + 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"` // 配置文件 + 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"` // 广告 + HotSearch []*HotSearchItem `json:"hotSearch" form:"hotSearch"` // 热搜 + 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"` // 主旨分类 +} diff --git a/app/api/rechargeapi/recharge.go b/app/api/rechargeapi/recharge.go new file mode 100644 index 0000000..547584f --- /dev/null +++ b/app/api/rechargeapi/recharge.go @@ -0,0 +1,88 @@ +package rechargeapi + +import ( + "videoweb/app/service/rechargeser" + "videoweb/pub/common" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +type submitOrderReq struct { + PackageId int64 `json:"packageId" binding:"required"` // 购买套餐ID(会员畅享) + PayMode string `json:"payMode" binding:"required"` // 支付方式:alipay/wechat + SourcePageKey string `json:"sourcePageKey"` // 下单来源页面标识 + SourcePageName string `json:"sourcePageName"` // 下单来源页面名称 +} + +// SubmitOrder 发起第三方支付下单 +// @Tags 充值 +// @Summary 创建 VIP 充值订单(第三方支付) +// @Accept json +// @Produce json +// @Param param body submitOrderReq true "下单参数" +// @Success 200 {object} rechargeser.SubmitOrderResp "支付链接" +// @Security ApiKeyAuth +// @Router /api/app/vip/pay [POST] +func SubmitOrder(ctx *gin.Context) { + var req submitOrderReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + userId := common.GetUserId(ctx) + sysType := ctx.GetString(consts.CtxSysType) + devType := ctx.GetString(consts.CtxDevType) + ip := ipaddr.ResolveClientIP(ctx) + + user, serr := rechargeser.GetUserInfo(ctx, userId) + if serr != nil || user == nil { + common.ServerJSON(ctx, srvdef.ErrUserNotExsit, nil) + return + } + + deviceID := rechargeser.PayDeviceID(ctx.GetString(consts.CtxDevId), user.DevID, user.DevV2) + resp, serr := rechargeser.CreateOrder(ctx, userId, user.NickName, req.PackageId, req.PayMode, sysType, devType, deviceID, ip, user.DistrictCode, req.SourcePageKey, req.SourcePageName) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +type orderHistoryReq struct { + Page int64 `json:"page" form:"page"` + PageSize int64 `json:"pageSize" form:"pageSize"` +} + +// GetOrderHistory 查询当前用户 VIP 订单历史 +// @Tags 充值 +// @Summary 查询 VIP 充值记录 +// @Accept json +// @Produce json +// @Param page query int false "页码(默认1)" +// @Param pageSize query int false "每页数量(默认10)" +// @Success 200 {array} vipordermod.VipOrder "订单列表" +// @Security ApiKeyAuth +// @Router /api/app/vip/orders [GET] +func GetOrderHistory(ctx *gin.Context) { + var req orderHistoryReq + _ = ctx.ShouldBindQuery(&req) + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + + userId := common.GetUserId(ctx) + list, total, serr := rechargeser.GetOrderHistory(ctx, userId, req.Page, req.PageSize) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, gin.H{"total": total, "list": list}) +} diff --git a/app/api/rechargepackageapi/package.go b/app/api/rechargepackageapi/package.go new file mode 100644 index 0000000..2d10f2a --- /dev/null +++ b/app/api/rechargepackageapi/package.go @@ -0,0 +1,25 @@ +package rechargepackageapi + +import ( + "videoweb/app/service/rechargepackageser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// List 查询购买套餐列表 +// @Tags 金币与会员 +// @Summary 查询购买套餐列表 -- by 王鸣 +// @Description 返回会员畅享和金币充值两个列表,并附带各套餐可用支付方式 +// @Produce json +// @Success 200 {object} proto.RechargePackageListResp "成功" +// @Router /api/app/recharge/package/list [GET] +func List(ctx *gin.Context) { + resp, err := rechargepackageser.GetRechargePackageList(ctx) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/app/api/statcenterapi/sync.go b/app/api/statcenterapi/sync.go new file mode 100644 index 0000000..005243b --- /dev/null +++ b/app/api/statcenterapi/sync.go @@ -0,0 +1,437 @@ +package statcenterapi + +import ( + "context" + "fmt" + "math" + "net/http" + "strconv" + "videoweb/pub/common" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/statcenter" + "videoweb/pub/models/cointransmod" + "videoweb/pub/models/usermod" + "videoweb/pub/models/uservisitlog" + "videoweb/pub/models/vipordermod" + "videoweb/pub/pubdef" + + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" + "go.mongodb.org/mongo-driver/bson" +) + +// @Tags 数据同步 +// @Summary 统计中心增量同步 +// @Description 按 job 拉取注册/日活/充值/绑定/消费/售卡等增量数据 +// @Accept json +// @Produce json +// @Param param body statcenter.StatcenterSyncReq true "参数" +// @Success 200 {object} statcenter.StatcenterSyncResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/statcenter/sync [POST] +func StatcenterSyncList(ctx *gin.Context) { + var request statcenter.StatcenterSyncReq + if err := ctx.ShouldBind(&request); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + + var resp statcenter.StatcenterSyncResp + resp.Job = request.Job + resp.Code = 200 + appId := statcenter.STAT_APPID + + var err error + switch request.Job { + case string(statcenter.USER_ACCE): + resp.AccessList, err = UserAccessList(appId, request, true) + case string(statcenter.USER_REG): + resp.RegisterList, err = UserRegisterList(appId, request, true) + case string(statcenter.USER_BINDING): + resp.BindingList, err = UserBindingList(appId, request) + case string(statcenter.USER_INVITE): + resp.InviteList, err = UserInviteList(appId, request) + case string(statcenter.ConsumeRecordJob): + resp.ConsumeList, err = ConsumeRecordList(appId, request) + case string(statcenter.USER_RECH): + resp.RechargeList, err = UserRechargeList(appId, request, true) + case string(statcenter.USER_RECH_ALL): + resp.AllRechargeList, err = UserAllRechargeList(appId, request, true) + case string(statcenter.CardSellJob): + resp.CardSellList, err = CardSellList(appId, request) + case string(statcenter.AI_SELL_JOB): + resp.AiSellList, err = AiSellList(appId, request) + case string(statcenter.VipJob): + resp.VipList, err = UserSyncVipList(appId, request) + case string(statcenter.RechargeAll): // 兼容旧任务名 + resp.RechargeList, err = UserAllRechargeList(appId, request, true) + default: + resp.Code = 501 + resp.Msg = "job is error" + ctx.JSON(http.StatusOK, resp) + return + } + + if err != nil { + resp.Code = 502 + resp.Msg = err.Error() + } + ctx.JSON(http.StatusOK, resp) +} + +// UserAccessList 日活数据同步 +func UserAccessList(appId int32, request statcenter.StatcenterSyncReq, isNewStat bool) (accessList []statcenter.UserVisitMsg, err error) { + platformId := request.PlatformId + if platformId == "" { + platformId = "000000000000000000000000" + } + visitList, err := uservisitlog.AccessSyncById(context.Background(), platformId, request.MaxSize) + if err != nil { + log.Error("UserAccessList AccessSyncById", log.E(err)) + return nil, err + } + accessList = make([]statcenter.UserVisitMsg, 0, len(visitList)) + userIds := make([]int64, 0, len(visitList)) + for _, visitInfo := range visitList { + userIds = append(userIds, visitInfo.UserId) + } + userList, _ := usermod.FindManyUser(context.Background(), bson.M{usermod.FN.User.Id: bson.M{"$in": userIds}}) + userMap := make(map[int64]*usermod.User, len(userList)) + for _, u := range userList { + userMap[u.Id] = u + } + for _, visitInfo := range visitList { + sType, dType := visitInfo.SysType, visitInfo.DevType + if appId == statcenter.STAT_APPID && isNewStat { + sType, dType = pubdef.GetCenterType(pubdef.SysTypeFromString(visitInfo.SysType), visitInfo.DevType) + } + accessInfo := statcenter.UserVisitMsg{ + UserId: uint64(visitInfo.UserId), + AppId: appId, + SysType: sType, + DevType: dType, + IP: visitInfo.IP, + Version: visitInfo.Version, + DevID: visitInfo.DevID, + VisitAt: visitInfo.VisitAt, + PlatformId: visitInfo.Id.Hex(), + } + if userInfo, ok := userMap[visitInfo.UserId]; ok { + accessInfo.IsDirect = userInfo.IsDirect + accessInfo.DistrictCode = userInfo.DistrictCode + accessInfo.RegisterTime = userInfo.CreatedAt + } + accessList = append(accessList, accessInfo) + } + return +} + +// UserRegisterList 注册数据同步 +func UserRegisterList(appId int32, request statcenter.StatcenterSyncReq, isNewStat bool) (registerList []statcenter.UserRegisterMsg, err error) { + var userList usermod.UserList + if appId == statcenter.STAT_APPID && isNewStat { + if request.UserId > math.MaxInt64 { + return nil, fmt.Errorf("invalid register cursor userId: %d", request.UserId) + } + userList, err = usermod.StatcenterSyncListByUserID(context.Background(), int64(request.UserId), request.MaxSize) + } else { + userList, err = usermod.StatcenterSyncList(context.Background(), request.SuccessTime, request.MaxSize) + } + if err != nil { + log.Error("UserRegisterList", log.E(err)) + return nil, err + } + registerList = make([]statcenter.UserRegisterMsg, 0, len(userList)) + for _, userInfo := range userList { + if userInfo.Device == "robot" { + continue + } + isNativeIOS := pubdef.SysTypeFromString(userInfo.SysType) == pubdef.ST_IOS + districtCode := userInfo.DistrictCode + if isNativeIOS && statcenter.IOSRegistrationDistrictCode != "" { + districtCode = statcenter.IOSRegistrationDistrictCode + } + + sType, dType := userInfo.SysType, userInfo.Device + if appId == statcenter.STAT_APPID && isNewStat { + sType, dType = pubdef.GetCenterType(pubdef.SysTypeFromString(userInfo.SysType), userInfo.Device) + } + registerList = append(registerList, statcenter.UserRegisterMsg{ + UserId: uint64(userInfo.Id), + AppId: appId, + SysType: sType, + DevType: dType, + Mobile: userInfo.Email, + Name: userInfo.NickName, + IP: userInfo.RegIp, + IsDirect: userInfo.IsDirect, + DistrictCode: districtCode, + PromSeqe: userInfo.AgentInviteCode, + PromCode: userInfo.InviteCode, + RegisterTime: userInfo.CreatedAt, + }) + } + return +} + +// UserBindingList 绑定数据同步(本项目:正式注册并绑定邮箱) +func UserBindingList(appId int32, request statcenter.StatcenterSyncReq) ([]statcenter.UserBindingMsg, error) { + userList, err := usermod.StatcenterSyncBindUserList(context.Background(), request.SuccessTime, request.MaxSize) + if err != nil { + log.Error("UserBindingList", log.E(err)) + return nil, err + } + list := make([]statcenter.UserBindingMsg, 0, len(userList)) + for _, userInfo := range userList { + sType, dType := pubdef.GetCenterType(pubdef.SysTypeFromString(userInfo.SysType), userInfo.Device) + bindAt := userInfo.UpdatedAt + list = append(list, statcenter.UserBindingMsg{ + UserId: uint64(userInfo.Id), + AppId: appId, + PlatformId: fmt.Sprintf("%d", userInfo.Id), + SysType: sType, + DevType: dType, + Mobile: userInfo.Email, + BindingTime: &bindAt, + }) + } + return list, nil +} + +// UserInviteList 邀请绑定同步 +func UserInviteList(appId int32, request statcenter.StatcenterSyncReq) ([]statcenter.UserInviteBindMsg, error) { + userList, err := usermod.StatcenterSyncInviteList(context.Background(), request.SuccessTime, request.MaxSize) + if err != nil { + log.Error("UserInviteList", log.E(err)) + return nil, err + } + list := make([]statcenter.UserInviteBindMsg, 0, len(userList)) + for _, userInfo := range userList { + list = append(list, statcenter.UserInviteBindMsg{ + UserId: uint64(userInfo.Id), + AppId: appId, + ParentPromCode: userInfo.AgentInviteCode, + InviteTime: userInfo.CreatedAt, + }) + } + return list, nil +} + +// ConsumeRecordList 金币消费流水同步 +func ConsumeRecordList(appId int32, request statcenter.StatcenterSyncReq) ([]statcenter.ConsumeRecordMsg, error) { + startId := int64(0) + if request.PlatformId != "" { + id, err := strconv.ParseInt(request.PlatformId, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid consume cursor platformId: %s", request.PlatformId) + } + startId = id + } + transList, err := cointransmod.StatSyncConsumeList(context.Background(), startId, request.MaxSize) + if err != nil { + log.Error("ConsumeRecordList", log.E(err)) + return nil, err + } + list := make([]statcenter.ConsumeRecordMsg, 0, len(transList)) + for _, item := range transList { + consumeType := statcenter.StatValueAddSer + if item.TranType == cointransmod.TranTypeVideoUnlock { + consumeType = statcenter.StatVideoUnlock + } + list = append(list, statcenter.ConsumeRecordMsg{ + AppID: appId, + UID: uint64(item.UserId), + Type: consumeType, + CurrencyType: statcenter.CurrencyTypeGold, + Money: decimal.Zero, + Amount: decimal.NewFromInt(item.Amount), + Uniq: strconv.FormatInt(item.Id, 10), + CreatedAt: item.CreatedAt, + }) + } + return list, nil +} + +// UserRechargeList 成功充值订单同步(会员+金币) +func UserRechargeList(appId int32, request statcenter.StatcenterSyncReq, isNewStat bool) (registerList []statcenter.UserRechargeMsg, err error) { + orderList, err := vipordermod.StatSyncSuccessOrderList(context.Background(), request.SuccessTime, request.MaxSize) + if err != nil { + log.Error("UserRechargeList StatSyncSuccessOrderList", log.E(err)) + return nil, err + } + userMap := findUserDistrictMap(orderList) + registerList = make([]statcenter.UserRechargeMsg, 0, len(orderList)) + for _, order := range orderList { + sType, dType := order.SysType, "" + if appId == statcenter.STAT_APPID && isNewStat { + sType, dType = pubdef.GetCenterType(pubdef.SysTypeFromString(order.SysType), "") + } + msg := statcenter.UserRechargeMsg{ + UserId: uint64(order.UserId), + AppId: appId, + SysType: sType, + DevType: dType, + PlatformId: fmt.Sprintf("%d", order.Id), + ChannelName: order.PayChannel, + CID: order.Oid, + Type: order.PayMode, + OrderId: order.TradeNo, + OID: order.Oid, + Money: order.Amount, + PayMoney: order.Amount, + Status: statcenter.MapOrderStatusToStat(order.Status), + Rate: order.PayRate, + SuccessAt: order.FinishedAt, + CreatedAt: order.CreatedAt, + } + fillRechargeDistrict(&msg, userMap) + if order.OrderType == vipordermod.OrderTypeCoin { + msg.ChanShareMod = 1 + } else { + msg.ChanShareMod = 2 + } + registerList = append(registerList, msg) + } + return +} + +// UserAllRechargeList 全部非失败充值订单同步 +func UserAllRechargeList(appId int32, request statcenter.StatcenterSyncReq, isNewStat bool) (registerList []statcenter.UserRechargeMsg, err error) { + orderList, err := vipordermod.StatSyncAllOrderList(context.Background(), request.SuccessTime, request.MaxSize) + if err != nil { + log.Error("UserAllRechargeList StatSyncAllOrderList", log.E(err)) + return nil, err + } + userMap := findUserDistrictMap(orderList) + registerList = make([]statcenter.UserRechargeMsg, 0, len(orderList)) + for _, order := range orderList { + sType, dType := order.SysType, "" + if appId == statcenter.STAT_APPID && isNewStat { + sType, dType = pubdef.GetCenterType(pubdef.SysTypeFromString(order.SysType), "") + } + msg := statcenter.UserRechargeMsg{ + UserId: uint64(order.UserId), + AppId: appId, + SysType: sType, + DevType: dType, + PlatformId: fmt.Sprintf("%d", order.Id), + ChannelName: order.PayChannel, + CID: order.Oid, + Type: order.PayMode, + OrderId: order.TradeNo, + OID: order.Oid, + Money: order.Amount, + PayMoney: order.Amount, + Status: statcenter.MapOrderStatusToStat(order.Status), + Rate: order.PayRate, + CreatedAt: order.CreatedAt, + SuccessAt: order.CreatedAt, + } + fillRechargeDistrict(&msg, userMap) + registerList = append(registerList, msg) + } + return +} + +// CardSellList 会员卡销售流水同步(成功会员订单) +func CardSellList(appId int32, request statcenter.StatcenterSyncReq) ([]statcenter.CardSellMsg, error) { + orderList, err := vipordermod.StatSyncVipOrderList(context.Background(), request.SuccessTime, request.MaxSize) + if err != nil { + log.Error("CardSellList", log.E(err)) + return nil, err + } + list := make([]statcenter.CardSellMsg, 0, len(orderList)) + for _, order := range orderList { + sType, _ := pubdef.GetCenterType(pubdef.SysTypeFromString(order.SysType), "") + list = append(list, statcenter.CardSellMsg{ + AppID: appId, + UID: uint64(order.UserId), + UniqID: fmt.Sprintf("%d", order.Id), + Amount: order.Amount, + TranTypeInt: int64(order.OrderType), + TranType: "会员卡", + SysType: sType, + CurrencyType: statcenter.CurrencyTypeCash, + TranCreatedAt: order.FinishedAt, + Product: statcenter.Product{ + ID: fmt.Sprintf("%d", order.CardId), + Name: order.CardName, + DiscountedPrice: order.Amount, + }, + }) + } + return list, nil +} + +// AiSellList AI 销售流水(本项目无 AI 业务,返回空列表) +func AiSellList(appId int32, request statcenter.StatcenterSyncReq) ([]statcenter.AiSellMsg, error) { + return make([]statcenter.AiSellMsg, 0), nil +} + +// UserSyncVipList VIP 购买成功订单同步 +func UserSyncVipList(appId int32, request statcenter.StatcenterSyncReq) (registerList []statcenter.UserBuyVIPMsg, err error) { + orderList, err := vipordermod.StatSyncVipOrderList(context.Background(), request.SuccessTime, request.MaxSize) + if err != nil { + log.Error("UserSyncVipList StatSyncVipOrderList", log.E(err)) + return nil, err + } + userMap := findUserDistrictMap(orderList) + registerList = make([]statcenter.UserBuyVIPMsg, 0, len(orderList)) + for _, order := range orderList { + msg := statcenter.UserBuyVIPMsg{ + UID: uint64(order.UserId), + AppId: appId, + PlatformId: fmt.Sprintf("%d", order.Id), + Name: order.CardName, + Money: order.Amount, + BuyVIPType: statcenter.BuyVIPTypeMoney, + OID: order.Oid, + DeviceType: order.SysType, + Status: 3, + SuccessAt: order.FinishedAt, + } + if userInfo, ok := userMap[order.UserId]; ok { + msg.IsDirect = userInfo.IsDirect + msg.DistrictCode = userInfo.DistrictCode + } + registerList = append(registerList, msg) + } + return +} + +func findUserDistrictMap(orderList vipordermod.VipOrderList) map[int64]*usermod.User { + userIds := make([]int64, 0, len(orderList)) + seen := make(map[int64]struct{}, len(orderList)) + for _, order := range orderList { + if order == nil || order.UserId <= 0 { + continue + } + if _, ok := seen[order.UserId]; ok { + continue + } + seen[order.UserId] = struct{}{} + userIds = append(userIds, order.UserId) + } + userMap := make(map[int64]*usermod.User, len(userIds)) + if len(userIds) == 0 { + return userMap + } + userList, _ := usermod.FindManyUser(context.Background(), bson.M{usermod.FN.User.Id: bson.M{"$in": userIds}}) + for _, u := range userList { + userMap[u.Id] = u + } + return userMap +} + +func fillRechargeDistrict(msg *statcenter.UserRechargeMsg, userMap map[int64]*usermod.User) { + if msg == nil { + return + } + userInfo, ok := userMap[int64(msg.UserId)] + if !ok || userInfo == nil { + return + } + msg.DistrictCode = userInfo.DistrictCode + msg.RegisterTime = userInfo.CreatedAt +} diff --git a/app/api/translationapi/translation.go b/app/api/translationapi/translation.go new file mode 100644 index 0000000..478dafd --- /dev/null +++ b/app/api/translationapi/translation.go @@ -0,0 +1,22 @@ +package translationapi + +import ( + "videoweb/app/service/translationser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// CategoryList app端导航分类列表 +// @Tags APP导航相关接口 +// @Summary app端导航分类列表 --by 王鸣 +// @Description 无请求参数,不分页,返回启用的顶部导航分类 +// @Accept json +// @Produce json +// @Success 200 {object} proto.AppTranslationInfo "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/translation/category/list [get] +func CategoryList(ctx *gin.Context) { + common.ServerJSON(ctx, srvdef.Success, translationser.GetTranslationData()) +} diff --git a/app/api/uploadapi/upload.go b/app/api/uploadapi/upload.go new file mode 100644 index 0000000..66f4300 --- /dev/null +++ b/app/api/uploadapi/upload.go @@ -0,0 +1,49 @@ +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" +) + +// UploadImage 上传图片 +// @Tags 上传 +// @Summary 图片上传 +// @Description 将图片转发至存储服务 /media/web/autopublish/image +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "图片文件" +// @Param watermark formData string false "是否加水印:1加,其他情况不加(头像不加)" +// @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 + } + + watermark := int64(0) + if ctx.PostForm("watermark") == "1" { + watermark = 1 + } + + param := &uploadser.ImageParam{ + FileHeader: f, + Watermark: watermark, + } + 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/usercenterapi/user_center.go b/app/api/usercenterapi/user_center.go new file mode 100644 index 0000000..1cbf6ea --- /dev/null +++ b/app/api/usercenterapi/user_center.go @@ -0,0 +1,31 @@ +package usercenterapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/usercenterser" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// Data app个人中心数据 +// @Tags app个人中心相关接口 +// @Summary app个人中心数据 --by 王鸣 +// @Description 返回当前用户观看、收藏、追剧中、已购剧集统计 +// @Accept json +// @Produce json +// @Success 200 {object} proto.UserCenterDataResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/app/user/center/data [get] +func Data(ctx *gin.Context) { + resp, err := usercenterser.Data(ctx, common.GetUserId(ctx)) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +var _ = proto.UserCenterDataResp{} diff --git a/app/api/videoapi/video.go b/app/api/videoapi/video.go new file mode 100644 index 0000000..18b21ba --- /dev/null +++ b/app/api/videoapi/video.go @@ -0,0 +1,246 @@ +package videoapi + +import ( + "videoweb/app/api/proto" + "videoweb/app/service/videoser" + "videoweb/pub/common" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/srvdef" + + "github.com/gin-gonic/gin" +) + +// AllList app端分页查询视频列表 +// @Tags 视频相关接口 +// @Summary app端分页查询视频列表 --by 王鸣 +// @Description 分页查询已上架视频,支持类型、题材和排序筛选 +// @Accept json +// @Produce json +// @Param params query proto.VideoAllListReq true "app端分页查询视频列表请求" +// @Success 200 {object} proto.VideoAllListResp{list=[]proto.VideoAllListItemResp} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/video/all/list [get] +func AllList(ctx *gin.Context) { + var req proto.VideoAllListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := videoser.VideoAllList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TopicList app端查询视频题材列表 +// @Tags 视频相关接口 +// @Summary app端查询视频题材列表 --by 王鸣 +// @Description 查询已上架视频题材列表,mediaType=0全部 1影集 2动画 +// @Accept json +// @Produce json +// @Param params query proto.VideoTopicListReq true "app端查询视频题材列表请求" +// @Success 200 {object} proto.VideoTopicListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/video/topic/list [get] +func TopicList(ctx *gin.Context) { + var req proto.VideoTopicListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := videoser.VideoTopicList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// List 普通分页查询视频 +// @Tags 视频相关接口 +// @Summary 普通分页查询视频 --by 旺鸣 +// @Description 按上架时间倒序普通分页查询已上架视频 +// @Accept json +// @Produce json +// @Param params query proto.VideoReq true "普通分页查询视频请求" +// @Success 200 {object} proto.VideoResp{list=[]proto.VideoItemResp} "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/video/list [get] +func List(ctx *gin.Context) { + var req proto.VideoReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := videoser.VideoList(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetLike app端视频点赞/取消点赞 +// @Tags 视频相关接口 +// @Summary app端视频点赞/取消点赞 --by 王鸣 +// @Description 设置当前用户视频点赞状态,点赞数只在状态真实变化时增减 +// @Accept json +// @Produce json +// @Param param body proto.VideoLikeSetReq true "视频点赞设置请求" +// @Success 200 {object} proto.VideoLikeSetResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/video/like/set [post] +func SetLike(ctx *gin.Context) { + var req proto.VideoLikeSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := videoser.SetLike(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetCollect app端视频收藏/取消收藏 +// @Tags 视频相关接口 +// @Summary app端视频收藏/取消收藏 --by 王鸣 +// @Description 设置当前用户视频收藏状态,收藏数只在状态真实变化时增减 +// @Accept json +// @Produce json +// @Param param body proto.VideoCollectSetReq true "视频收藏设置请求" +// @Success 200 {object} proto.VideoCollectSetResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/video/collect/set [post] +func SetCollect(ctx *gin.Context) { + var req proto.VideoCollectSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := videoser.SetCollect(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// HistoryList app端我的视频观看历史列表 +// @Tags 视频相关接口 +// @Summary app端我的视频观看历史列表 --by 王鸣 +// @Description 按当前用户最近观看时间倒序分页返回视频观看历史 +// @Accept json +// @Produce json +// @Param params query proto.VideoHistoryListReq true "视频观看历史列表请求" +// @Success 200 {object} proto.VideoHistoryListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/video/history/list [get] +func HistoryList(ctx *gin.Context) { + var req proto.VideoHistoryListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := videoser.HistoryList(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// DeleteHistory app端删除单条视频观看历史 +// @Tags 视频相关接口 +// @Summary app端删除单条视频观看历史 --by 王鸣 +// @Description 删除当前用户指定视频观看历史,不影响收藏和点赞 +// @Accept json +// @Produce json +// @Param param body proto.VideoHistoryDeleteReq true "删除单条视频观看历史请求" +// @Success 200 {object} proto.VideoHistoryDeleteResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/video/history/delete [post] +func DeleteHistory(ctx *gin.Context) { + var req proto.VideoHistoryDeleteReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := videoser.DeleteHistory(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ClearHistory app端清空我的视频观看历史 +// @Tags 视频相关接口 +// @Summary app端清空我的视频观看历史 --by 王鸣 +// @Description 清空当前用户全部视频观看历史,不影响收藏和点赞 +// @Accept json +// @Produce json +// @Success 200 {object} proto.VideoHistoryClearResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/video/history/clear [post] +func ClearHistory(ctx *gin.Context) { + resp, err := videoser.ClearHistory(ctx, common.GetUserId(ctx)) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// CollectList app端我的视频收藏列表 +// @Tags 视频相关接口 +// @Summary app端我的视频收藏列表 --by 王鸣 +// @Description 按当前用户收藏时间倒序分页返回视频收藏列表 +// @Accept json +// @Produce json +// @Param params query proto.VideoCollectListReq true "视频收藏列表请求" +// @Success 200 {object} proto.VideoCollectListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/video/collect/list [get] +func CollectList(ctx *gin.Context) { + var req proto.VideoCollectListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := videoser.CollectList(ctx, common.GetUserId(ctx), &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Detail 查询视频详情 +// @Tags 视频相关接口 +// @Summary 查询视频详情 --by 旺鸣 +// @Description 查询已上架视频详情 +// @Accept json +// @Produce json +// @Param params query proto.VideoInfoReq true "查询视频详情请求" +// @Success 200 {object} proto.VideoInfoResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/app/video/detail [get] +func Detail(ctx *gin.Context) { + var req proto.VideoInfoReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + ua, _ := common.GetUA(ctx) + resp, err := videoser.VideoInfo(ctx, &req, common.GetUserId(ctx), ipaddr.ResolveClientIP(ctx), ua.UserAgent) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/app/appg/appg.go b/app/appg/appg.go new file mode 100644 index 0000000..a6049f2 --- /dev/null +++ b/app/appg/appg.go @@ -0,0 +1,125 @@ +package appg + +import ( + "videoweb/pub/common/ipaddr" + "videoweb/pub/payCenter" + + "github.com/patrickmn/go-cache" +) + +const EsIndex = "sz" + +var ( + Conf GlobalCfgEx + Cache *cache.Cache + 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"` + 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"` + NewPayCenter payCenter.Conf `yaml:"newPayCenter"` + 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"` + TrustedProxies string `yaml:"trustedProxies"` + BucketVideoAddressV2 string `yaml:"bucketVideoAddressV2"` + 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"` + LsjMediaConf struct { + AppId string `yaml:"appId"` + ApiKey string `yaml:"apiKey"` + ApiUrl string `yaml:"apiUrl"` + ImageYuan string `yaml:"imageYuan"` + KeyHex string `yaml:"keyHex"` + M3u8Yuan string `yaml:"m3u8Yuan"` + TsYuan string `yaml:"tsYuan"` + DomainsRefreshHr int64 `yaml:"domainsRefreshHr"` + } `yaml:"lsjMediaConf"` +} 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..1e589ef --- /dev/null +++ b/app/appg/helper.go @@ -0,0 +1,34 @@ +package appg + +import ( + "fmt" + "math/rand/v2" + "time" +) + +var familyNames = []string{ + "赵", "钱", "孙", "李", "周", "吴", "郑", "王", "冯", "陈", + "褚", "卫", "蒋", "沈", "韩", "杨", "朱", "秦", "尤", "许", + "何", "吕", "施", "张", "孔", "曹", "严", "华", "金", "魏", + "陶", "姜", "戚", "谢", "邹", "喻", "柏", "水", "窦", "章", + "云", "苏", "潘", "葛", "奚", "范", "彭", "郎", "鲁", "韦", +} + +var givenNames = []string{ + "伟", "芳", "娜", "秀英", "敏", "静", "丽", "强", "磊", "洋", + "艳", "勇", "军", "杰", "娟", "涛", "明", "超", "秀兰", "霞", + "平", "刚", "桂英", "华", "玲", "辉", "英", "志明", "建国", "文", + "国强", "小红", "建军", "小明", "宇", "浩", "晨", "鑫", "博", "宸", + "若曦", "雨桐", "梓涵", "紫萱", "雨萱", "诗涵", "欣怡", "嘉怡", "子涵", "梦琪", +} + +// GenerateNickname 随机生成一个中文昵称,格式:姓+名 +// 若词表为空则降级为"用户"+时间戳后6位 +func GenerateNickname() string { + if len(familyNames) == 0 || len(givenNames) == 0 { + return fmt.Sprintf("用户%06d", time.Now().UnixNano()%1000000) + } + family := familyNames[rand.IntN(len(familyNames))] + given := givenNames[rand.IntN(len(givenNames))] + return family + given +} 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/deploy_prod.sh b/app/deploy_prod.sh new file mode 100755 index 0000000..09ed892 --- /dev/null +++ b/app/deploy_prod.sh @@ -0,0 +1,28 @@ +#!/bin/bash +FILE_NAME="web_mdcom_app" +REMOTE_FILE_PATH="/home/server2/web_mdcom/app/" +TAG_NAME="web_mdcom_app.tar.gz" + +set -e +set -o +function send_msg() { + msg="@jscdsndj @xiuyuan_Lee @steven0yu %0A服务:SERVER2  站点-91spz.com管理服务APP%0A更新内容:$content%0A创建时间: $(date +%F_%X)%0A操作人:$(git config user.name)" + curl -X POST "https://api.telegram.org/bot6357085965:AAEmXfjZnZwnDTicL7Wjbd7cXvYv_G98w8A/sendMessage" -d "chat_id=$1&text=$msg" >/dev/null 2>&1 +} + +read -p "输入yes, 开始上传: " word +if [[ ! "yes" =~ $word ]]; then + echo "终止" +else + read -e -p "本次更新内容(输入q退出编辑): " -r content + echo "开始" + rm -rf ./$FILE_NAME ./$TAG_NAME ./yinsemd5.txt + GOOS=linux GOARCH=amd64 go build -o $FILE_NAME + md5sum $FILE_NAME | cut -d ' ' -f1 >yinsemd5.txt + tar zvcf $TAG_NAME ./$FILE_NAME yinsemd5.txt + md5sum $TAG_NAME | cut -d ' ' -f1 >yinsemd5.txt + scp -i ~/.ssh/id_rsa -P 22889 ./yinsemd5.txt server2@116.212.120.2:$REMOTE_FILE_PATH + scp -i ~/.ssh/id_rsa -P 22889 ./$TAG_NAME server2@116.212.120.2:$REMOTE_FILE_PATH + send_msg -4791758991 #服务器小组 +fi +rm -rf ./$FILE_NAME ./$TAG_NAME ./yinsemd5.txt \ No newline at end of file diff --git a/app/deploy_test.sh b/app/deploy_test.sh new file mode 100755 index 0000000..80d3c94 --- /dev/null +++ b/app/deploy_test.sh @@ -0,0 +1,11 @@ +#!/bin/bash +sudo make test + +scp -P 22889 web_91av_app.tar.gz root@172.104.181.99:/server/web_91av/app/ +ssh root@172.104.181.99 -p 22889 "cd /server/web_91av/app/ && /bin/tar -xf /server/web_91av/app/web_91av_app.tar.gz && /usr/bin/supervisorctl restart web_91av_app" +if [ $? -eq 0 ];then + echo -e "\033[32m 91spz.com H5更新成功 \033[0m" +else + echo -e "\033[31m 91spz.com H5更新失败 \033[0m" +fi +rm -f web_91av_app.tar.gz diff --git a/app/docs/docs.go b/app/docs/docs.go new file mode 100644 index 0000000..82e429f --- /dev/null +++ b/app/docs/docs.go @@ -0,0 +1,8767 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package 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/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 获取最新用户信息,包含金币余额、VIP状态等", + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "获取当前用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "$ref": "#/definitions/authser.LoginResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/auth/guest": { + "post": { + "description": "按设备ID创建或找回游客账号;请求头可带 Authorization 复用已有游客会话。需传 X-User-Agent", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "游客登录", + "parameters": [ + { + "description": "游客登录参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authser.GuestLoginReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/authser.LoginResp" + } + }, + "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": "用户信息通过 token 获取,无需传账号;nickName、avatar、introduction、oldPassword+newPassword 均为可选,至少提供一项。未设置密码时传 newPassword 可设置密码,已设置密码时修改需传 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": "传入账号、邮箱、明文密码。请求头带游客 Authorization 时升级该游客为普通用户,否则新建用户", + "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/auth/resetPassword": { + "post": { + "description": "通过账号和邮箱双重校验后重置密码,两者必须同时匹配同一用户", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "找回密码", + "parameters": [ + { + "description": "找回密码参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authapi.resetPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/coin/buy": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据指定金币套餐发起第三方支付,返回支付链接", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "金币" + ], + "summary": "购买金币", + "parameters": [ + { + "description": "购买参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/coinapi.buyCoinReq" + } + } + ], + "responses": { + "200": { + "description": "支付链接", + "schema": { + "$ref": "#/definitions/coinser.BuyCoinResp" + } + } + } + } + }, + "/api/app/coin/orders": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "金币" + ], + "summary": "查询金币购买记录", + "parameters": [ + { + "type": "integer", + "description": "页码(默认1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认10)", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "订单列表", + "schema": { + "type": "object" + } + } + } + } + }, + "/api/app/coin/trans/export": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "导出当前登录用户的金币收入、支出流水CSV;type=0全部 1充值 2消费 3奖励", + "produces": [ + "text/csv" + ], + "tags": [ + "金币" + ], + "summary": "导出金币余额明细 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "类型:0全部 1充值 2消费 3奖励", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "CSV文件", + "schema": { + "type": "file" + } + } + } + } + }, + "/api/app/coin/trans/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "返回当前登录用户的金币收入、支出流水,按交易时间倒序;type=0全部 1充值 2消费 3奖励", + "produces": [ + "application/json" + ], + "tags": [ + "金币" + ], + "summary": "查询金币余额明细 -- by 旺鸣", + "parameters": [ + { + "type": "integer", + "description": "页码(默认1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认10,最大100)", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "类型:0全部 1充值 2消费 3奖励", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.BasePageResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CoinTransListItemResp" + } + } + } + } + ] + } + } + } + } + }, + "/api/app/comment/like/set": { + "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.CommentLikeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.CommentLikeSetResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/comment/list": { + "get": { + "description": "分页查询短剧、短剧剧集或视频评论树,一级评论分页返回,二级评论挂在children中,objectType 10-短剧 11-短剧剧集 20-视频", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论APP" + ], + "summary": "评论列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "对象ID(短剧id 短剧剧集id 视频id)", + "name": "objectId", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "对象类型 10-短剧 11-短剧剧集 20-视频", + "name": "objectType", + "in": "query", + "required": true + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-热门,按点赞数倒序 2-最新,按创建时间倒序", + "name": "sortOrder", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.GetCommentListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommentInfo" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/comment/send": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "发布短剧、短剧剧集或视频评论,parentsId为0发布一级评论,大于0发布二级评论,replyCommentId表示实际回复的评论,objectType 10-短剧 11-短剧剧集 20-视频", + "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/info": { + "get": { + "description": "返回最新地址信息、完整系统配置、广告配置和全站SEO配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP配置相关接口" + ], + "summary": "查询APP公共配置 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ConfigInfoResp" + } + }, + "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/contactus/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "用户提交联系我们表单,问题类型:1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "联系我们相关接口" + ], + "summary": "提交联系我们 -- by 旺鸣", + "parameters": [ + { + "description": "联系我们请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.ContactUsAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ContactUsAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/feedback/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "用户提交意见反馈,问题类型,1-功能建议、2-播放问题、3-账号问题、4-其他", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "意见反馈相关接口" + ], + "summary": "提交意见反馈 -- by 旺鸣", + "parameters": [ + { + "description": "意见反馈请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.FeedbackReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.FeedbackResp" + } + }, + "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/all/list": { + "get": { + "description": "分页查询已上架短剧,支持题材、更新状态和排序筛选", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询全部短剧 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-最新 2-最热 3-收藏最多", + "name": "sortType", + "in": "query" + }, + { + "type": "integer", + "description": "题材ID", + "name": "themeId", + "in": "query" + }, + { + "type": "string", + "description": "题材slug,非空时优先按slug解析题材ID", + "name": "themeSlug", + "in": "query" + }, + { + "type": "integer", + "description": "更新状态 0-全部 1-连载中 2-已完结", + "name": "updateStatus", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletAllListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/bought/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按当前用户最近购买时间倒序分页返回已购短剧列表,并返回已购集数、消耗金币和最近观看位置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端我的已购短剧列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletBoughtListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/collect/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按当前用户收藏时间倒序分页返回短剧剧集收藏列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端我的短剧剧集收藏列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterCollectListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/collect/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "设置当前用户短剧剧集收藏状态,收藏数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧剧集收藏/取消收藏 --by 王鸣", + "parameters": [ + { + "description": "短剧剧集收藏设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterCollectSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterCollectSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/detail": { + "get": { + "description": "根据短剧剧集ID查询播放页当前剧集详情;可传playletId或playletSlug校验剧集归属,slug非空时优先解析短剧ID", + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端根据ID查询短剧剧集详情 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "短剧剧集ID", + "name": "chapterId", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "短剧ID,传入时用于校验剧集归属", + "name": "playletId", + "in": "query" + }, + { + "type": "string", + "description": "短剧slug,非空时优先按slug解析短剧ID", + "name": "playletSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterInfoResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/like/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "设置当前用户短剧剧集点赞状态,点赞数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧剧集点赞/取消点赞 --by 王鸣", + "parameters": [ + { + "description": "短剧剧集点赞设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterLikeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterLikeSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/list": { + "get": { + "description": "根据短剧ID或slug查询右侧选集列表;slug非空时优先按slug解析短剧ID", + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端根据短剧ID查询剧集列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "短剧ID", + "name": "playletId", + "in": "query" + }, + { + "type": "string", + "description": "短剧slug,非空时优先按slug解析短剧ID", + "name": "playletSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/unlock": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "仅金币付费剧集可调用;接口具备幂等性,已解锁剧集重复调用不会重复扣金币", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端使用金币逐集解锁短剧剧集 --by 王鸣", + "parameters": [ + { + "description": "短剧剧集金币解锁请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterUnlockReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterUnlockResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/watch/report": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "上报当前用户短剧剧集播放进度,达到已看条件时用于我的收藏未看集数计算", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧剧集观看上报 --by 王鸣", + "parameters": [ + { + "description": "短剧剧集观看上报请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterWatchReportReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterWatchReportResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/collect/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按当前用户收藏时间倒序分页返回短剧收藏列表,并返回已看/未看集数和最近观看位置;watchStatus=0全部 1追剧中 2已看完", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端我的短剧收藏列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "观看状态 0-全部 1-追剧中 2-已看完", + "name": "watchStatus", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletCollectListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/collect/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "设置当前用户短剧收藏状态,收藏数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧收藏/取消收藏 --by 王鸣", + "parameters": [ + { + "description": "短剧收藏设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletCollectSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletCollectSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/detail": { + "get": { + "description": "根据短剧ID或slug查询已上架短剧详情;slug非空时优先按slug解析短剧ID", + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端根据ID查询短剧详情 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "短剧id", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "短剧slug,非空时优先按slug解析短剧ID", + "name": "slug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletInfoResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/forward/heat": { + "post": { + "description": "请求一次短剧热度值增加5,仅支持已上架短剧", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧转发增加热度 --by 王鸣", + "parameters": [ + { + "description": "短剧转发加热度请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletForwardHeatReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletForwardHeatResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/guess-like/list": { + "get": { + "description": "根据当前短剧同题材查询猜你喜欢,排除当前短剧", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询猜你喜欢短剧 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "当前短剧ID", + "name": "playletId", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletGuessLikeListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/history/clear": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "仅清空观看历史,不影响收藏列表和观看进度", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端清空我的短剧观看历史 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletHistoryClearResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/history/delete": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除当前用户指定短剧的观看历史,不影响观看进度、收藏和已购记录", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端删除单条短剧观看历史 --by 王鸣", + "parameters": [ + { + "description": "删除单条观看历史请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletHistoryDeleteReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletHistoryDeleteResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/history/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按当前用户最近观看时间倒序分页返回短剧观看历史;pageSize默认20、最大100", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端我的短剧观看历史列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletHistoryListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/home": { + "get": { + "description": "无请求参数,返回首页banner短剧、公告和SEO信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端首页短剧接口 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomePlayletResponse" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/home/config/detail/list": { + "get": { + "description": "根据首页配置ID分页查询短剧、视频或题材", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询首页配置详情列表--by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "首页配置ID", + "name": "homeConfigId", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "type=2时生效,视频类型 0-全部 1-影集 2-动画", + "name": "mediaType", + "in": "query" + }, + { + "type": "integer", + "description": "type=1时生效,运营类型 0-全部 1-短剧 2-漫剧", + "name": "operateType", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "type=1时生效,题材ID", + "name": "themeId", + "in": "query" + }, + { + "type": "string", + "description": "type=1时生效,题材slug,非空时优先按slug解析题材ID", + "name": "themeSlug", + "in": "query" + }, + { + "type": "integer", + "description": "type=1/type=2时生效,时间范围 0-综合 1-本周 2-本月", + "name": "timeRange", + "in": "query" + }, + { + "type": "integer", + "description": "type=2时生效,视频题材ID,对应video_topic", + "name": "topicId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeConfigDetailListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/hot-like": { + "get": { + "description": "返回缓存的前10个热搜词,以及按收藏量最多的15部猜你喜欢短剧", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端热搜词和猜你喜欢", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletHotLikeResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/hot/rank/list": { + "get": { + "description": "分页查询已上架短剧热榜;rankType=1热播榜 2新剧榜 3追剧榜", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询热榜 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "榜单类型 1-热播榜 2-新剧榜 3-追剧榜", + "name": "rankType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletHotRankListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/like/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "设置当前用户短剧点赞状态,点赞数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧点赞/取消点赞 --by 王鸣", + "parameters": [ + { + "description": "短剧点赞设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletLikeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletLikeSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/operate/list": { + "get": { + "description": "按运营类型查询已上架短剧;operateType=1短剧 2漫剧;filterType=1最近更新(按上架时间) 2当前热播(按收藏数) 3独家原创(原创且按推荐排序)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端按运营类型分页查询短剧", + "parameters": [ + { + "type": "integer", + "description": "筛选类型 1-最近更新 2-当前热播 3-独家原创", + "name": "filterType", + "in": "query" + }, + { + "type": "integer", + "description": "运营类型 1-短剧 2-漫剧", + "name": "operateType", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletOperateListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/rank": { + "get": { + "description": "分页查询已上架短剧排行榜;sortOrder=1按热度值倒序,2按上架时间倒序,3按收藏数倒序", + "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": "排序 1-热播榜,按热度值倒序 2-新剧榜,按上架时间倒序 3-追剧榜,按收藏数倒序", + "name": "sortOrder", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletRankResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/search": { + "get": { + "description": "使用ES分页搜索已上架短剧,支持剧名、题材名、简介和标签名检索", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端ES分页搜索短剧 --by 王鸣", + "parameters": [ + { + "type": "string", + "description": "搜索关键词,支持剧名、题材名、简介和标签名", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletSearchResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/tag/list": { + "get": { + "description": "根据标签ID或标签slug分页查询已上架短剧;slug非空时优先按slug解析标签ID;sortType=1最新 2最热 3收藏最多", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端根据标签ID分页查询短剧 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-最新 2-最热 3-收藏最多", + "name": "sortType", + "in": "query" + }, + { + "type": "integer", + "description": "标签ID,tagSlug为空时必传", + "name": "tagId", + "in": "query" + }, + { + "type": "string", + "description": "标签slug,非空时优先按slug解析标签ID", + "name": "tagSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletTagVideoResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/tag/options": { + "get": { + "description": "无请求参数,不分页,返回已上架短剧标签列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧标签相关接口" + ], + "summary": "app端短剧标签列表 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletTagListResponse" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/today-update": { + "get": { + "description": "分页查询已上架短剧", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询今日更新短剧 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletTodayUpdateResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/topic/detail": { + "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 + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-编辑排序 2-最新 3-最热", + "name": "sortType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletTopicDetailResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "moreTopics": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletTopicPageItem" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/topic/list": { + "get": { + "description": "分页查询已上架且有短剧数据的专题剧单", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询专题剧单 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-最新 2-最热", + "name": "sortType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletTopicPageListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletTopicPageItem" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/recharge/package/list": { + "get": { + "description": "返回会员畅享和金币充值两个列表,并附带各套餐可用支付方式", + "produces": [ + "application/json" + ], + "tags": [ + "金币与会员" + ], + "summary": "查询购买套餐列表 -- by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.RechargePackageListResp" + } + } + } + } + }, + "/api/app/statcenter/sync": { + "post": { + "description": "按 job 拉取注册/日活/充值/绑定/消费/售卡等增量数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "数据同步" + ], + "summary": "统计中心增量同步", + "parameters": [ + { + "description": "参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/statcenter.StatcenterSyncReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/statcenter.StatcenterSyncResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/theme/list": { + "get": { + "description": "无请求参数,不分页,返回已上架题材列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧题材相关接口" + ], + "summary": "app端短剧题材列表 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletThemeListResponse" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/translation/category/list": { + "get": { + "description": "无请求参数,不分页,返回启用的顶部导航分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP导航相关接口" + ], + "summary": "app端导航分类列表 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + }, + "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 + }, + { + "type": "string", + "description": "是否加水印:1加,其他情况不加(头像不加)", + "name": "watermark", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/uploadser.ImageUploadResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/user/center/data": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "返回当前用户观看、收藏、追剧中、已购剧集统计", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app个人中心相关接口" + ], + "summary": "app个人中心数据 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.UserCenterDataResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/all/list": { + "get": { + "description": "分页查询已上架视频,支持类型、题材和排序筛选", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端分页查询视频列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "类型 0-全部 1-影集 2-动画", + "name": "mediaType", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-最新 2-最热 3-收藏最多", + "name": "sortType", + "in": "query" + }, + { + "type": "integer", + "description": "题材ID,对应video_topic", + "name": "topicId", + "in": "query" + }, + { + "type": "string", + "description": "题材slug,非空时优先按slug解析题材ID", + "name": "topicSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.VideoAllListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoAllListItemResp" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/collect/list": { + "get": { + "description": "按当前用户收藏时间倒序分页返回视频收藏列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端我的视频收藏列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoCollectListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/collect/set": { + "post": { + "description": "设置当前用户视频收藏状态,收藏数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端视频收藏/取消收藏 --by 王鸣", + "parameters": [ + { + "description": "视频收藏设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.VideoCollectSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoCollectSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/detail": { + "get": { + "description": "查询已上架视频详情", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "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": { + "$ref": "#/definitions/proto.VideoInfoResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/history/clear": { + "post": { + "description": "清空当前用户全部视频观看历史,不影响收藏和点赞", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端清空我的视频观看历史 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoHistoryClearResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/history/delete": { + "post": { + "description": "删除当前用户指定视频观看历史,不影响收藏和点赞", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端删除单条视频观看历史 --by 王鸣", + "parameters": [ + { + "description": "删除单条视频观看历史请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.VideoHistoryDeleteReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoHistoryDeleteResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/history/list": { + "get": { + "description": "按当前用户最近观看时间倒序分页返回视频观看历史", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端我的视频观看历史列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoHistoryListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/like/set": { + "post": { + "description": "设置当前用户视频点赞状态,点赞数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端视频点赞/取消点赞 --by 王鸣", + "parameters": [ + { + "description": "视频点赞设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.VideoLikeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoLikeSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/list": { + "get": { + "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.VideoResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoItemResp" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/topic/list": { + "get": { + "description": "查询已上架视频题材列表,mediaType=0全部 1影集 2动画", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端查询视频题材列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "类型 0-全部 1-影集 2-动画", + "name": "mediaType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoTopicListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/vip/orders": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "充值" + ], + "summary": "查询 VIP 充值记录", + "parameters": [ + { + "type": "integer", + "description": "页码(默认1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认10)", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "订单列表", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/vipordermod.VipOrder" + } + } + } + } + } + }, + "/api/app/vip/pay": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "充值" + ], + "summary": "创建 VIP 充值订单(第三方支付)", + "parameters": [ + { + "description": "下单参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/rechargeapi.submitOrderReq" + } + } + ], + "responses": { + "200": { + "description": "支付链接", + "schema": { + "$ref": "#/definitions/rechargeser.SubmitOrderResp" + } + } + } + } + }, + "/richtext/: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" + } + } + } + } + } + }, + "definitions": { + "authapi.loginReq": { + "type": "object", + "required": [ + "account", + "password" + ], + "properties": { + "account": { + "description": "账号或邮箱", + "type": "string" + }, + "channel": { + "description": "渠道码(可选,用户无商区码时写入)", + "type": "string" + }, + "devID": { + "description": "设备唯一标示(可选)", + "type": "string" + }, + "password": { + "description": "明文密码", + "type": "string" + } + } + }, + "authapi.registerReq": { + "type": "object", + "required": [ + "accountNo", + "email", + "password" + ], + "properties": { + "accountNo": { + "description": "账号", + "type": "string" + }, + "agentInviteCode": { + "description": "邀请人的邀请码(可选)", + "type": "string" + }, + "channel": { + "description": "渠道码(可选,用户无商区码时写入)", + "type": "string" + }, + "devID": { + "description": "设备唯一标示(可选)", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "password": { + "description": "明文密码", + "type": "string" + } + } + }, + "authapi.resetPasswordReq": { + "type": "object", + "required": [ + "accountNo", + "email", + "newPassword" + ], + "properties": { + "accountNo": { + "description": "账号", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "newPassword": { + "description": "新密码", + "type": "string" + } + } + }, + "authapi.updateProfileReq": { + "type": "object", + "properties": { + "avatar": { + "description": "新头像 URL(可选)", + "type": "string" + }, + "introduction": { + "description": "简介(可选)", + "type": "string" + }, + "newPassword": { + "description": "新密码明文(设置或修改密码时必填)", + "type": "string" + }, + "nickName": { + "description": "新昵称(可选)", + "type": "string" + }, + "oldPassword": { + "description": "旧密码明文(已设置密码时修改必填)", + "type": "string" + }, + "privacyMode": { + "description": "隐私模式:true-开启,false-关闭(可选)", + "type": "boolean" + } + } + }, + "authser.GuestLoginReq": { + "type": "object", + "properties": { + "channel": { + "description": "渠道码(可选,用户无商区码时写入 DistrictCode)", + "type": "string" + }, + "devID": { + "description": "设备唯一标示(必填)", + "type": "string" + }, + "deviceId": { + "description": "备用设备ID(可选,写入 DevV2)", + "type": "string" + }, + "mac": { + "description": "设备 Mac(可选)", + "type": "string" + } + } + }, + "authser.LoginResp": { + "type": "object", + "properties": { + "accountNo": { + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "currentVipExpire": { + "description": "VIP 过期时间,客户端需结合 vipType 判断是否真正有效", + "type": "string" + }, + "email": { + "type": "string" + }, + "hasPassword": { + "description": "是否已设置密码", + "type": "boolean" + }, + "introduction": { + "description": "简介", + "type": "string" + }, + "inviteCode": { + "description": "邀请码", + "type": "string" + }, + "isNewUser": { + "description": "本次是否新建游客", + "type": "boolean" + }, + "isRegister": { + "description": "是否已注册", + "type": "boolean" + }, + "loginType": { + "description": "登陆方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.LoginType" + } + ] + }, + "nickName": { + "type": "string" + }, + "privacyMode": { + "description": "隐私模式", + "type": "boolean" + }, + "recharge": { + "description": "金币余额", + "type": "integer" + }, + "score": { + "description": "积分", + "type": "integer" + }, + "showCardId": { + "description": "最新会员卡id", + "type": "integer" + }, + "showCardName": { + "description": "最新会员卡名称", + "type": "string" + }, + "status": { + "$ref": "#/definitions/pubdef.UserStatus" + }, + "token": { + "type": "string" + }, + "type": { + "description": "身份类型:0游客 1普通用户", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserType" + } + ] + }, + "userId": { + "type": "integer" + }, + "vipType": { + "$ref": "#/definitions/pubdef.VipType" + } + } + }, + "coinapi.buyCoinReq": { + "type": "object", + "required": [ + "packageId", + "payMode" + ], + "properties": { + "packageId": { + "description": "购买套餐ID(金币充值)", + "type": "integer" + }, + "payMode": { + "description": "支付方式:alipay/wechat", + "type": "string" + } + } + }, + "coinser.BuyCoinResp": { + "type": "object", + "properties": { + "isOpenNewBrowser": { + "type": "boolean" + }, + "payUrl": { + "type": "string" + }, + "payUrlMode": { + "type": "string" + }, + "tradeNo": { + "type": "string" + } + } + }, + "cointransmod.FlowDir": { + "type": "integer", + "enum": [ + 1, + -1 + ], + "x-enum-comments": { + "FlowIn": "收入(充值、赠送)", + "FlowOut": "支出(消费、解锁)" + }, + "x-enum-varnames": [ + "FlowIn", + "FlowOut" + ] + }, + "cointransmod.TranType": { + "type": "integer", + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "x-enum-comments": { + "TranTypeAdminAdjust": "管理员手动调整", + "TranTypeCoinGift": "金币充值赠送", + "TranTypeCoinRecharge": "金币充值(购买金币)", + "TranTypeDarkAlley": "历史遗留交易类型(已下线)", + "TranTypeVideoUnlock": "视频解锁", + "TranTypeVipGiftCoin": "购买会员赠送金币" + }, + "x-enum-varnames": [ + "TranTypeCoinRecharge", + "TranTypeVipGiftCoin", + "TranTypeDarkAlley", + "TranTypeVideoUnlock", + "TranTypeAdminAdjust", + "TranTypeCoinGift" + ] + }, + "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" + } + }, + "bottomBanner": { + "description": "底部banner", + "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" + } + }, + "downList": { + "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" + } + }, + "nineGridList": { + "description": "9宫格", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "pauseyList": { + "description": "暂停广告", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "playList": { + "description": "播放强制跳转", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "titleList": { + "description": "标题广告", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "videoInfoList": { + "description": "视频详情", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + } + } + }, + "proto.AnnouncementItem": { + "type": "object", + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "id": { + "description": "公告ID", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "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.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" + } + ] + }, + "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" + } + } + }, + "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.CoinTransListItemResp": { + "type": "object", + "properties": { + "amount": { + "description": "本次交易金币数,始终为正数", + "type": "integer" + }, + "balanceAfter": { + "description": "交易后余额", + "type": "integer" + }, + "balanceBefore": { + "description": "交易前余额", + "type": "integer" + }, + "changeAmount": { + "description": "带方向的金币变动数,收入为正数,支出为负数", + "type": "integer" + }, + "createdAt": { + "description": "交易时间,格式:2006-01-02 15:04:05", + "type": "string" + }, + "flowDir": { + "description": "收支方向:1-收入,-1-支出", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.FlowDir" + } + ] + }, + "flowDirName": { + "description": "收支方向名称:收入/支出", + "type": "string" + }, + "id": { + "description": "流水ID", + "type": "integer" + }, + "orderNo": { + "description": "关联订单号", + "type": "string" + }, + "relatedId": { + "description": "关联内容ID", + "type": "integer" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "title": { + "description": "标题,优先使用备注,备注为空时使用交易类型名称", + "type": "string" + }, + "tranType": { + "description": "交易类型 1-金币充值(购买金币)2-购买会员赠送金币 3-历史遗留 4-视频解锁 5-管理员手动调整", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.TranType" + } + ] + }, + "tranTypeName": { + "description": "交易类型名称", + "type": "string" + } + } + }, + "proto.CommentInfo": { + "type": "object", + "properties": { + "children": { + "description": "二级评论列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommentInfo" + } + }, + "commentCount": { + "description": "二级评论数量", + "type": "integer" + }, + "countLike": { + "description": "评论点赞数", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "description": "评论ID", + "type": "integer" + }, + "isLike": { + "description": "当前用户点赞状态 1-已点赞 2-未点赞", + "type": "integer" + }, + "level": { + "description": "评论级别 1-一级评论 2-二级评论", + "type": "integer" + }, + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "objectType": { + "description": "对象类型 10-短剧 11-短剧剧集 20-视频", + "type": "integer" + }, + "parentsId": { + "description": "父评论ID,一级评论为0", + "type": "integer" + }, + "replyCommentId": { + "description": "被回复评论ID,一级评论为0", + "type": "integer" + }, + "replyUserName": { + "description": "被回复用户昵称", + "type": "string" + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "userAvatar": { + "description": "用户头像,已拼接图片域名", + "type": "string" + }, + "userId": { + "description": "评论用户ID", + "type": "integer" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "proto.CommentLikeSetReq": { + "type": "object", + "required": [ + "commentId", + "isLike" + ], + "properties": { + "commentId": { + "description": "评论ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞,false-取消点赞", + "type": "boolean" + } + } + }, + "proto.CommentLikeSetResp": { + "type": "object", + "properties": { + "commentId": { + "description": "评论ID", + "type": "integer" + }, + "countLike": { + "description": "评论点赞数", + "type": "integer" + }, + "isLike": { + "description": "点赞状态 1-已点赞 2-未点赞", + "type": "integer" + } + } + }, + "proto.CommentSendReq": { + "type": "object", + "properties": { + "objectId": { + "description": "对象ID,短剧评论传短剧ID,剧集评论传剧集ID,视频评论传视频ID", + "type": "integer" + }, + "objectType": { + "description": "对象类型 10-短剧 11-短剧剧集 20-视频", + "type": "integer" + }, + "parentsId": { + "description": "一级评论ID,0-发布一级评论,大于0-发布二级评论", + "type": "integer" + }, + "replyCommentId": { + "description": "被回复评论ID,0时默认回复parentsId", + "type": "integer" + }, + "text": { + "description": "评论内容", + "type": "string" + } + } + }, + "proto.CommonData": { + "type": "object", + "properties": { + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图,已拼接图片域名", + "type": "string" + }, + "categoryIds": { + "description": "分类ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "countBrowse": { + "description": "观看量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "点赞数,包含真实点赞数和后台虚拟点赞数", + "type": "integer" + }, + "cover": { + "description": "竖版封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "description": { + "description": "SEO Description", + "type": "string" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "horizontalCover": { + "description": "横版封面,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "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" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "type": "integer" + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "preview": { + "description": "预告地址", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态: 0下架 1上架", + "type": "integer" + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "tagSlugs": { + "description": "标签slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeSlugs": { + "description": "题材slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + } + } + }, + "proto.ConfigInfo": { + "type": "object", + "properties": { + "actressBackGround": { + "description": "女优通用背景图", + "type": "string" + }, + "advitiesTopicNum": { + "description": "广告间隔", + "type": "integer" + }, + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "customerServiceHost": { + "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" + }, + "mediaAppUsCdn": { + "description": "视频CDN(海外app使用)", + "type": "string" + }, + "newUrlAddr": { + "description": "最新地址", + "type": "string" + }, + "newUrlAddr1": { + "description": "最新地址(备用地址1)", + "type": "string" + }, + "newUrlAddr2": { + "description": "最新地址(备用地址2)", + "type": "string" + }, + "officialAppAddr": { + "description": "官网app地址", + "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" + } + }, + "watermarkImage": { + "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" + } + } + }, + "proto.ConfigInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告配置", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "config": { + "description": "完整系统配置(与首页等接口的 config 一致,含 potaoGroup 等其余字段)", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "navPageList": { + "description": "导航页线路集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.NavPageItem" + } + }, + "newAddressInfo": { + "description": "最新地址信息", + "allOf": [ + { + "$ref": "#/definitions/proto.NewAddressInfo" + } + ] + }, + "reportSdkConfig": { + "description": "数据上报SDK域名配置", + "type": "string" + }, + "seoInfo": { + "description": "全站SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "seoNavInfo": { + "description": "导航SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoNavInfo" + } + ] + } + } + }, + "proto.ContactUsAddReq": { + "type": "object", + "required": [ + "content", + "email", + "title", + "type" + ], + "properties": { + "attachments": { + "description": "附件地址,可存放多张图片或文件地址", + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "description": "内容", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "title": { + "description": "标题", + "type": "string" + }, + "type": { + "description": "问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他", + "type": "integer" + } + } + }, + "proto.ContactUsAddResp": { + "type": "object", + "properties": { + "message": { + "description": "提示信息", + "type": "string" + } + } + }, + "proto.FeedbackReq": { + "type": "object", + "required": [ + "content", + "type" + ], + "properties": { + "contact": { + "description": "联系方式,可为空", + "type": "string" + }, + "content": { + "description": "描述内容,最多500字", + "type": "string" + }, + "screenshots": { + "description": "问题截图,可存放多张图片地址", + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "description": "问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他", + "type": "integer" + } + } + }, + "proto.FeedbackResp": { + "type": "object", + "properties": { + "message": { + "description": "提示信息", + "type": "string" + } + } + }, + "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.GetCommentListResp": { + "type": "object", + "properties": { + "list": { + "description": "数据", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommentInfo" + } + }, + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.HomeConfigData": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "list": { + "description": "配置对应短剧集合,type=1时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "sortType": { + "description": "排序字段 1-推荐 2-热播 3-最近更新 4-新片预告 5-分类", + "type": "integer" + }, + "status": { + "description": "上下架状态 true-上架 false-下架", + "type": "boolean" + }, + "themeList": { + "description": "题材集合,type=3时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HomeConfigThemeData" + } + }, + "type": { + "description": "类型 1-短剧 2-视频 3-题材", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "videoList": { + "description": "视频集合,type=2时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoItemResp" + } + } + } + }, + "proto.HomeConfigDetailListResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "desc": { + "description": "描述", + "type": "string" + }, + "homeConfigId": { + "description": "首页配置ID", + "type": "integer" + }, + "list": { + "description": "短剧集合,type=1时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "name": { + "description": "名称", + "type": "string" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "sortType": { + "description": "排序字段 1-推荐 2-热播 3-最近更新 4-新片预告 5-分类", + "type": "integer" + }, + "themeList": { + "description": "题材集合,type=3时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HomeConfigThemeData" + } + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + }, + "type": { + "description": "类型 1-短剧 2-视频 3-题材", + "type": "integer" + }, + "videoList": { + "description": "视频集合,type=2时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoItemResp" + } + } + } + }, + "proto.HomeConfigThemeData": { + "type": "object", + "properties": { + "icon": { + "description": "题材图标,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "题材ID", + "type": "integer" + }, + "list": { + "description": "题材下短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "name": { + "description": "题材名称", + "type": "string" + }, + "playletCount": { + "description": "绑定短剧数量", + "type": "integer" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "题材对应页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "题材slug", + "type": "string" + } + } + }, + "proto.HomeHtmlData": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "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.HomePlayletResponse": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcements": { + "description": "启用中的公告,来自独立缓存", + "type": "array", + "items": { + "$ref": "#/definitions/proto.AnnouncementItem" + } + }, + "homeBannerData": { + "description": "首页banner短剧信息列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "homeConfigData": { + "description": "首页配置集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HomeConfigData" + } + }, + "seoInfo": { + "description": "页面SEO信息(短剧列表页SEO)", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + } + } + }, + "proto.HotSearchItem": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架shi时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "images": { + "description": "封面", + "type": "string" + }, + "title": { + "description": "标题", + "type": "string" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "proto.NavPageItem": { + "type": "object", + "properties": { + "line": { + "description": "线路序号", + "type": "integer" + }, + "url": { + "description": "线路地址", + "type": "string" + } + } + }, + "proto.NewAddressInfo": { + "type": "object", + "properties": { + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "customerServiceHost": { + "description": "在线客服", + "type": "string" + }, + "foreverUrlAddr": { + "description": "永久海外地址(翻墙访问 永不迷路)", + "type": "string" + }, + "gmailAddr": { + "description": "邮箱地址", + "type": "string" + }, + "newUrlAddr": { + "description": "新国内地址", + "type": "string" + }, + "newUrlAddr1": { + "description": "备用地址1", + "type": "string" + }, + "newUrlAddr2": { + "description": "备用地址1", + "type": "string" + }, + "officialAppAddr": { + "description": "官网app地址", + "type": "string" + }, + "pdfAddr": { + "description": "回家的路PDF", + "type": "string" + }, + "telegram": { + "description": "商务tg", + "type": "string" + }, + "tgGroup": { + "description": "tg群", + "type": "string" + }, + "twitterAddr": { + "description": "推特地址", + "type": "string" + } + } + }, + "proto.PayMethodInfo": { + "type": "object", + "properties": { + "type": { + "description": "alipay / wechat / union", + "type": "string" + }, + "typeName": { + "description": "支付宝 / 微信 / 银联", + "type": "string" + } + } + }, + "proto.PlayletAllListResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletBoughtData": { + "type": "object", + "properties": { + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图,已拼接图片域名", + "type": "string" + }, + "boughtAt": { + "description": "最近购买时间", + "type": "string" + }, + "boughtChapterCount": { + "description": "已购剧集数量", + "type": "integer" + }, + "categoryIds": { + "description": "分类ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "costCoins": { + "description": "已消耗金币", + "type": "integer" + }, + "countBrowse": { + "description": "观看量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "点赞数,包含真实点赞数和后台虚拟点赞数", + "type": "integer" + }, + "cover": { + "description": "竖版封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "description": { + "description": "SEO Description", + "type": "string" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "horizontalCover": { + "description": "横版封面,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "type": "boolean" + }, + "isFinished": { + "description": "当前用户是否已看完", + "type": "boolean" + }, + "isNew": { + "description": "是否上新,本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐,false-不推荐", + "type": "boolean" + }, + "lastChapterId": { + "description": "最近观看剧集ID,无观看进度时为0", + "type": "integer" + }, + "lastEpisode": { + "description": "最近观看集数,无观看进度时为0", + "type": "integer" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "type": "integer" + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "preview": { + "description": "预告地址", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态: 0下架 1上架", + "type": "integer" + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "tagSlugs": { + "description": "标签slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeSlugs": { + "description": "题材slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "unwatchedCount": { + "description": "当前用户未看集数,按totalEpisode-watchedCount计算", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "watchedCount": { + "description": "当前用户已看集数", + "type": "integer" + } + } + }, + "proto.PlayletBoughtListResp": { + "type": "object", + "properties": { + "list": { + "description": "已购短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletBoughtData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总已购短剧数量", + "type": "integer" + } + } + }, + "proto.PlayletChapterCollectData": { + "type": "object", + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "chapterTitle": { + "description": "剧集标题", + "type": "string" + }, + "collectedAt": { + "description": "收藏时间", + "type": "string" + }, + "countBrowse": { + "description": "剧集观看数", + "type": "integer" + }, + "countCollect": { + "description": "剧集收藏数", + "type": "integer" + }, + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态,收藏列表固定返回1-已收藏", + "type": "integer" + }, + "mediaTime": { + "description": "影片长度(秒)", + "type": "integer" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "type": "integer" + }, + "playletCover": { + "description": "短剧封面,已拼接图片域名", + "type": "string" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "playletTitle": { + "description": "短剧标题", + "type": "string" + }, + "singleCoins": { + "description": "单集金币", + "type": "integer" + }, + "totalEpisode": { + "description": "短剧总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "videoUrl": { + "description": "播放地址", + "type": "string" + } + } + }, + "proto.PlayletChapterCollectListResp": { + "type": "object", + "properties": { + "list": { + "description": "收藏剧集集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterCollectData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总收藏数量", + "type": "integer" + } + } + }, + "proto.PlayletChapterCollectSetReq": { + "type": "object", + "required": [ + "chapterId", + "isCollect" + ], + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "isCollect": { + "description": "是否收藏,true-收藏,false-取消收藏", + "type": "boolean" + } + } + }, + "proto.PlayletChapterCollectSetResp": { + "type": "object", + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "countCollect": { + "description": "短剧剧集收藏数", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态 1-已收藏 2-未收藏", + "type": "integer" + } + } + }, + "proto.PlayletChapterData": { + "type": "object", + "properties": { + "balance": { + "description": "当前金币余额", + "type": "integer" + }, + "canWatch": { + "description": "是否可观看", + "type": "boolean" + }, + "costCoins": { + "description": "本次解锁消耗金币", + "type": "integer" + }, + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "hash": { + "description": "媒体hash", + "type": "string" + }, + "height": { + "description": "高", + "type": "integer" + }, + "id": { + "description": "短剧剧集ID", + "type": "integer" + }, + "isUnlocked": { + "description": "当前用户是否已解锁本集", + "type": "boolean" + }, + "md5": { + "description": "媒体md5", + "type": "string" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "mediaTime": { + "description": "影片长度(秒)", + "type": "integer" + }, + "needUnlock": { + "description": "是否需要金币解锁", + "type": "boolean" + }, + "needVip": { + "description": "是否需要开通VIP", + "type": "boolean" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "ratio": { + "description": "宽高比", + "type": "number" + }, + "singleCoins": { + "description": "单集金币(支付方式为1-金币时候生效)", + "type": "integer" + }, + "title": { + "description": "章节名", + "type": "string" + }, + "videoUrl": { + "description": "视频地址", + "type": "string" + }, + "vipValid": { + "description": "当前用户VIP是否有效", + "type": "boolean" + }, + "width": { + "description": "宽", + "type": "integer" + } + } + }, + "proto.PlayletChapterInfoResp": { + "type": "object", + "properties": { + "balance": { + "description": "当前金币余额", + "type": "integer" + }, + "canWatch": { + "description": "是否可观看", + "type": "boolean" + }, + "costCoins": { + "description": "本次解锁消耗金币", + "type": "integer" + }, + "countBrowse": { + "description": "短剧剧集观看数", + "type": "integer" + }, + "countCollect": { + "description": "短剧剧集收藏数", + "type": "integer" + }, + "countComment": { + "description": "短剧剧集评论数", + "type": "integer" + }, + "countLike": { + "description": "短剧剧集点赞数", + "type": "integer" + }, + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "hash": { + "description": "媒体hash", + "type": "string" + }, + "height": { + "description": "高", + "type": "integer" + }, + "id": { + "description": "短剧剧集ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态 1-已收藏 2-未收藏", + "type": "integer" + }, + "isLike": { + "description": "点赞状态 1-已点赞 2-未点赞", + "type": "integer" + }, + "isUnlocked": { + "description": "当前用户是否已解锁本集", + "type": "boolean" + }, + "md5": { + "description": "媒体md5", + "type": "string" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "mediaTime": { + "description": "影片长度(秒)", + "type": "integer" + }, + "needUnlock": { + "description": "是否需要金币解锁", + "type": "boolean" + }, + "needVip": { + "description": "是否需要开通VIP", + "type": "boolean" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "ratio": { + "description": "宽高比", + "type": "number" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "singleCoins": { + "description": "单集金币(支付方式为1-金币时候生效)", + "type": "integer" + }, + "title": { + "description": "章节名", + "type": "string" + }, + "videoUrl": { + "description": "视频地址", + "type": "string" + }, + "vipValid": { + "description": "当前用户VIP是否有效", + "type": "boolean" + }, + "width": { + "description": "宽", + "type": "integer" + } + } + }, + "proto.PlayletChapterLikeSetReq": { + "type": "object", + "required": [ + "chapterId", + "isLike" + ], + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞,false-取消点赞", + "type": "boolean" + } + } + }, + "proto.PlayletChapterLikeSetResp": { + "type": "object", + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "countLike": { + "description": "短剧剧集点赞数", + "type": "integer" + }, + "isLike": { + "description": "点赞状态 1-已点赞 2-未点赞", + "type": "integer" + } + } + }, + "proto.PlayletChapterListResp": { + "type": "object", + "properties": { + "list": { + "description": "短剧剧集集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterData" + } + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletChapterUnlockReq": { + "type": "object", + "required": [ + "chapterId", + "playletId" + ], + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletChapterUnlockResp": { + "type": "object", + "properties": { + "balance": { + "description": "当前金币余额", + "type": "integer" + }, + "canWatch": { + "description": "是否可观看", + "type": "boolean" + }, + "changeCoin": { + "description": "本次实际扣减金币,幂等命中时为0", + "type": "integer" + }, + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "costCoins": { + "description": "本次解锁消耗金币", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "unlocked": { + "description": "是否已解锁", + "type": "boolean" + } + } + }, + "proto.PlayletChapterWatchReportReq": { + "type": "object", + "required": [ + "chapterId" + ], + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "playletId": { + "description": "短剧ID,传入时用于校验剧集归属", + "type": "integer" + } + } + }, + "proto.PlayletChapterWatchReportResp": { + "type": "object", + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "currentEpisode": { + "description": "当前集数", + "type": "integer" + }, + "lastChapterId": { + "description": "最近观看剧集ID", + "type": "integer" + }, + "lastEpisode": { + "description": "最近观看集数", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "unwatchedCount": { + "description": "当前用户未看集数", + "type": "integer" + }, + "watchedCount": { + "description": "当前用户已看集数", + "type": "integer" + } + } + }, + "proto.PlayletCollectData": { + "type": "object", + "properties": { + "collectedAt": { + "description": "收藏时间", + "type": "string" + }, + "countBrowse": { + "description": "观看量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "cover": { + "description": "封面,已拼接图片域名", + "type": "string" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态,收藏列表固定返回1-已收藏", + "type": "integer" + }, + "lastChapterId": { + "description": "最近观看剧集ID,无观看进度时为0", + "type": "integer" + }, + "lastEpisode": { + "description": "最近观看集数,无观看进度时为0", + "type": "integer" + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "tagSlugs": { + "description": "标签slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeSlugs": { + "description": "题材slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "unwatchedCount": { + "description": "当前用户未看集数,按totalEpisode-watchedCount计算", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "watchedCount": { + "description": "当前用户已看集数", + "type": "integer" + } + } + }, + "proto.PlayletCollectListResp": { + "type": "object", + "properties": { + "list": { + "description": "收藏短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletCollectData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总收藏数量", + "type": "integer" + } + } + }, + "proto.PlayletCollectSetReq": { + "type": "object", + "required": [ + "id", + "isCollect" + ], + "properties": { + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isCollect": { + "description": "是否收藏,true-收藏,false-取消收藏", + "type": "boolean" + } + } + }, + "proto.PlayletCollectSetResp": { + "type": "object", + "properties": { + "countCollect": { + "description": "短剧收藏数", + "type": "integer" + }, + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态 1-已收藏 2-未收藏", + "type": "integer" + } + } + }, + "proto.PlayletForwardHeatReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletForwardHeatResp": { + "type": "object", + "properties": { + "heatValue": { + "description": "加热后的热度值", + "type": "integer" + }, + "id": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletGuessLikeListResp": { + "type": "object", + "properties": { + "list": { + "description": "猜你喜欢短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletHistoryClearResp": { + "type": "object", + "properties": { + "deletedCount": { + "description": "删除记录数量", + "type": "integer" + } + } + }, + "proto.PlayletHistoryData": { + "type": "object", + "properties": { + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图,已拼接图片域名", + "type": "string" + }, + "categoryIds": { + "description": "分类ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "countBrowse": { + "description": "观看量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "点赞数,包含真实点赞数和后台虚拟点赞数", + "type": "integer" + }, + "cover": { + "description": "竖版封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "description": { + "description": "SEO Description", + "type": "string" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "historyAt": { + "description": "最近观看时间", + "type": "string" + }, + "horizontalCover": { + "description": "横版封面,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "type": "boolean" + }, + "isFinished": { + "description": "是否已看完", + "type": "boolean" + }, + "isNew": { + "description": "是否上新,本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐,false-不推荐", + "type": "boolean" + }, + "lastChapterId": { + "description": "最近观看剧集ID", + "type": "integer" + }, + "lastEpisode": { + "description": "最近观看集数", + "type": "integer" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "type": "integer" + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "preview": { + "description": "预告地址", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态: 0下架 1上架", + "type": "integer" + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "tagSlugs": { + "description": "标签slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeSlugs": { + "description": "题材slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "unwatchedCount": { + "description": "当前用户未看集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "watchedCount": { + "description": "当前用户已看集数", + "type": "integer" + } + } + }, + "proto.PlayletHistoryDeleteReq": { + "type": "object", + "required": [ + "playletId" + ], + "properties": { + "playletId": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletHistoryDeleteResp": { + "type": "object", + "properties": { + "deletedCount": { + "description": "删除记录数量", + "type": "integer" + } + } + }, + "proto.PlayletHistoryListResp": { + "type": "object", + "properties": { + "list": { + "description": "观看历史集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletHistoryData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总历史数量", + "type": "integer" + } + } + }, + "proto.PlayletHotLikeResp": { + "type": "object", + "properties": { + "guessLike": { + "description": "按收藏量最多的15部上架短剧", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "hotSearch": { + "description": "前10个热搜词", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletHotSearchItem" + } + } + } + }, + "proto.PlayletHotRankListResp": { + "type": "object", + "properties": { + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "rankType": { + "description": "榜单类型 1-热播榜 2-新剧榜 3-追剧榜", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletHotSearchItem": { + "type": "object", + "properties": { + "count": { + "description": "搜索次数", + "type": "integer" + }, + "keyword": { + "description": "搜索词", + "type": "string" + } + } + }, + "proto.PlayletInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图,已拼接图片域名", + "type": "string" + }, + "categoryIds": { + "description": "分类ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "countBrowse": { + "description": "观看量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "点赞数,包含真实点赞数和后台虚拟点赞数", + "type": "integer" + }, + "cover": { + "description": "竖版封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "description": { + "description": "SEO Description", + "type": "string" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "horizontalCover": { + "description": "横版封面,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isCollect": { + "description": "是否收藏(1-已收藏,2-未收藏)", + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "type": "boolean" + }, + "isLike": { + "description": "是否点赞(1-已点赞,2-未点赞)", + "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" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "type": "integer" + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "playletChapterInfo": { + "description": "短剧章节集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterData" + } + }, + "preview": { + "description": "预告地址", + "type": "string" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态: 0下架 1上架", + "type": "integer" + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "tagSlugs": { + "description": "标签slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeSlugs": { + "description": "题材slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + } + } + }, + "proto.PlayletLikeSetReq": { + "type": "object", + "required": [ + "id", + "isLike" + ], + "properties": { + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞,false-取消点赞", + "type": "boolean" + } + } + }, + "proto.PlayletLikeSetResp": { + "type": "object", + "properties": { + "countLike": { + "description": "短剧点赞数", + "type": "integer" + }, + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isLike": { + "description": "点赞状态 1-已点赞 2-未点赞", + "type": "integer" + } + } + }, + "proto.PlayletOperateListResp": { + "type": "object", + "properties": { + "filterType": { + "description": "筛选类型 1-最近更新 2-当前热播 3-独家原创", + "type": "integer" + }, + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "type": "integer" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletRankResp": { + "type": "object", + "properties": { + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息(短剧列表页SEO)", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletSearchResp": { + "type": "object", + "properties": { + "everyoneWatching": { + "description": "大家都在看,搜索无结果时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "keyword": { + "description": "搜索词", + "type": "string" + }, + "list": { + "description": "搜索结果", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletTagItem": { + "type": "object", + "properties": { + "id": { + "description": "标签ID", + "type": "integer" + }, + "name": { + "description": "标签名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "标签对应页面 SEO 信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "上架状态", + "type": "boolean" + } + } + }, + "proto.PlayletTagListResponse": { + "type": "object", + "properties": { + "list": { + "description": "标签列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletTagItem" + } + } + } + }, + "proto.PlayletTagVideoResp": { + "type": "object", + "properties": { + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息(标签SEO)", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletThemeItem": { + "type": "object", + "properties": { + "icon": { + "description": "题材图标,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "题材ID", + "type": "integer" + }, + "isRecommend": { + "description": "是否推荐", + "type": "boolean" + }, + "name": { + "description": "题材名称", + "type": "string" + }, + "playletCount": { + "description": "该题材绑定的上架短剧数量", + "type": "integer" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "题材对应页面 SEO 信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "上架状态", + "type": "boolean" + } + } + }, + "proto.PlayletThemeListResponse": { + "type": "object", + "properties": { + "list": { + "description": "题材列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletThemeItem" + } + } + } + }, + "proto.PlayletTodayUpdateResp": { + "type": "object", + "properties": { + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息(短剧列表页SEO)", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletTopicDetailResp": { + "type": "object", + "properties": { + "covers": { + "description": "顶部封面集合", + "type": "array", + "items": { + "type": "string" + } + }, + "desc": { + "description": "描述", + "type": "string" + }, + "icon": { + "description": "图标", + "type": "string" + }, + "id": { + "description": "专题ID", + "type": "integer" + }, + "list": { + "description": "专题短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "moreTopics": { + "description": "更多专题", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletTopicPageItem" + } + }, + "name": { + "description": "专题名称", + "type": "string" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "playletCount": { + "description": "上架短剧数量", + "type": "integer" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "专题SEO", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "totalCount": { + "description": "短剧总数", + "type": "integer" + } + } + }, + "proto.PlayletTopicPageItem": { + "type": "object", + "properties": { + "covers": { + "description": "展示封面集合", + "type": "array", + "items": { + "type": "string" + } + }, + "desc": { + "description": "描述", + "type": "string" + }, + "icon": { + "description": "图标", + "type": "string" + }, + "id": { + "description": "专题ID", + "type": "integer" + }, + "list": { + "description": "专题下短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "name": { + "description": "专题名称", + "type": "string" + }, + "playletCount": { + "description": "上架短剧数量", + "type": "integer" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "专题SEO", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + } + } + }, + "proto.PlayletTopicPageListResp": { + "type": "object", + "properties": { + "list": { + "description": "专题集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletTopicPageItem" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.RechargePackageItem": { + "type": "object", + "properties": { + "baseCoin": { + "description": "基础金币数", + "type": "integer" + }, + "durationDays": { + "description": "会员有效天数", + "type": "integer" + }, + "durationHours": { + "description": "会员有效小时数,由durationDays计算", + "type": "integer" + }, + "giftCoin": { + "description": "赠送金币数", + "type": "integer" + }, + "id": { + "description": "套餐ID", + "type": "integer" + }, + "name": { + "description": "展示标题", + "type": "string" + }, + "originalPrice": { + "description": "原价,单位分", + "type": "integer" + }, + "packageType": { + "description": "套餐类型:1-会员畅享 2-金币充值", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.PackageType" + } + ] + }, + "payMethods": { + "description": "该套餐支持的支付方式", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PayMethodInfo" + } + }, + "price": { + "description": "售价,单位分", + "type": "integer" + }, + "sort": { + "description": "排序,越小越靠前", + "type": "integer" + }, + "subTitle": { + "description": "展示副标题", + "type": "string" + }, + "totalCoin": { + "description": "到账总金币数,由baseCoin+giftCoin计算", + "type": "integer" + } + } + }, + "proto.RechargePackageListResp": { + "type": "object", + "properties": { + "coinList": { + "description": "金币充值列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.RechargePackageItem" + } + }, + "vipList": { + "description": "会员畅享列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.RechargePackageItem" + } + } + } + }, + "proto.SeoInfo": { + "type": "object", + "properties": { + "canonicalUrl": { + "description": "当前内容的标准URL", + "type": "string" + }, + "description": { + "description": "页面描述", + "type": "string" + }, + "keywords": { + "description": "页面关键词", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "页面标题", + "type": "string" + }, + "webSiteName": { + "description": "网站名称", + "type": "string" + } + } + }, + "proto.SeoNavInfo": { + "type": "object", + "properties": { + "dhDescription": { + "description": "导航描述", + "type": "string" + }, + "dhKeyworld": { + "description": "关键词", + "type": "array", + "items": { + "type": "string" + } + }, + "dhSiteName": { + "description": "导航名称", + "type": "string" + }, + "dhTitle": { + "description": "导航标题", + "type": "string" + } + } + }, + "proto.TranslationCategory": { + "type": "object", + "properties": { + "categoryType": { + "description": "导航类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.TranslationType" + } + ] + }, + "icon": { + "description": "Icon", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "description": "名称", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "translationList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.TranslationInfo" + } + }, + "urlValue": { + "description": "URl路径", + "type": "string" + } + } + }, + "proto.TranslationInfo": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isFriendAds": { + "description": "是否是友链广告位", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + }, + "urlValue": { + "description": "URl路径", + "type": "string" + } + } + }, + "proto.UserCenterDataResp": { + "type": "object", + "properties": { + "boughtChapterCount": { + "description": "已购剧集数量", + "type": "integer" + }, + "chasingCount": { + "description": "追剧中数量", + "type": "integer" + }, + "collectCount": { + "description": "收藏数量", + "type": "integer" + }, + "watchCount": { + "description": "观看数量", + "type": "integer" + } + } + }, + "proto.VideoAllListItemResp": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名称", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "mediaType": { + "description": "类型 1-影集 2-动画", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "topicIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "topicNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "topics": { + "description": "题材集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoTopicInfo" + } + }, + "videoCount": { + "description": "视频数量", + "type": "integer" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "watchTimes": { + "description": "观看数", + "type": "integer" + } + } + }, + "proto.VideoAllListResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "list": { + "description": "视频集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoAllListItemResp" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.VideoCollectData": { + "type": "object", + "properties": { + "collectedAt": { + "description": "收藏时间", + "type": "string" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countComment": { + "description": "评论数", + "type": "integer" + }, + "countLike": { + "description": "点赞数", + "type": "integer" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "视频ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态,收藏列表固定返回1-已收藏", + "type": "integer" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "watchTimes": { + "description": "观看数", + "type": "integer" + } + } + }, + "proto.VideoCollectListResp": { + "type": "object", + "properties": { + "list": { + "description": "收藏视频集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoCollectData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总收藏数量", + "type": "integer" + } + } + }, + "proto.VideoCollectSetReq": { + "type": "object", + "required": [ + "id", + "isCollect" + ], + "properties": { + "id": { + "description": "视频ID", + "type": "integer" + }, + "isCollect": { + "description": "是否收藏,true-收藏,false-取消收藏", + "type": "boolean" + } + } + }, + "proto.VideoCollectSetResp": { + "type": "object", + "properties": { + "countCollect": { + "description": "视频收藏数", + "type": "integer" + }, + "id": { + "description": "视频ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态 1-已收藏 2-未收藏", + "type": "integer" + } + } + }, + "proto.VideoData": { + "type": "object", + "properties": { + "cover": { + "description": "视频封面", + "type": "string" + }, + "fileId": { + "description": "媒资库ID", + "type": "string" + }, + "height": { + "description": "分辨率高(视频才会存在)", + "type": "integer" + }, + "preMp4FileNameFs": { + "description": "预览MP4文件", + "type": "string" + }, + "taskId": { + "description": "视频ID(视频才会存在)", + "type": "string" + }, + "unencryptedPreMp4": { + "description": "不加密的预览MP4", + "type": "string" + }, + "url": { + "description": "视频URl", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "width": { + "description": "分辨率宽(视频才会存在)", + "type": "integer" + } + } + }, + "proto.VideoHistoryClearResp": { + "type": "object", + "properties": { + "deletedCount": { + "description": "删除记录数量", + "type": "integer" + } + } + }, + "proto.VideoHistoryData": { + "type": "object", + "properties": { + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countComment": { + "description": "评论数", + "type": "integer" + }, + "countLike": { + "description": "点赞数", + "type": "integer" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "historyAt": { + "description": "最近观看时间", + "type": "string" + }, + "id": { + "description": "视频ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态 1-已收藏 2-未收藏", + "type": "integer" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "watchTimes": { + "description": "观看数", + "type": "integer" + } + } + }, + "proto.VideoHistoryDeleteReq": { + "type": "object", + "required": [ + "videoId" + ], + "properties": { + "videoId": { + "description": "视频ID", + "type": "integer" + } + } + }, + "proto.VideoHistoryDeleteResp": { + "type": "object", + "properties": { + "deletedCount": { + "description": "删除记录数量", + "type": "integer" + } + } + }, + "proto.VideoHistoryListResp": { + "type": "object", + "properties": { + "list": { + "description": "观看历史集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoHistoryData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总历史数量", + "type": "integer" + } + } + }, + "proto.VideoInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countComment": { + "description": "评论数", + "type": "integer" + }, + "countLike": { + "description": "点赞数", + "type": "integer" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "isCollect": { + "description": "当前用户收藏状态 1-已收藏 2-未收藏", + "type": "integer" + }, + "isLike": { + "description": "当前用户点赞状态 1-已点赞 2-未点赞", + "type": "integer" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "topicIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "topics": { + "description": "题材集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoTopicInfo" + } + }, + "video": { + "description": "视频", + "allOf": [ + { + "$ref": "#/definitions/proto.VideoData" + } + ] + }, + "watchTimes": { + "description": "观看数(真假之和)", + "type": "integer" + } + } + }, + "proto.VideoItemResp": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名称", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "watchTimes": { + "description": "观看数(真假之和)", + "type": "integer" + } + } + }, + "proto.VideoLikeSetReq": { + "type": "object", + "required": [ + "id", + "isLike" + ], + "properties": { + "id": { + "description": "视频ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞,false-取消点赞", + "type": "boolean" + } + } + }, + "proto.VideoLikeSetResp": { + "type": "object", + "properties": { + "countLike": { + "description": "视频点赞数", + "type": "integer" + }, + "id": { + "description": "视频ID", + "type": "integer" + }, + "isLike": { + "description": "点赞状态 1-已点赞 2-未点赞", + "type": "integer" + } + } + }, + "proto.VideoResp": { + "type": "object", + "properties": { + "list": { + "description": "视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoItemResp" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.VideoTopicInfo": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "image": { + "description": "背景", + "type": "string" + }, + "name": { + "description": "专题名称", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "videoCategoryId": { + "description": "视频分类id", + "type": "integer" + }, + "works": { + "description": "作品数", + "type": "integer" + } + } + }, + "proto.VideoTopicListResp": { + "type": "object", + "properties": { + "list": { + "description": "题材集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoTopicInfo" + } + } + } + }, + "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.LoginType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "LoginTypeAccount": "账号登陆", + "LoginTypeDevice": "设备登陆", + "LoginTypeEmail": "邮箱登陆" + }, + "x-enum-varnames": [ + "LoginTypeDevice", + "LoginTypeAccount", + "LoginTypeEmail" + ] + }, + "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" + ] + }, + "pubdef.UserType": { + "type": "integer", + "enum": [ + 0, + 1, + 0 + ], + "x-enum-comments": { + "UserTypeGuest": "游客用户", + "UserTypeNormal": "普通用户(已注册)" + }, + "x-enum-varnames": [ + "UserTypeGuest", + "UserTypeNormal", + "UserTypeNone" + ] + }, + "pubdef.VipOrderStatus": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-comments": { + "VipOrderFailed": "支付失败", + "VipOrderPending": "待支付", + "VipOrderSuccess": "支付成功" + }, + "x-enum-varnames": [ + "VipOrderPending", + "VipOrderSuccess", + "VipOrderFailed" + ] + }, + "pubdef.VipType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "VipTypeMonth": "月卡", + "VipTypeNone": "没卡", + "VipTypeYear": "年卡" + }, + "x-enum-varnames": [ + "VipTypeNone", + "VipTypeMonth", + "VipTypeYear" + ] + }, + "rechargeapi.submitOrderReq": { + "type": "object", + "required": [ + "packageId", + "payMode" + ], + "properties": { + "packageId": { + "description": "购买套餐ID(会员畅享)", + "type": "integer" + }, + "payMode": { + "description": "支付方式:alipay/wechat", + "type": "string" + } + } + }, + "rechargepackagemod.PackageType": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "PackageTypeCoin": "金币充值", + "PackageTypeVip": "会员畅享" + }, + "x-enum-varnames": [ + "PackageTypeVip", + "PackageTypeCoin" + ] + }, + "rechargeser.SubmitOrderResp": { + "type": "object", + "properties": { + "isOpenNewBrowser": { + "description": "是否需要外部浏览器打开", + "type": "boolean" + }, + "payUrl": { + "description": "支付链接", + "type": "string" + }, + "payUrlMode": { + "description": "链接打开方式", + "type": "string" + }, + "tradeNo": { + "description": "本地订单号(可用于查询)", + "type": "string" + } + } + }, + "response.BasePageResp": { + "type": "object", + "properties": { + "list": { + "description": "集合列表" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "statcenter.AiSellMsg": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "appID": { + "type": "integer" + }, + "currencyType": { + "type": "string" + }, + "devType": { + "type": "string" + }, + "isRepurchase": { + "type": "string" + }, + "sysType": { + "type": "string" + }, + "tranCreatedAt": { + "type": "string" + }, + "tranType": { + "type": "string" + }, + "uid": { + "type": "integer" + }, + "uniqID": { + "type": "string" + } + } + }, + "statcenter.BuyVIPType": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "x-enum-comments": { + "BuyVIPTypeGold": "金币", + "BuyVIPTypeMoney": "现金" + }, + "x-enum-varnames": [ + "BuyVIPTypeMoney", + "BuyVIPTypeGold" + ] + }, + "statcenter.CardSellMsg": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "appID": { + "type": "integer" + }, + "currencyType": { + "description": "0金币 1现金", + "type": "integer" + }, + "product": { + "$ref": "#/definitions/statcenter.Product" + }, + "sysType": { + "type": "string" + }, + "tranCreatedAt": { + "type": "string" + }, + "tranType": { + "type": "string" + }, + "tranTypeInt": { + "type": "integer" + }, + "uid": { + "type": "integer" + }, + "uniqID": { + "type": "string" + } + } + }, + "statcenter.ConsumeRecordMsg": { + "type": "object", + "properties": { + "amount": { + "description": "金币", + "type": "number" + }, + "appID": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "currencyType": { + "description": "0金币 1人民币", + "type": "integer" + }, + "money": { + "description": "人民币 元", + "type": "number" + }, + "type": { + "description": "消费类型", + "type": "integer" + }, + "uid": { + "type": "integer" + }, + "uniq": { + "type": "string" + } + } + }, + "statcenter.Product": { + "type": "object", + "properties": { + "discountedPrice": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "statcenter.StatcenterSyncReq": { + "type": "object", + "properties": { + "job": { + "description": "同步任务类型", + "type": "string" + }, + "maxSize": { + "description": "最大条数", + "type": "integer" + }, + "platformId": { + "description": "原始平台Id(访问/流水)", + "type": "string" + }, + "successTime": { + "description": "游标时间(购买会员、提现、充值)", + "type": "string" + }, + "userId": { + "description": "用户(注册)", + "type": "integer" + } + } + }, + "statcenter.StatcenterSyncResp": { + "type": "object", + "properties": { + "accessList": { + "description": "日活记录", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserVisitMsg" + } + }, + "aiSellList": { + "description": "AI销售流水", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.AiSellMsg" + } + }, + "allRechargeList": { + "description": "全部支付订单", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserRechargeMsg" + } + }, + "bindingList": { + "description": "用户绑定记录", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserBindingMsg" + } + }, + "buyVipList": { + "description": "VIP购买记录", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserBuyVIPMsg" + } + }, + "cardSellList": { + "description": "会员卡销售流水", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.CardSellMsg" + } + }, + "code": { + "description": "200正常 其他异常", + "type": "integer" + }, + "consumeList": { + "description": "消费流水", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.ConsumeRecordMsg" + } + }, + "inviteList": { + "description": "邀请绑定", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserInviteBindMsg" + } + }, + "job": { + "description": "类型", + "type": "string" + }, + "msg": { + "description": "错误消息", + "type": "string" + }, + "rechargeList": { + "description": "充值记录", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserRechargeMsg" + } + }, + "registerList": { + "description": "注册记录", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserRegisterMsg" + } + }, + "visitList": { + "description": "日活记录(旧字段,兼容)", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserVisitMsg" + } + } + } + }, + "statcenter.UserBindingMsg": { + "type": "object", + "properties": { + "appId": { + "description": "appID", + "type": "integer" + }, + "bindingTime": { + "description": "绑定时间", + "type": "string" + }, + "devType": { + "description": "设备类型", + "type": "string" + }, + "mobile": { + "description": "手机号/绑定账号(本项目填邮箱)", + "type": "string" + }, + "platformId": { + "description": "原始平台流水Id", + "type": "string" + }, + "sysType": { + "description": "操作系统类型", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + } + } + }, + "statcenter.UserBuyVIPMsg": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "appId": { + "type": "integer" + }, + "buyVIPType": { + "$ref": "#/definitions/statcenter.BuyVIPType" + }, + "deviceType": { + "type": "string" + }, + "districtCode": { + "type": "string" + }, + "isDirect": { + "type": "boolean" + }, + "money": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "oid": { + "type": "string" + }, + "platformId": { + "type": "string" + }, + "promCode": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "successTime": { + "type": "string" + }, + "uid": { + "type": "integer" + } + } + }, + "statcenter.UserInviteBindMsg": { + "type": "object", + "required": [ + "appId", + "inviteTime", + "userId" + ], + "properties": { + "appId": { + "type": "integer" + }, + "inviteTime": { + "type": "string" + }, + "parentPromCode": { + "type": "string" + }, + "userId": { + "type": "integer" + } + } + }, + "statcenter.UserRechargeMsg": { + "type": "object", + "required": [ + "appId", + "money", + "orderId", + "payMoney", + "sysType", + "userId" + ], + "properties": { + "appId": { + "type": "integer" + }, + "chanShareMod": { + "type": "integer" + }, + "channelName": { + "type": "string" + }, + "cid": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "devType": { + "type": "string" + }, + "districtCode": { + "type": "string" + }, + "isNewOrOldUser": { + "type": "boolean" + }, + "money": { + "type": "integer" + }, + "oid": { + "type": "string" + }, + "orderId": { + "type": "string" + }, + "payMoney": { + "type": "integer" + }, + "paymentAt": { + "type": "string" + }, + "platformId": { + "type": "string" + }, + "productType": { + "type": "integer" + }, + "rate": { + "type": "string" + }, + "registerTime": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "successAt": { + "type": "string" + }, + "sysType": { + "type": "string" + }, + "type": { + "type": "string" + }, + "userId": { + "type": "integer" + } + } + }, + "statcenter.UserRegisterMsg": { + "type": "object", + "required": [ + "appId", + "isDirect", + "registerTime", + "sysType", + "userId" + ], + "properties": { + "appId": { + "description": "appID", + "type": "integer" + }, + "devType": { + "description": "设备类型", + "type": "string" + }, + "districtCode": { + "description": "渠道码", + "type": "string" + }, + "ip": { + "description": "IP", + "type": "string" + }, + "isDirect": { + "description": "true:是直推用户", + "type": "boolean" + }, + "mobile": { + "description": "手机号", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "platformId": { + "description": "原始平台流水Id", + "type": "string" + }, + "promCode": { + "description": "推广序列", + "type": "string" + }, + "promSeqe": { + "description": "全名代理", + "type": "string" + }, + "puc": { + "description": "应用中心推广", + "type": "string" + }, + "registerTime": { + "description": "注册时间", + "type": "string" + }, + "sysType": { + "description": "操作系统类型 安卓 IOS", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + } + } + }, + "statcenter.UserVisitMsg": { + "type": "object", + "properties": { + "appId": { + "description": "appID", + "type": "integer" + }, + "devID": { + "description": "设备ID", + "type": "string" + }, + "devType": { + "description": "设备型号", + "type": "string" + }, + "districtCode": { + "description": "渠道码", + "type": "string" + }, + "ip": { + "description": "IP", + "type": "string" + }, + "isDirect": { + "description": "true:是直推用户", + "type": "boolean" + }, + "platformId": { + "description": "原始平台流水Id", + "type": "string" + }, + "registerTime": { + "description": "用户注册时间", + "type": "string" + }, + "sysType": { + "description": "操作系统类型 安卓 IOS", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + }, + "version": { + "description": "APP版本号", + "type": "string" + }, + "visitAt": { + "description": "访问时间", + "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" + } + } + }, + "vipordermod.OrderType": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "OrderTypeCoin": "金币购买", + "OrderTypeVip": "会员购买" + }, + "x-enum-varnames": [ + "OrderTypeVip", + "OrderTypeCoin" + ] + }, + "vipordermod.VipOrder": { + "type": "object", + "properties": { + "amount": { + "description": "支付金额(分)", + "type": "integer" + }, + "baseCoin": { + "description": "基础金币数(快照)", + "type": "integer" + }, + "cardId": { + "description": "── VIP 专用 ──", + "type": "integer" + }, + "cardName": { + "description": "套餐名快照", + "type": "string" + }, + "coin": { + "description": "到账金币数(快照)", + "type": "integer" + }, + "coinCfgId": { + "description": "── 金币专用 ──", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "districtCode": { + "description": "下单时用户渠道码", + "type": "string" + }, + "finishedAt": { + "description": "完成时间", + "type": "string" + }, + "giftCoin": { + "description": "赠送金币数(快照)", + "type": "integer" + }, + "id": { + "description": "唯一ID", + "type": "integer" + }, + "ipAddr": { + "description": "客户端 IP", + "type": "string" + }, + "oid": { + "description": "第三方订单号", + "type": "string" + }, + "orderType": { + "description": "订单类型:1会员 2金币", + "allOf": [ + { + "$ref": "#/definitions/vipordermod.OrderType" + } + ] + }, + "payChannel": { + "description": "支付渠道", + "type": "string" + }, + "payMode": { + "description": "支付方式:overage/alipay/wechat", + "type": "string" + }, + "payRate": { + "description": "费率", + "type": "string" + }, + "payUrl": { + "description": "支付链接", + "type": "string" + }, + "payUrlMode": { + "description": "链接打开方式", + "type": "string" + }, + "status": { + "description": "订单状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.VipOrderStatus" + } + ] + }, + "sysType": { + "description": "ios/android", + "type": "string" + }, + "tradeNo": { + "description": "本地订单号", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "userId": { + "description": "用户ID", + "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: "https://视频站-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..c231d54 --- /dev/null +++ b/app/docs/swagger.json @@ -0,0 +1,8747 @@ +{ + "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": "https://视频站-api.lcncctn.com", + "basePath": "/", + "paths": { + "/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 获取最新用户信息,包含金币余额、VIP状态等", + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "获取当前用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "$ref": "#/definitions/authser.LoginResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/auth/guest": { + "post": { + "description": "按设备ID创建或找回游客账号;请求头可带 Authorization 复用已有游客会话。需传 X-User-Agent", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "游客登录", + "parameters": [ + { + "description": "游客登录参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authser.GuestLoginReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/authser.LoginResp" + } + }, + "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": "用户信息通过 token 获取,无需传账号;nickName、avatar、introduction、oldPassword+newPassword 均为可选,至少提供一项。未设置密码时传 newPassword 可设置密码,已设置密码时修改需传 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": "传入账号、邮箱、明文密码。请求头带游客 Authorization 时升级该游客为普通用户,否则新建用户", + "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/auth/resetPassword": { + "post": { + "description": "通过账号和邮箱双重校验后重置密码,两者必须同时匹配同一用户", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户认证" + ], + "summary": "找回密码", + "parameters": [ + { + "description": "找回密码参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authapi.resetPasswordReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/coin/buy": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据指定金币套餐发起第三方支付,返回支付链接", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "金币" + ], + "summary": "购买金币", + "parameters": [ + { + "description": "购买参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/coinapi.buyCoinReq" + } + } + ], + "responses": { + "200": { + "description": "支付链接", + "schema": { + "$ref": "#/definitions/coinser.BuyCoinResp" + } + } + } + } + }, + "/api/app/coin/orders": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "金币" + ], + "summary": "查询金币购买记录", + "parameters": [ + { + "type": "integer", + "description": "页码(默认1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认10)", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "订单列表", + "schema": { + "type": "object" + } + } + } + } + }, + "/api/app/coin/trans/export": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "导出当前登录用户的金币收入、支出流水CSV;type=0全部 1充值 2消费 3奖励", + "produces": [ + "text/csv" + ], + "tags": [ + "金币" + ], + "summary": "导出金币余额明细 -- by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "类型:0全部 1充值 2消费 3奖励", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "CSV文件", + "schema": { + "type": "file" + } + } + } + } + }, + "/api/app/coin/trans/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "返回当前登录用户的金币收入、支出流水,按交易时间倒序;type=0全部 1充值 2消费 3奖励", + "produces": [ + "application/json" + ], + "tags": [ + "金币" + ], + "summary": "查询金币余额明细 -- by 旺鸣", + "parameters": [ + { + "type": "integer", + "description": "页码(默认1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认10,最大100)", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "类型:0全部 1充值 2消费 3奖励", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.BasePageResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CoinTransListItemResp" + } + } + } + } + ] + } + } + } + } + }, + "/api/app/comment/like/set": { + "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.CommentLikeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.CommentLikeSetResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/comment/list": { + "get": { + "description": "分页查询短剧、短剧剧集或视频评论树,一级评论分页返回,二级评论挂在children中,objectType 10-短剧 11-短剧剧集 20-视频", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "评论APP" + ], + "summary": "评论列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "对象ID(短剧id 短剧剧集id 视频id)", + "name": "objectId", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "对象类型 10-短剧 11-短剧剧集 20-视频", + "name": "objectType", + "in": "query", + "required": true + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-热门,按点赞数倒序 2-最新,按创建时间倒序", + "name": "sortOrder", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.GetCommentListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommentInfo" + } + } + } + } + ] + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/comment/send": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "发布短剧、短剧剧集或视频评论,parentsId为0发布一级评论,大于0发布二级评论,replyCommentId表示实际回复的评论,objectType 10-短剧 11-短剧剧集 20-视频", + "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/info": { + "get": { + "description": "返回最新地址信息、完整系统配置、广告配置和全站SEO配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP配置相关接口" + ], + "summary": "查询APP公共配置 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ConfigInfoResp" + } + }, + "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/contactus/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "用户提交联系我们表单,问题类型:1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "联系我们相关接口" + ], + "summary": "提交联系我们 -- by 旺鸣", + "parameters": [ + { + "description": "联系我们请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.ContactUsAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ContactUsAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/feedback/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "用户提交意见反馈,问题类型,1-功能建议、2-播放问题、3-账号问题、4-其他", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "意见反馈相关接口" + ], + "summary": "提交意见反馈 -- by 旺鸣", + "parameters": [ + { + "description": "意见反馈请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.FeedbackReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.FeedbackResp" + } + }, + "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/all/list": { + "get": { + "description": "分页查询已上架短剧,支持题材、更新状态和排序筛选", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询全部短剧 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-最新 2-最热 3-收藏最多", + "name": "sortType", + "in": "query" + }, + { + "type": "integer", + "description": "题材ID", + "name": "themeId", + "in": "query" + }, + { + "type": "string", + "description": "题材slug,非空时优先按slug解析题材ID", + "name": "themeSlug", + "in": "query" + }, + { + "type": "integer", + "description": "更新状态 0-全部 1-连载中 2-已完结", + "name": "updateStatus", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletAllListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/bought/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按当前用户最近购买时间倒序分页返回已购短剧列表,并返回已购集数、消耗金币和最近观看位置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端我的已购短剧列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletBoughtListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/collect/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按当前用户收藏时间倒序分页返回短剧剧集收藏列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端我的短剧剧集收藏列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterCollectListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/collect/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "设置当前用户短剧剧集收藏状态,收藏数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧剧集收藏/取消收藏 --by 王鸣", + "parameters": [ + { + "description": "短剧剧集收藏设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterCollectSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterCollectSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/detail": { + "get": { + "description": "根据短剧剧集ID查询播放页当前剧集详情;可传playletId或playletSlug校验剧集归属,slug非空时优先解析短剧ID", + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端根据ID查询短剧剧集详情 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "短剧剧集ID", + "name": "chapterId", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "短剧ID,传入时用于校验剧集归属", + "name": "playletId", + "in": "query" + }, + { + "type": "string", + "description": "短剧slug,非空时优先按slug解析短剧ID", + "name": "playletSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterInfoResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/like/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "设置当前用户短剧剧集点赞状态,点赞数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧剧集点赞/取消点赞 --by 王鸣", + "parameters": [ + { + "description": "短剧剧集点赞设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterLikeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterLikeSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/list": { + "get": { + "description": "根据短剧ID或slug查询右侧选集列表;slug非空时优先按slug解析短剧ID", + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端根据短剧ID查询剧集列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "短剧ID", + "name": "playletId", + "in": "query" + }, + { + "type": "string", + "description": "短剧slug,非空时优先按slug解析短剧ID", + "name": "playletSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/unlock": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "仅金币付费剧集可调用;接口具备幂等性,已解锁剧集重复调用不会重复扣金币", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端使用金币逐集解锁短剧剧集 --by 王鸣", + "parameters": [ + { + "description": "短剧剧集金币解锁请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterUnlockReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterUnlockResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/chapter/watch/report": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "上报当前用户短剧剧集播放进度,达到已看条件时用于我的收藏未看集数计算", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧剧集观看上报 --by 王鸣", + "parameters": [ + { + "description": "短剧剧集观看上报请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletChapterWatchReportReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletChapterWatchReportResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/collect/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按当前用户收藏时间倒序分页返回短剧收藏列表,并返回已看/未看集数和最近观看位置;watchStatus=0全部 1追剧中 2已看完", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端我的短剧收藏列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "观看状态 0-全部 1-追剧中 2-已看完", + "name": "watchStatus", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletCollectListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/collect/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "设置当前用户短剧收藏状态,收藏数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧收藏/取消收藏 --by 王鸣", + "parameters": [ + { + "description": "短剧收藏设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletCollectSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletCollectSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/detail": { + "get": { + "description": "根据短剧ID或slug查询已上架短剧详情;slug非空时优先按slug解析短剧ID", + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端根据ID查询短剧详情 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "短剧id", + "name": "id", + "in": "query" + }, + { + "type": "string", + "description": "短剧slug,非空时优先按slug解析短剧ID", + "name": "slug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletInfoResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/forward/heat": { + "post": { + "description": "请求一次短剧热度值增加5,仅支持已上架短剧", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧转发增加热度 --by 王鸣", + "parameters": [ + { + "description": "短剧转发加热度请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletForwardHeatReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletForwardHeatResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/guess-like/list": { + "get": { + "description": "根据当前短剧同题材查询猜你喜欢,排除当前短剧", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询猜你喜欢短剧 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "当前短剧ID", + "name": "playletId", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletGuessLikeListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/history/clear": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "仅清空观看历史,不影响收藏列表和观看进度", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端清空我的短剧观看历史 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletHistoryClearResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/history/delete": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "删除当前用户指定短剧的观看历史,不影响观看进度、收藏和已购记录", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端删除单条短剧观看历史 --by 王鸣", + "parameters": [ + { + "description": "删除单条观看历史请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletHistoryDeleteReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletHistoryDeleteResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/history/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按当前用户最近观看时间倒序分页返回短剧观看历史;pageSize默认20、最大100", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端我的短剧观看历史列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletHistoryListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/home": { + "get": { + "description": "无请求参数,返回首页banner短剧、公告和SEO信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端首页短剧接口 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomePlayletResponse" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/home/config/detail/list": { + "get": { + "description": "根据首页配置ID分页查询短剧、视频或题材", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询首页配置详情列表--by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "首页配置ID", + "name": "homeConfigId", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "type=2时生效,视频类型 0-全部 1-影集 2-动画", + "name": "mediaType", + "in": "query" + }, + { + "type": "integer", + "description": "type=1时生效,运营类型 0-全部 1-短剧 2-漫剧", + "name": "operateType", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "type=1时生效,题材ID", + "name": "themeId", + "in": "query" + }, + { + "type": "string", + "description": "type=1时生效,题材slug,非空时优先按slug解析题材ID", + "name": "themeSlug", + "in": "query" + }, + { + "type": "integer", + "description": "type=1/type=2时生效,时间范围 0-综合 1-本周 2-本月", + "name": "timeRange", + "in": "query" + }, + { + "type": "integer", + "description": "type=2时生效,视频题材ID,对应video_topic", + "name": "topicId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeConfigDetailListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/hot-like": { + "get": { + "description": "返回缓存的前10个热搜词,以及按收藏量最多的15部猜你喜欢短剧", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端热搜词和猜你喜欢", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletHotLikeResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/hot/rank/list": { + "get": { + "description": "分页查询已上架短剧热榜;rankType=1热播榜 2新剧榜 3追剧榜", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询热榜 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "榜单类型 1-热播榜 2-新剧榜 3-追剧榜", + "name": "rankType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletHotRankListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/like/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "设置当前用户短剧点赞状态,点赞数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端短剧点赞/取消点赞 --by 王鸣", + "parameters": [ + { + "description": "短剧点赞设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletLikeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletLikeSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/operate/list": { + "get": { + "description": "按运营类型查询已上架短剧;operateType=1短剧 2漫剧;filterType=1最近更新(按上架时间) 2当前热播(按收藏数) 3独家原创(原创且按推荐排序)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端按运营类型分页查询短剧", + "parameters": [ + { + "type": "integer", + "description": "筛选类型 1-最近更新 2-当前热播 3-独家原创", + "name": "filterType", + "in": "query" + }, + { + "type": "integer", + "description": "运营类型 1-短剧 2-漫剧", + "name": "operateType", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletOperateListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/rank": { + "get": { + "description": "分页查询已上架短剧排行榜;sortOrder=1按热度值倒序,2按上架时间倒序,3按收藏数倒序", + "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": "排序 1-热播榜,按热度值倒序 2-新剧榜,按上架时间倒序 3-追剧榜,按收藏数倒序", + "name": "sortOrder", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletRankResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/search": { + "get": { + "description": "使用ES分页搜索已上架短剧,支持剧名、题材名、简介和标签名检索", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端ES分页搜索短剧 --by 王鸣", + "parameters": [ + { + "type": "string", + "description": "搜索关键词,支持剧名、题材名、简介和标签名", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletSearchResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/tag/list": { + "get": { + "description": "根据标签ID或标签slug分页查询已上架短剧;slug非空时优先按slug解析标签ID;sortType=1最新 2最热 3收藏最多", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端根据标签ID分页查询短剧 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-最新 2-最热 3-收藏最多", + "name": "sortType", + "in": "query" + }, + { + "type": "integer", + "description": "标签ID,tagSlug为空时必传", + "name": "tagId", + "in": "query" + }, + { + "type": "string", + "description": "标签slug,非空时优先按slug解析标签ID", + "name": "tagSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletTagVideoResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/tag/options": { + "get": { + "description": "无请求参数,不分页,返回已上架短剧标签列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧标签相关接口" + ], + "summary": "app端短剧标签列表 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletTagListResponse" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/today-update": { + "get": { + "description": "分页查询已上架短剧", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询今日更新短剧 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletTodayUpdateResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/topic/detail": { + "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 + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-编辑排序 2-最新 3-最热", + "name": "sortType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletTopicDetailResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "moreTopics": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletTopicPageItem" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/playlet/topic/list": { + "get": { + "description": "分页查询已上架且有短剧数据的专题剧单", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧相关接口" + ], + "summary": "app端分页查询专题剧单 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-最新 2-最热", + "name": "sortType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.PlayletTopicPageListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletTopicPageItem" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/recharge/package/list": { + "get": { + "description": "返回会员畅享和金币充值两个列表,并附带各套餐可用支付方式", + "produces": [ + "application/json" + ], + "tags": [ + "金币与会员" + ], + "summary": "查询购买套餐列表 -- by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.RechargePackageListResp" + } + } + } + } + }, + "/api/app/statcenter/sync": { + "post": { + "description": "按 job 拉取注册/日活/充值/绑定/消费/售卡等增量数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "数据同步" + ], + "summary": "统计中心增量同步", + "parameters": [ + { + "description": "参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/statcenter.StatcenterSyncReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/statcenter.StatcenterSyncResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/theme/list": { + "get": { + "description": "无请求参数,不分页,返回已上架题材列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app短剧题材相关接口" + ], + "summary": "app端短剧题材列表 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletThemeListResponse" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/translation/category/list": { + "get": { + "description": "无请求参数,不分页,返回启用的顶部导航分类", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP导航相关接口" + ], + "summary": "app端导航分类列表 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.AppTranslationInfo" + } + }, + "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 + }, + { + "type": "string", + "description": "是否加水印:1加,其他情况不加(头像不加)", + "name": "watermark", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/uploadser.ImageUploadResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/user/center/data": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "返回当前用户观看、收藏、追剧中、已购剧集统计", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "app个人中心相关接口" + ], + "summary": "app个人中心数据 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.UserCenterDataResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/all/list": { + "get": { + "description": "分页查询已上架视频,支持类型、题材和排序筛选", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端分页查询视频列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "类型 0-全部 1-影集 2-动画", + "name": "mediaType", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序 1-最新 2-最热 3-收藏最多", + "name": "sortType", + "in": "query" + }, + { + "type": "integer", + "description": "题材ID,对应video_topic", + "name": "topicId", + "in": "query" + }, + { + "type": "string", + "description": "题材slug,非空时优先按slug解析题材ID", + "name": "topicSlug", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.VideoAllListResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoAllListItemResp" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/collect/list": { + "get": { + "description": "按当前用户收藏时间倒序分页返回视频收藏列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端我的视频收藏列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoCollectListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/collect/set": { + "post": { + "description": "设置当前用户视频收藏状态,收藏数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端视频收藏/取消收藏 --by 王鸣", + "parameters": [ + { + "description": "视频收藏设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.VideoCollectSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoCollectSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/detail": { + "get": { + "description": "查询已上架视频详情", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "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": { + "$ref": "#/definitions/proto.VideoInfoResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/history/clear": { + "post": { + "description": "清空当前用户全部视频观看历史,不影响收藏和点赞", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端清空我的视频观看历史 --by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoHistoryClearResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/history/delete": { + "post": { + "description": "删除当前用户指定视频观看历史,不影响收藏和点赞", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端删除单条视频观看历史 --by 王鸣", + "parameters": [ + { + "description": "删除单条视频观看历史请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.VideoHistoryDeleteReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoHistoryDeleteResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/history/list": { + "get": { + "description": "按当前用户最近观看时间倒序分页返回视频观看历史", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端我的视频观看历史列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoHistoryListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/like/set": { + "post": { + "description": "设置当前用户视频点赞状态,点赞数只在状态真实变化时增减", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端视频点赞/取消点赞 --by 王鸣", + "parameters": [ + { + "description": "视频点赞设置请求", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.VideoLikeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoLikeSetResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/list": { + "get": { + "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.VideoResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoItemResp" + } + } + } + } + ] + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/video/topic/list": { + "get": { + "description": "查询已上架视频题材列表,mediaType=0全部 1影集 2动画", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频相关接口" + ], + "summary": "app端查询视频题材列表 --by 王鸣", + "parameters": [ + { + "type": "integer", + "description": "类型 0-全部 1-影集 2-动画", + "name": "mediaType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoTopicListResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/app/vip/orders": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "充值" + ], + "summary": "查询 VIP 充值记录", + "parameters": [ + { + "type": "integer", + "description": "页码(默认1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认10)", + "name": "pageSize", + "in": "query" + } + ], + "responses": { + "200": { + "description": "订单列表", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/vipordermod.VipOrder" + } + } + } + } + } + }, + "/api/app/vip/pay": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "充值" + ], + "summary": "创建 VIP 充值订单(第三方支付)", + "parameters": [ + { + "description": "下单参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/rechargeapi.submitOrderReq" + } + } + ], + "responses": { + "200": { + "description": "支付链接", + "schema": { + "$ref": "#/definitions/rechargeser.SubmitOrderResp" + } + } + } + } + }, + "/richtext/: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" + } + } + } + } + } + }, + "definitions": { + "authapi.loginReq": { + "type": "object", + "required": [ + "account", + "password" + ], + "properties": { + "account": { + "description": "账号或邮箱", + "type": "string" + }, + "channel": { + "description": "渠道码(可选,用户无商区码时写入)", + "type": "string" + }, + "devID": { + "description": "设备唯一标示(可选)", + "type": "string" + }, + "password": { + "description": "明文密码", + "type": "string" + } + } + }, + "authapi.registerReq": { + "type": "object", + "required": [ + "accountNo", + "email", + "password" + ], + "properties": { + "accountNo": { + "description": "账号", + "type": "string" + }, + "agentInviteCode": { + "description": "邀请人的邀请码(可选)", + "type": "string" + }, + "channel": { + "description": "渠道码(可选,用户无商区码时写入)", + "type": "string" + }, + "devID": { + "description": "设备唯一标示(可选)", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "password": { + "description": "明文密码", + "type": "string" + } + } + }, + "authapi.resetPasswordReq": { + "type": "object", + "required": [ + "accountNo", + "email", + "newPassword" + ], + "properties": { + "accountNo": { + "description": "账号", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "newPassword": { + "description": "新密码", + "type": "string" + } + } + }, + "authapi.updateProfileReq": { + "type": "object", + "properties": { + "avatar": { + "description": "新头像 URL(可选)", + "type": "string" + }, + "introduction": { + "description": "简介(可选)", + "type": "string" + }, + "newPassword": { + "description": "新密码明文(设置或修改密码时必填)", + "type": "string" + }, + "nickName": { + "description": "新昵称(可选)", + "type": "string" + }, + "oldPassword": { + "description": "旧密码明文(已设置密码时修改必填)", + "type": "string" + }, + "privacyMode": { + "description": "隐私模式:true-开启,false-关闭(可选)", + "type": "boolean" + } + } + }, + "authser.GuestLoginReq": { + "type": "object", + "properties": { + "channel": { + "description": "渠道码(可选,用户无商区码时写入 DistrictCode)", + "type": "string" + }, + "devID": { + "description": "设备唯一标示(必填)", + "type": "string" + }, + "deviceId": { + "description": "备用设备ID(可选,写入 DevV2)", + "type": "string" + }, + "mac": { + "description": "设备 Mac(可选)", + "type": "string" + } + } + }, + "authser.LoginResp": { + "type": "object", + "properties": { + "accountNo": { + "type": "string" + }, + "avatar": { + "description": "头像", + "type": "string" + }, + "currentVipExpire": { + "description": "VIP 过期时间,客户端需结合 vipType 判断是否真正有效", + "type": "string" + }, + "email": { + "type": "string" + }, + "hasPassword": { + "description": "是否已设置密码", + "type": "boolean" + }, + "introduction": { + "description": "简介", + "type": "string" + }, + "inviteCode": { + "description": "邀请码", + "type": "string" + }, + "isNewUser": { + "description": "本次是否新建游客", + "type": "boolean" + }, + "isRegister": { + "description": "是否已注册", + "type": "boolean" + }, + "loginType": { + "description": "登陆方式", + "allOf": [ + { + "$ref": "#/definitions/pubdef.LoginType" + } + ] + }, + "nickName": { + "type": "string" + }, + "privacyMode": { + "description": "隐私模式", + "type": "boolean" + }, + "recharge": { + "description": "金币余额", + "type": "integer" + }, + "score": { + "description": "积分", + "type": "integer" + }, + "showCardId": { + "description": "最新会员卡id", + "type": "integer" + }, + "showCardName": { + "description": "最新会员卡名称", + "type": "string" + }, + "status": { + "$ref": "#/definitions/pubdef.UserStatus" + }, + "token": { + "type": "string" + }, + "type": { + "description": "身份类型:0游客 1普通用户", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserType" + } + ] + }, + "userId": { + "type": "integer" + }, + "vipType": { + "$ref": "#/definitions/pubdef.VipType" + } + } + }, + "coinapi.buyCoinReq": { + "type": "object", + "required": [ + "packageId", + "payMode" + ], + "properties": { + "packageId": { + "description": "购买套餐ID(金币充值)", + "type": "integer" + }, + "payMode": { + "description": "支付方式:alipay/wechat", + "type": "string" + } + } + }, + "coinser.BuyCoinResp": { + "type": "object", + "properties": { + "isOpenNewBrowser": { + "type": "boolean" + }, + "payUrl": { + "type": "string" + }, + "payUrlMode": { + "type": "string" + }, + "tradeNo": { + "type": "string" + } + } + }, + "cointransmod.FlowDir": { + "type": "integer", + "enum": [ + 1, + -1 + ], + "x-enum-comments": { + "FlowIn": "收入(充值、赠送)", + "FlowOut": "支出(消费、解锁)" + }, + "x-enum-varnames": [ + "FlowIn", + "FlowOut" + ] + }, + "cointransmod.TranType": { + "type": "integer", + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "x-enum-comments": { + "TranTypeAdminAdjust": "管理员手动调整", + "TranTypeCoinGift": "金币充值赠送", + "TranTypeCoinRecharge": "金币充值(购买金币)", + "TranTypeDarkAlley": "历史遗留交易类型(已下线)", + "TranTypeVideoUnlock": "视频解锁", + "TranTypeVipGiftCoin": "购买会员赠送金币" + }, + "x-enum-varnames": [ + "TranTypeCoinRecharge", + "TranTypeVipGiftCoin", + "TranTypeDarkAlley", + "TranTypeVideoUnlock", + "TranTypeAdminAdjust", + "TranTypeCoinGift" + ] + }, + "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" + } + }, + "bottomBanner": { + "description": "底部banner", + "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" + } + }, + "downList": { + "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" + } + }, + "nineGridList": { + "description": "9宫格", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "pauseyList": { + "description": "暂停广告", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "playList": { + "description": "播放强制跳转", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "titleList": { + "description": "标题广告", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + }, + "videoInfoList": { + "description": "视频详情", + "type": "array", + "items": { + "$ref": "#/definitions/proxy.AdvitiseInfo" + } + } + } + }, + "proto.AnnouncementItem": { + "type": "object", + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "id": { + "description": "公告ID", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "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.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" + } + ] + }, + "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" + } + } + }, + "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.CoinTransListItemResp": { + "type": "object", + "properties": { + "amount": { + "description": "本次交易金币数,始终为正数", + "type": "integer" + }, + "balanceAfter": { + "description": "交易后余额", + "type": "integer" + }, + "balanceBefore": { + "description": "交易前余额", + "type": "integer" + }, + "changeAmount": { + "description": "带方向的金币变动数,收入为正数,支出为负数", + "type": "integer" + }, + "createdAt": { + "description": "交易时间,格式:2006-01-02 15:04:05", + "type": "string" + }, + "flowDir": { + "description": "收支方向:1-收入,-1-支出", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.FlowDir" + } + ] + }, + "flowDirName": { + "description": "收支方向名称:收入/支出", + "type": "string" + }, + "id": { + "description": "流水ID", + "type": "integer" + }, + "orderNo": { + "description": "关联订单号", + "type": "string" + }, + "relatedId": { + "description": "关联内容ID", + "type": "integer" + }, + "remark": { + "description": "备注", + "type": "string" + }, + "title": { + "description": "标题,优先使用备注,备注为空时使用交易类型名称", + "type": "string" + }, + "tranType": { + "description": "交易类型 1-金币充值(购买金币)2-购买会员赠送金币 3-历史遗留 4-视频解锁 5-管理员手动调整", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.TranType" + } + ] + }, + "tranTypeName": { + "description": "交易类型名称", + "type": "string" + } + } + }, + "proto.CommentInfo": { + "type": "object", + "properties": { + "children": { + "description": "二级评论列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommentInfo" + } + }, + "commentCount": { + "description": "二级评论数量", + "type": "integer" + }, + "countLike": { + "description": "评论点赞数", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "description": "评论ID", + "type": "integer" + }, + "isLike": { + "description": "当前用户点赞状态 1-已点赞 2-未点赞", + "type": "integer" + }, + "level": { + "description": "评论级别 1-一级评论 2-二级评论", + "type": "integer" + }, + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "objectType": { + "description": "对象类型 10-短剧 11-短剧剧集 20-视频", + "type": "integer" + }, + "parentsId": { + "description": "父评论ID,一级评论为0", + "type": "integer" + }, + "replyCommentId": { + "description": "被回复评论ID,一级评论为0", + "type": "integer" + }, + "replyUserName": { + "description": "被回复用户昵称", + "type": "string" + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "userAvatar": { + "description": "用户头像,已拼接图片域名", + "type": "string" + }, + "userId": { + "description": "评论用户ID", + "type": "integer" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "proto.CommentLikeSetReq": { + "type": "object", + "required": [ + "commentId", + "isLike" + ], + "properties": { + "commentId": { + "description": "评论ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞,false-取消点赞", + "type": "boolean" + } + } + }, + "proto.CommentLikeSetResp": { + "type": "object", + "properties": { + "commentId": { + "description": "评论ID", + "type": "integer" + }, + "countLike": { + "description": "评论点赞数", + "type": "integer" + }, + "isLike": { + "description": "点赞状态 1-已点赞 2-未点赞", + "type": "integer" + } + } + }, + "proto.CommentSendReq": { + "type": "object", + "properties": { + "objectId": { + "description": "对象ID,短剧评论传短剧ID,剧集评论传剧集ID,视频评论传视频ID", + "type": "integer" + }, + "objectType": { + "description": "对象类型 10-短剧 11-短剧剧集 20-视频", + "type": "integer" + }, + "parentsId": { + "description": "一级评论ID,0-发布一级评论,大于0-发布二级评论", + "type": "integer" + }, + "replyCommentId": { + "description": "被回复评论ID,0时默认回复parentsId", + "type": "integer" + }, + "text": { + "description": "评论内容", + "type": "string" + } + } + }, + "proto.CommonData": { + "type": "object", + "properties": { + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图,已拼接图片域名", + "type": "string" + }, + "categoryIds": { + "description": "分类ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "countBrowse": { + "description": "观看量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "点赞数,包含真实点赞数和后台虚拟点赞数", + "type": "integer" + }, + "cover": { + "description": "竖版封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "description": { + "description": "SEO Description", + "type": "string" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "horizontalCover": { + "description": "横版封面,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "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" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "type": "integer" + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "preview": { + "description": "预告地址", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态: 0下架 1上架", + "type": "integer" + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "tagSlugs": { + "description": "标签slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeSlugs": { + "description": "题材slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + } + } + }, + "proto.ConfigInfo": { + "type": "object", + "properties": { + "actressBackGround": { + "description": "女优通用背景图", + "type": "string" + }, + "advitiesTopicNum": { + "description": "广告间隔", + "type": "integer" + }, + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "customerServiceHost": { + "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" + }, + "mediaAppUsCdn": { + "description": "视频CDN(海外app使用)", + "type": "string" + }, + "newUrlAddr": { + "description": "最新地址", + "type": "string" + }, + "newUrlAddr1": { + "description": "最新地址(备用地址1)", + "type": "string" + }, + "newUrlAddr2": { + "description": "最新地址(备用地址2)", + "type": "string" + }, + "officialAppAddr": { + "description": "官网app地址", + "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" + } + }, + "watermarkImage": { + "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" + } + } + }, + "proto.ConfigInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告配置", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "config": { + "description": "完整系统配置(与首页等接口的 config 一致,含 potaoGroup 等其余字段)", + "allOf": [ + { + "$ref": "#/definitions/proto.ConfigInfo" + } + ] + }, + "navPageList": { + "description": "导航页线路集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.NavPageItem" + } + }, + "newAddressInfo": { + "description": "最新地址信息", + "allOf": [ + { + "$ref": "#/definitions/proto.NewAddressInfo" + } + ] + }, + "reportSdkConfig": { + "description": "数据上报SDK域名配置", + "type": "string" + }, + "seoInfo": { + "description": "全站SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "seoNavInfo": { + "description": "导航SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoNavInfo" + } + ] + } + } + }, + "proto.ContactUsAddReq": { + "type": "object", + "required": [ + "content", + "email", + "title", + "type" + ], + "properties": { + "attachments": { + "description": "附件地址,可存放多张图片或文件地址", + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "description": "内容", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "title": { + "description": "标题", + "type": "string" + }, + "type": { + "description": "问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他", + "type": "integer" + } + } + }, + "proto.ContactUsAddResp": { + "type": "object", + "properties": { + "message": { + "description": "提示信息", + "type": "string" + } + } + }, + "proto.FeedbackReq": { + "type": "object", + "required": [ + "content", + "type" + ], + "properties": { + "contact": { + "description": "联系方式,可为空", + "type": "string" + }, + "content": { + "description": "描述内容,最多500字", + "type": "string" + }, + "screenshots": { + "description": "问题截图,可存放多张图片地址", + "type": "array", + "items": { + "type": "string" + } + }, + "type": { + "description": "问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他", + "type": "integer" + } + } + }, + "proto.FeedbackResp": { + "type": "object", + "properties": { + "message": { + "description": "提示信息", + "type": "string" + } + } + }, + "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.GetCommentListResp": { + "type": "object", + "properties": { + "list": { + "description": "数据", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommentInfo" + } + }, + "objectId": { + "description": "对象ID", + "type": "integer" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总数量", + "type": "integer" + } + } + }, + "proto.HomeConfigData": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "list": { + "description": "配置对应短剧集合,type=1时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "sortType": { + "description": "排序字段 1-推荐 2-热播 3-最近更新 4-新片预告 5-分类", + "type": "integer" + }, + "status": { + "description": "上下架状态 true-上架 false-下架", + "type": "boolean" + }, + "themeList": { + "description": "题材集合,type=3时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HomeConfigThemeData" + } + }, + "type": { + "description": "类型 1-短剧 2-视频 3-题材", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "videoList": { + "description": "视频集合,type=2时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoItemResp" + } + } + } + }, + "proto.HomeConfigDetailListResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "desc": { + "description": "描述", + "type": "string" + }, + "homeConfigId": { + "description": "首页配置ID", + "type": "integer" + }, + "list": { + "description": "短剧集合,type=1时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "name": { + "description": "名称", + "type": "string" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "sortType": { + "description": "排序字段 1-推荐 2-热播 3-最近更新 4-新片预告 5-分类", + "type": "integer" + }, + "themeList": { + "description": "题材集合,type=3时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HomeConfigThemeData" + } + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + }, + "type": { + "description": "类型 1-短剧 2-视频 3-题材", + "type": "integer" + }, + "videoList": { + "description": "视频集合,type=2时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoItemResp" + } + } + } + }, + "proto.HomeConfigThemeData": { + "type": "object", + "properties": { + "icon": { + "description": "题材图标,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "题材ID", + "type": "integer" + }, + "list": { + "description": "题材下短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "name": { + "description": "题材名称", + "type": "string" + }, + "playletCount": { + "description": "绑定短剧数量", + "type": "integer" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "题材对应页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "题材slug", + "type": "string" + } + } + }, + "proto.HomeHtmlData": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "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.HomePlayletResponse": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "announcements": { + "description": "启用中的公告,来自独立缓存", + "type": "array", + "items": { + "$ref": "#/definitions/proto.AnnouncementItem" + } + }, + "homeBannerData": { + "description": "首页banner短剧信息列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "homeConfigData": { + "description": "首页配置集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.HomeConfigData" + } + }, + "seoInfo": { + "description": "页面SEO信息(短剧列表页SEO)", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + } + } + }, + "proto.HotSearchItem": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架shi时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "hotValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "images": { + "description": "封面", + "type": "string" + }, + "title": { + "description": "标题", + "type": "string" + }, + "works": { + "description": "作品数量", + "type": "integer" + } + } + }, + "proto.NavPageItem": { + "type": "object", + "properties": { + "line": { + "description": "线路序号", + "type": "integer" + }, + "url": { + "description": "线路地址", + "type": "string" + } + } + }, + "proto.NewAddressInfo": { + "type": "object", + "properties": { + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "customerServiceHost": { + "description": "在线客服", + "type": "string" + }, + "foreverUrlAddr": { + "description": "永久海外地址(翻墙访问 永不迷路)", + "type": "string" + }, + "gmailAddr": { + "description": "邮箱地址", + "type": "string" + }, + "newUrlAddr": { + "description": "新国内地址", + "type": "string" + }, + "newUrlAddr1": { + "description": "备用地址1", + "type": "string" + }, + "newUrlAddr2": { + "description": "备用地址1", + "type": "string" + }, + "officialAppAddr": { + "description": "官网app地址", + "type": "string" + }, + "pdfAddr": { + "description": "回家的路PDF", + "type": "string" + }, + "telegram": { + "description": "商务tg", + "type": "string" + }, + "tgGroup": { + "description": "tg群", + "type": "string" + }, + "twitterAddr": { + "description": "推特地址", + "type": "string" + } + } + }, + "proto.PayMethodInfo": { + "type": "object", + "properties": { + "type": { + "description": "alipay / wechat / union", + "type": "string" + }, + "typeName": { + "description": "支付宝 / 微信 / 银联", + "type": "string" + } + } + }, + "proto.PlayletAllListResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletBoughtData": { + "type": "object", + "properties": { + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图,已拼接图片域名", + "type": "string" + }, + "boughtAt": { + "description": "最近购买时间", + "type": "string" + }, + "boughtChapterCount": { + "description": "已购剧集数量", + "type": "integer" + }, + "categoryIds": { + "description": "分类ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "costCoins": { + "description": "已消耗金币", + "type": "integer" + }, + "countBrowse": { + "description": "观看量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "点赞数,包含真实点赞数和后台虚拟点赞数", + "type": "integer" + }, + "cover": { + "description": "竖版封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "description": { + "description": "SEO Description", + "type": "string" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "horizontalCover": { + "description": "横版封面,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "type": "boolean" + }, + "isFinished": { + "description": "当前用户是否已看完", + "type": "boolean" + }, + "isNew": { + "description": "是否上新,本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐,false-不推荐", + "type": "boolean" + }, + "lastChapterId": { + "description": "最近观看剧集ID,无观看进度时为0", + "type": "integer" + }, + "lastEpisode": { + "description": "最近观看集数,无观看进度时为0", + "type": "integer" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "type": "integer" + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "preview": { + "description": "预告地址", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态: 0下架 1上架", + "type": "integer" + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "tagSlugs": { + "description": "标签slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeSlugs": { + "description": "题材slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "unwatchedCount": { + "description": "当前用户未看集数,按totalEpisode-watchedCount计算", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "watchedCount": { + "description": "当前用户已看集数", + "type": "integer" + } + } + }, + "proto.PlayletBoughtListResp": { + "type": "object", + "properties": { + "list": { + "description": "已购短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletBoughtData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总已购短剧数量", + "type": "integer" + } + } + }, + "proto.PlayletChapterCollectData": { + "type": "object", + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "chapterTitle": { + "description": "剧集标题", + "type": "string" + }, + "collectedAt": { + "description": "收藏时间", + "type": "string" + }, + "countBrowse": { + "description": "剧集观看数", + "type": "integer" + }, + "countCollect": { + "description": "剧集收藏数", + "type": "integer" + }, + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态,收藏列表固定返回1-已收藏", + "type": "integer" + }, + "mediaTime": { + "description": "影片长度(秒)", + "type": "integer" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "type": "integer" + }, + "playletCover": { + "description": "短剧封面,已拼接图片域名", + "type": "string" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "playletTitle": { + "description": "短剧标题", + "type": "string" + }, + "singleCoins": { + "description": "单集金币", + "type": "integer" + }, + "totalEpisode": { + "description": "短剧总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "videoUrl": { + "description": "播放地址", + "type": "string" + } + } + }, + "proto.PlayletChapterCollectListResp": { + "type": "object", + "properties": { + "list": { + "description": "收藏剧集集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterCollectData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总收藏数量", + "type": "integer" + } + } + }, + "proto.PlayletChapterCollectSetReq": { + "type": "object", + "required": [ + "chapterId", + "isCollect" + ], + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "isCollect": { + "description": "是否收藏,true-收藏,false-取消收藏", + "type": "boolean" + } + } + }, + "proto.PlayletChapterCollectSetResp": { + "type": "object", + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "countCollect": { + "description": "短剧剧集收藏数", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态 1-已收藏 2-未收藏", + "type": "integer" + } + } + }, + "proto.PlayletChapterData": { + "type": "object", + "properties": { + "balance": { + "description": "当前金币余额", + "type": "integer" + }, + "canWatch": { + "description": "是否可观看", + "type": "boolean" + }, + "costCoins": { + "description": "本次解锁消耗金币", + "type": "integer" + }, + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "hash": { + "description": "媒体hash", + "type": "string" + }, + "height": { + "description": "高", + "type": "integer" + }, + "id": { + "description": "短剧剧集ID", + "type": "integer" + }, + "isUnlocked": { + "description": "当前用户是否已解锁本集", + "type": "boolean" + }, + "md5": { + "description": "媒体md5", + "type": "string" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "mediaTime": { + "description": "影片长度(秒)", + "type": "integer" + }, + "needUnlock": { + "description": "是否需要金币解锁", + "type": "boolean" + }, + "needVip": { + "description": "是否需要开通VIP", + "type": "boolean" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "ratio": { + "description": "宽高比", + "type": "number" + }, + "singleCoins": { + "description": "单集金币(支付方式为1-金币时候生效)", + "type": "integer" + }, + "title": { + "description": "章节名", + "type": "string" + }, + "videoUrl": { + "description": "视频地址", + "type": "string" + }, + "vipValid": { + "description": "当前用户VIP是否有效", + "type": "boolean" + }, + "width": { + "description": "宽", + "type": "integer" + } + } + }, + "proto.PlayletChapterInfoResp": { + "type": "object", + "properties": { + "balance": { + "description": "当前金币余额", + "type": "integer" + }, + "canWatch": { + "description": "是否可观看", + "type": "boolean" + }, + "costCoins": { + "description": "本次解锁消耗金币", + "type": "integer" + }, + "countBrowse": { + "description": "短剧剧集观看数", + "type": "integer" + }, + "countCollect": { + "description": "短剧剧集收藏数", + "type": "integer" + }, + "countComment": { + "description": "短剧剧集评论数", + "type": "integer" + }, + "countLike": { + "description": "短剧剧集点赞数", + "type": "integer" + }, + "currentEpisode": { + "description": "第几集", + "type": "integer" + }, + "hash": { + "description": "媒体hash", + "type": "string" + }, + "height": { + "description": "高", + "type": "integer" + }, + "id": { + "description": "短剧剧集ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态 1-已收藏 2-未收藏", + "type": "integer" + }, + "isLike": { + "description": "点赞状态 1-已点赞 2-未点赞", + "type": "integer" + }, + "isUnlocked": { + "description": "当前用户是否已解锁本集", + "type": "boolean" + }, + "md5": { + "description": "媒体md5", + "type": "string" + }, + "mediaSize": { + "description": "资源大小", + "type": "integer" + }, + "mediaTime": { + "description": "影片长度(秒)", + "type": "integer" + }, + "needUnlock": { + "description": "是否需要金币解锁", + "type": "boolean" + }, + "needVip": { + "description": "是否需要开通VIP", + "type": "boolean" + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "ratio": { + "description": "宽高比", + "type": "number" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "singleCoins": { + "description": "单集金币(支付方式为1-金币时候生效)", + "type": "integer" + }, + "title": { + "description": "章节名", + "type": "string" + }, + "videoUrl": { + "description": "视频地址", + "type": "string" + }, + "vipValid": { + "description": "当前用户VIP是否有效", + "type": "boolean" + }, + "width": { + "description": "宽", + "type": "integer" + } + } + }, + "proto.PlayletChapterLikeSetReq": { + "type": "object", + "required": [ + "chapterId", + "isLike" + ], + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞,false-取消点赞", + "type": "boolean" + } + } + }, + "proto.PlayletChapterLikeSetResp": { + "type": "object", + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "countLike": { + "description": "短剧剧集点赞数", + "type": "integer" + }, + "isLike": { + "description": "点赞状态 1-已点赞 2-未点赞", + "type": "integer" + } + } + }, + "proto.PlayletChapterListResp": { + "type": "object", + "properties": { + "list": { + "description": "短剧剧集集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterData" + } + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletChapterUnlockReq": { + "type": "object", + "required": [ + "chapterId", + "playletId" + ], + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletChapterUnlockResp": { + "type": "object", + "properties": { + "balance": { + "description": "当前金币余额", + "type": "integer" + }, + "canWatch": { + "description": "是否可观看", + "type": "boolean" + }, + "changeCoin": { + "description": "本次实际扣减金币,幂等命中时为0", + "type": "integer" + }, + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "costCoins": { + "description": "本次解锁消耗金币", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "unlocked": { + "description": "是否已解锁", + "type": "boolean" + } + } + }, + "proto.PlayletChapterWatchReportReq": { + "type": "object", + "required": [ + "chapterId" + ], + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "playletId": { + "description": "短剧ID,传入时用于校验剧集归属", + "type": "integer" + } + } + }, + "proto.PlayletChapterWatchReportResp": { + "type": "object", + "properties": { + "chapterId": { + "description": "短剧剧集ID", + "type": "integer" + }, + "currentEpisode": { + "description": "当前集数", + "type": "integer" + }, + "lastChapterId": { + "description": "最近观看剧集ID", + "type": "integer" + }, + "lastEpisode": { + "description": "最近观看集数", + "type": "integer" + }, + "playletId": { + "description": "短剧ID", + "type": "integer" + }, + "unwatchedCount": { + "description": "当前用户未看集数", + "type": "integer" + }, + "watchedCount": { + "description": "当前用户已看集数", + "type": "integer" + } + } + }, + "proto.PlayletCollectData": { + "type": "object", + "properties": { + "collectedAt": { + "description": "收藏时间", + "type": "string" + }, + "countBrowse": { + "description": "观看量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "cover": { + "description": "封面,已拼接图片域名", + "type": "string" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态,收藏列表固定返回1-已收藏", + "type": "integer" + }, + "lastChapterId": { + "description": "最近观看剧集ID,无观看进度时为0", + "type": "integer" + }, + "lastEpisode": { + "description": "最近观看集数,无观看进度时为0", + "type": "integer" + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "tagSlugs": { + "description": "标签slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeSlugs": { + "description": "题材slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "unwatchedCount": { + "description": "当前用户未看集数,按totalEpisode-watchedCount计算", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "watchedCount": { + "description": "当前用户已看集数", + "type": "integer" + } + } + }, + "proto.PlayletCollectListResp": { + "type": "object", + "properties": { + "list": { + "description": "收藏短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletCollectData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总收藏数量", + "type": "integer" + } + } + }, + "proto.PlayletCollectSetReq": { + "type": "object", + "required": [ + "id", + "isCollect" + ], + "properties": { + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isCollect": { + "description": "是否收藏,true-收藏,false-取消收藏", + "type": "boolean" + } + } + }, + "proto.PlayletCollectSetResp": { + "type": "object", + "properties": { + "countCollect": { + "description": "短剧收藏数", + "type": "integer" + }, + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态 1-已收藏 2-未收藏", + "type": "integer" + } + } + }, + "proto.PlayletForwardHeatReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletForwardHeatResp": { + "type": "object", + "properties": { + "heatValue": { + "description": "加热后的热度值", + "type": "integer" + }, + "id": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletGuessLikeListResp": { + "type": "object", + "properties": { + "list": { + "description": "猜你喜欢短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletHistoryClearResp": { + "type": "object", + "properties": { + "deletedCount": { + "description": "删除记录数量", + "type": "integer" + } + } + }, + "proto.PlayletHistoryData": { + "type": "object", + "properties": { + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图,已拼接图片域名", + "type": "string" + }, + "categoryIds": { + "description": "分类ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "countBrowse": { + "description": "观看量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "点赞数,包含真实点赞数和后台虚拟点赞数", + "type": "integer" + }, + "cover": { + "description": "竖版封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "description": { + "description": "SEO Description", + "type": "string" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "historyAt": { + "description": "最近观看时间", + "type": "string" + }, + "horizontalCover": { + "description": "横版封面,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "type": "boolean" + }, + "isFinished": { + "description": "是否已看完", + "type": "boolean" + }, + "isNew": { + "description": "是否上新,本周内上架为true", + "type": "boolean" + }, + "isRecommend": { + "description": "是否推荐 true-推荐,false-不推荐", + "type": "boolean" + }, + "lastChapterId": { + "description": "最近观看剧集ID", + "type": "integer" + }, + "lastEpisode": { + "description": "最近观看集数", + "type": "integer" + }, + "mediaCreatedAt": { + "description": "媒资中心创建时间", + "type": "string" + }, + "mediaUpdatedAt": { + "description": "媒资中心更新时间", + "type": "string" + }, + "onlineAt": { + "description": "上架时间", + "type": "string" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "type": "integer" + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "preview": { + "description": "预告地址", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态: 0下架 1上架", + "type": "integer" + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "tagSlugs": { + "description": "标签slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeSlugs": { + "description": "题材slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "unwatchedCount": { + "description": "当前用户未看集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + }, + "watchedCount": { + "description": "当前用户已看集数", + "type": "integer" + } + } + }, + "proto.PlayletHistoryDeleteReq": { + "type": "object", + "required": [ + "playletId" + ], + "properties": { + "playletId": { + "description": "短剧ID", + "type": "integer" + } + } + }, + "proto.PlayletHistoryDeleteResp": { + "type": "object", + "properties": { + "deletedCount": { + "description": "删除记录数量", + "type": "integer" + } + } + }, + "proto.PlayletHistoryListResp": { + "type": "object", + "properties": { + "list": { + "description": "观看历史集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletHistoryData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总历史数量", + "type": "integer" + } + } + }, + "proto.PlayletHotLikeResp": { + "type": "object", + "properties": { + "guessLike": { + "description": "按收藏量最多的15部上架短剧", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "hotSearch": { + "description": "前10个热搜词", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletHotSearchItem" + } + } + } + }, + "proto.PlayletHotRankListResp": { + "type": "object", + "properties": { + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "rankType": { + "description": "榜单类型 1-热播榜 2-新剧榜 3-追剧榜", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletHotSearchItem": { + "type": "object", + "properties": { + "count": { + "description": "搜索次数", + "type": "integer" + }, + "keyword": { + "description": "搜索词", + "type": "string" + } + } + }, + "proto.PlayletInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "anchor": { + "description": "作者/演员", + "type": "string" + }, + "banner": { + "description": "banner图,已拼接图片域名", + "type": "string" + }, + "categoryIds": { + "description": "分类ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "countBrowse": { + "description": "观看量", + "type": "integer" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countLike": { + "description": "点赞数,包含真实点赞数和后台虚拟点赞数", + "type": "integer" + }, + "cover": { + "description": "竖版封面,已拼接图片域名", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "简介", + "type": "string" + }, + "description": { + "description": "SEO Description", + "type": "string" + }, + "heatValue": { + "description": "热度值", + "type": "integer" + }, + "horizontalCover": { + "description": "横版封面,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "短剧id", + "type": "integer" + }, + "isCollect": { + "description": "是否收藏(1-已收藏,2-未收藏)", + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "type": "boolean" + }, + "isLike": { + "description": "是否点赞(1-已点赞,2-未点赞)", + "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" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "type": "integer" + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "playletChapterInfo": { + "description": "短剧章节集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletChapterData" + } + }, + "preview": { + "description": "预告地址", + "type": "string" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "showOnBanner": { + "description": "是否显示在首页banner", + "type": "boolean" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "状态: 0下架 1上架", + "type": "integer" + }, + "tagIds": { + "description": "标签ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagNames": { + "description": "标签名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "tagSlugs": { + "description": "标签slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "themeNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "themeSlugs": { + "description": "题材slug集合", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "totalEpisode": { + "description": "总集数", + "type": "integer" + }, + "updateStatus": { + "description": "更新状态 1-连载中 2-已完结", + "type": "integer" + }, + "updatedAt": { + "description": "修改时间", + "type": "string" + } + } + }, + "proto.PlayletLikeSetReq": { + "type": "object", + "required": [ + "id", + "isLike" + ], + "properties": { + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞,false-取消点赞", + "type": "boolean" + } + } + }, + "proto.PlayletLikeSetResp": { + "type": "object", + "properties": { + "countLike": { + "description": "短剧点赞数", + "type": "integer" + }, + "id": { + "description": "短剧ID", + "type": "integer" + }, + "isLike": { + "description": "点赞状态 1-已点赞 2-未点赞", + "type": "integer" + } + } + }, + "proto.PlayletOperateListResp": { + "type": "object", + "properties": { + "filterType": { + "description": "筛选类型 1-最近更新 2-当前热播 3-独家原创", + "type": "integer" + }, + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "type": "integer" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletRankResp": { + "type": "object", + "properties": { + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息(短剧列表页SEO)", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletSearchResp": { + "type": "object", + "properties": { + "everyoneWatching": { + "description": "大家都在看,搜索无结果时返回", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "keyword": { + "description": "搜索词", + "type": "string" + }, + "list": { + "description": "搜索结果", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletTagItem": { + "type": "object", + "properties": { + "id": { + "description": "标签ID", + "type": "integer" + }, + "name": { + "description": "标签名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "标签对应页面 SEO 信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "上架状态", + "type": "boolean" + } + } + }, + "proto.PlayletTagListResponse": { + "type": "object", + "properties": { + "list": { + "description": "标签列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletTagItem" + } + } + } + }, + "proto.PlayletTagVideoResp": { + "type": "object", + "properties": { + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息(标签SEO)", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletThemeItem": { + "type": "object", + "properties": { + "icon": { + "description": "题材图标,已拼接图片域名", + "type": "string" + }, + "id": { + "description": "题材ID", + "type": "integer" + }, + "isRecommend": { + "description": "是否推荐", + "type": "boolean" + }, + "name": { + "description": "题材名称", + "type": "string" + }, + "playletCount": { + "description": "该题材绑定的上架短剧数量", + "type": "integer" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "题材对应页面 SEO 信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "status": { + "description": "上架状态", + "type": "boolean" + } + } + }, + "proto.PlayletThemeListResponse": { + "type": "object", + "properties": { + "list": { + "description": "题材列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletThemeItem" + } + } + } + }, + "proto.PlayletTodayUpdateResp": { + "type": "object", + "properties": { + "list": { + "description": "短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息(短剧列表页SEO)", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletTopicDetailResp": { + "type": "object", + "properties": { + "covers": { + "description": "顶部封面集合", + "type": "array", + "items": { + "type": "string" + } + }, + "desc": { + "description": "描述", + "type": "string" + }, + "icon": { + "description": "图标", + "type": "string" + }, + "id": { + "description": "专题ID", + "type": "integer" + }, + "list": { + "description": "专题短剧列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "moreTopics": { + "description": "更多专题", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletTopicPageItem" + } + }, + "name": { + "description": "专题名称", + "type": "string" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "playletCount": { + "description": "上架短剧数量", + "type": "integer" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "专题SEO", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "totalCount": { + "description": "短剧总数", + "type": "integer" + } + } + }, + "proto.PlayletTopicPageItem": { + "type": "object", + "properties": { + "covers": { + "description": "展示封面集合", + "type": "array", + "items": { + "type": "string" + } + }, + "desc": { + "description": "描述", + "type": "string" + }, + "icon": { + "description": "图标", + "type": "string" + }, + "id": { + "description": "专题ID", + "type": "integer" + }, + "list": { + "description": "专题下短剧集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.CommonData" + } + }, + "name": { + "description": "专题名称", + "type": "string" + }, + "playletCount": { + "description": "上架短剧数量", + "type": "integer" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "seoInfo": { + "description": "专题SEO", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + } + } + }, + "proto.PlayletTopicPageListResp": { + "type": "object", + "properties": { + "list": { + "description": "专题集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletTopicPageItem" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.RechargePackageItem": { + "type": "object", + "properties": { + "baseCoin": { + "description": "基础金币数", + "type": "integer" + }, + "durationDays": { + "description": "会员有效天数", + "type": "integer" + }, + "durationHours": { + "description": "会员有效小时数,由durationDays计算", + "type": "integer" + }, + "giftCoin": { + "description": "赠送金币数", + "type": "integer" + }, + "id": { + "description": "套餐ID", + "type": "integer" + }, + "name": { + "description": "展示标题", + "type": "string" + }, + "originalPrice": { + "description": "原价,单位分", + "type": "integer" + }, + "packageType": { + "description": "套餐类型:1-会员畅享 2-金币充值", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.PackageType" + } + ] + }, + "payMethods": { + "description": "该套餐支持的支付方式", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PayMethodInfo" + } + }, + "price": { + "description": "售价,单位分", + "type": "integer" + }, + "sort": { + "description": "排序,越小越靠前", + "type": "integer" + }, + "subTitle": { + "description": "展示副标题", + "type": "string" + }, + "totalCoin": { + "description": "到账总金币数,由baseCoin+giftCoin计算", + "type": "integer" + } + } + }, + "proto.RechargePackageListResp": { + "type": "object", + "properties": { + "coinList": { + "description": "金币充值列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.RechargePackageItem" + } + }, + "vipList": { + "description": "会员畅享列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.RechargePackageItem" + } + } + } + }, + "proto.SeoInfo": { + "type": "object", + "properties": { + "canonicalUrl": { + "description": "当前内容的标准URL", + "type": "string" + }, + "description": { + "description": "页面描述", + "type": "string" + }, + "keywords": { + "description": "页面关键词", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "页面标题", + "type": "string" + }, + "webSiteName": { + "description": "网站名称", + "type": "string" + } + } + }, + "proto.SeoNavInfo": { + "type": "object", + "properties": { + "dhDescription": { + "description": "导航描述", + "type": "string" + }, + "dhKeyworld": { + "description": "关键词", + "type": "array", + "items": { + "type": "string" + } + }, + "dhSiteName": { + "description": "导航名称", + "type": "string" + }, + "dhTitle": { + "description": "导航标题", + "type": "string" + } + } + }, + "proto.TranslationCategory": { + "type": "object", + "properties": { + "categoryType": { + "description": "导航类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.TranslationType" + } + ] + }, + "icon": { + "description": "Icon", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "description": "名称", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "translationList": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.TranslationInfo" + } + }, + "urlValue": { + "description": "URl路径", + "type": "string" + } + } + }, + "proto.TranslationInfo": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + }, + "isFriendAds": { + "description": "是否是友链广告位", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + }, + "urlValue": { + "description": "URl路径", + "type": "string" + } + } + }, + "proto.UserCenterDataResp": { + "type": "object", + "properties": { + "boughtChapterCount": { + "description": "已购剧集数量", + "type": "integer" + }, + "chasingCount": { + "description": "追剧中数量", + "type": "integer" + }, + "collectCount": { + "description": "收藏数量", + "type": "integer" + }, + "watchCount": { + "description": "观看数量", + "type": "integer" + } + } + }, + "proto.VideoAllListItemResp": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名称", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "mediaType": { + "description": "类型 1-影集 2-动画", + "type": "integer" + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "topicIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "topicNames": { + "description": "题材名称集合", + "type": "array", + "items": { + "type": "string" + } + }, + "topics": { + "description": "题材集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoTopicInfo" + } + }, + "videoCount": { + "description": "视频数量", + "type": "integer" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "watchTimes": { + "description": "观看数", + "type": "integer" + } + } + }, + "proto.VideoAllListResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "list": { + "description": "视频集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoAllListItemResp" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.VideoCollectData": { + "type": "object", + "properties": { + "collectedAt": { + "description": "收藏时间", + "type": "string" + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countComment": { + "description": "评论数", + "type": "integer" + }, + "countLike": { + "description": "点赞数", + "type": "integer" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "视频ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态,收藏列表固定返回1-已收藏", + "type": "integer" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "watchTimes": { + "description": "观看数", + "type": "integer" + } + } + }, + "proto.VideoCollectListResp": { + "type": "object", + "properties": { + "list": { + "description": "收藏视频集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoCollectData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总收藏数量", + "type": "integer" + } + } + }, + "proto.VideoCollectSetReq": { + "type": "object", + "required": [ + "id", + "isCollect" + ], + "properties": { + "id": { + "description": "视频ID", + "type": "integer" + }, + "isCollect": { + "description": "是否收藏,true-收藏,false-取消收藏", + "type": "boolean" + } + } + }, + "proto.VideoCollectSetResp": { + "type": "object", + "properties": { + "countCollect": { + "description": "视频收藏数", + "type": "integer" + }, + "id": { + "description": "视频ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态 1-已收藏 2-未收藏", + "type": "integer" + } + } + }, + "proto.VideoData": { + "type": "object", + "properties": { + "cover": { + "description": "视频封面", + "type": "string" + }, + "fileId": { + "description": "媒资库ID", + "type": "string" + }, + "height": { + "description": "分辨率高(视频才会存在)", + "type": "integer" + }, + "preMp4FileNameFs": { + "description": "预览MP4文件", + "type": "string" + }, + "taskId": { + "description": "视频ID(视频才会存在)", + "type": "string" + }, + "unencryptedPreMp4": { + "description": "不加密的预览MP4", + "type": "string" + }, + "url": { + "description": "视频URl", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "width": { + "description": "分辨率宽(视频才会存在)", + "type": "integer" + } + } + }, + "proto.VideoHistoryClearResp": { + "type": "object", + "properties": { + "deletedCount": { + "description": "删除记录数量", + "type": "integer" + } + } + }, + "proto.VideoHistoryData": { + "type": "object", + "properties": { + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countComment": { + "description": "评论数", + "type": "integer" + }, + "countLike": { + "description": "点赞数", + "type": "integer" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "historyAt": { + "description": "最近观看时间", + "type": "string" + }, + "id": { + "description": "视频ID", + "type": "integer" + }, + "isCollect": { + "description": "收藏状态 1-已收藏 2-未收藏", + "type": "integer" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "watchTimes": { + "description": "观看数", + "type": "integer" + } + } + }, + "proto.VideoHistoryDeleteReq": { + "type": "object", + "required": [ + "videoId" + ], + "properties": { + "videoId": { + "description": "视频ID", + "type": "integer" + } + } + }, + "proto.VideoHistoryDeleteResp": { + "type": "object", + "properties": { + "deletedCount": { + "description": "删除记录数量", + "type": "integer" + } + } + }, + "proto.VideoHistoryListResp": { + "type": "object", + "properties": { + "list": { + "description": "观看历史集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoHistoryData" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总历史数量", + "type": "integer" + } + } + }, + "proto.VideoInfoResp": { + "type": "object", + "properties": { + "advitise": { + "description": "广告", + "allOf": [ + { + "$ref": "#/definitions/proto.AdvitiseData" + } + ] + }, + "countCollect": { + "description": "收藏数", + "type": "integer" + }, + "countComment": { + "description": "评论数", + "type": "integer" + }, + "countLike": { + "description": "点赞数", + "type": "integer" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "isCollect": { + "description": "当前用户收藏状态 1-已收藏 2-未收藏", + "type": "integer" + }, + "isLike": { + "description": "当前用户点赞状态 1-已点赞 2-未点赞", + "type": "integer" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "seoInfo": { + "description": "页面SEO信息", + "allOf": [ + { + "$ref": "#/definitions/proto.SeoInfo" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "tagsName": { + "description": "标签名称", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "topicIds": { + "description": "题材ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "topics": { + "description": "题材集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoTopicInfo" + } + }, + "video": { + "description": "视频", + "allOf": [ + { + "$ref": "#/definitions/proto.VideoData" + } + ] + }, + "watchTimes": { + "description": "观看数(真假之和)", + "type": "integer" + } + } + }, + "proto.VideoItemResp": { + "type": "object", + "properties": { + "addedTime": { + "description": "上架时间", + "type": "string" + }, + "coverImg": { + "description": "封面", + "type": "string" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名称", + "type": "string" + }, + "id": { + "description": "id", + "type": "integer" + }, + "keyworld": { + "description": "关键字", + "type": "array", + "items": { + "type": "string" + } + }, + "slug": { + "description": "slug", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "标题", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "watchTimes": { + "description": "观看数(真假之和)", + "type": "integer" + } + } + }, + "proto.VideoLikeSetReq": { + "type": "object", + "required": [ + "id", + "isLike" + ], + "properties": { + "id": { + "description": "视频ID", + "type": "integer" + }, + "isLike": { + "description": "是否点赞,true-点赞,false-取消点赞", + "type": "boolean" + } + } + }, + "proto.VideoLikeSetResp": { + "type": "object", + "properties": { + "countLike": { + "description": "视频点赞数", + "type": "integer" + }, + "id": { + "description": "视频ID", + "type": "integer" + }, + "isLike": { + "description": "点赞状态 1-已点赞 2-未点赞", + "type": "integer" + } + } + }, + "proto.VideoResp": { + "type": "object", + "properties": { + "list": { + "description": "视频列表", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoItemResp" + } + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.VideoTopicInfo": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "image": { + "description": "背景", + "type": "string" + }, + "name": { + "description": "专题名称", + "type": "string" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "videoCategoryId": { + "description": "视频分类id", + "type": "integer" + }, + "works": { + "description": "作品数", + "type": "integer" + } + } + }, + "proto.VideoTopicListResp": { + "type": "object", + "properties": { + "list": { + "description": "题材集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.VideoTopicInfo" + } + } + } + }, + "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.LoginType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "LoginTypeAccount": "账号登陆", + "LoginTypeDevice": "设备登陆", + "LoginTypeEmail": "邮箱登陆" + }, + "x-enum-varnames": [ + "LoginTypeDevice", + "LoginTypeAccount", + "LoginTypeEmail" + ] + }, + "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" + ] + }, + "pubdef.UserType": { + "type": "integer", + "enum": [ + 0, + 1, + 0 + ], + "x-enum-comments": { + "UserTypeGuest": "游客用户", + "UserTypeNormal": "普通用户(已注册)" + }, + "x-enum-varnames": [ + "UserTypeGuest", + "UserTypeNormal", + "UserTypeNone" + ] + }, + "pubdef.VipOrderStatus": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-comments": { + "VipOrderFailed": "支付失败", + "VipOrderPending": "待支付", + "VipOrderSuccess": "支付成功" + }, + "x-enum-varnames": [ + "VipOrderPending", + "VipOrderSuccess", + "VipOrderFailed" + ] + }, + "pubdef.VipType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "VipTypeMonth": "月卡", + "VipTypeNone": "没卡", + "VipTypeYear": "年卡" + }, + "x-enum-varnames": [ + "VipTypeNone", + "VipTypeMonth", + "VipTypeYear" + ] + }, + "rechargeapi.submitOrderReq": { + "type": "object", + "required": [ + "packageId", + "payMode" + ], + "properties": { + "packageId": { + "description": "购买套餐ID(会员畅享)", + "type": "integer" + }, + "payMode": { + "description": "支付方式:alipay/wechat", + "type": "string" + } + } + }, + "rechargepackagemod.PackageType": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "PackageTypeCoin": "金币充值", + "PackageTypeVip": "会员畅享" + }, + "x-enum-varnames": [ + "PackageTypeVip", + "PackageTypeCoin" + ] + }, + "rechargeser.SubmitOrderResp": { + "type": "object", + "properties": { + "isOpenNewBrowser": { + "description": "是否需要外部浏览器打开", + "type": "boolean" + }, + "payUrl": { + "description": "支付链接", + "type": "string" + }, + "payUrlMode": { + "description": "链接打开方式", + "type": "string" + }, + "tradeNo": { + "description": "本地订单号(可用于查询)", + "type": "string" + } + } + }, + "response.BasePageResp": { + "type": "object", + "properties": { + "list": { + "description": "集合列表" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "statcenter.AiSellMsg": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "appID": { + "type": "integer" + }, + "currencyType": { + "type": "string" + }, + "devType": { + "type": "string" + }, + "isRepurchase": { + "type": "string" + }, + "sysType": { + "type": "string" + }, + "tranCreatedAt": { + "type": "string" + }, + "tranType": { + "type": "string" + }, + "uid": { + "type": "integer" + }, + "uniqID": { + "type": "string" + } + } + }, + "statcenter.BuyVIPType": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "x-enum-comments": { + "BuyVIPTypeGold": "金币", + "BuyVIPTypeMoney": "现金" + }, + "x-enum-varnames": [ + "BuyVIPTypeMoney", + "BuyVIPTypeGold" + ] + }, + "statcenter.CardSellMsg": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "appID": { + "type": "integer" + }, + "currencyType": { + "description": "0金币 1现金", + "type": "integer" + }, + "product": { + "$ref": "#/definitions/statcenter.Product" + }, + "sysType": { + "type": "string" + }, + "tranCreatedAt": { + "type": "string" + }, + "tranType": { + "type": "string" + }, + "tranTypeInt": { + "type": "integer" + }, + "uid": { + "type": "integer" + }, + "uniqID": { + "type": "string" + } + } + }, + "statcenter.ConsumeRecordMsg": { + "type": "object", + "properties": { + "amount": { + "description": "金币", + "type": "number" + }, + "appID": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "currencyType": { + "description": "0金币 1人民币", + "type": "integer" + }, + "money": { + "description": "人民币 元", + "type": "number" + }, + "type": { + "description": "消费类型", + "type": "integer" + }, + "uid": { + "type": "integer" + }, + "uniq": { + "type": "string" + } + } + }, + "statcenter.Product": { + "type": "object", + "properties": { + "discountedPrice": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "statcenter.StatcenterSyncReq": { + "type": "object", + "properties": { + "job": { + "description": "同步任务类型", + "type": "string" + }, + "maxSize": { + "description": "最大条数", + "type": "integer" + }, + "platformId": { + "description": "原始平台Id(访问/流水)", + "type": "string" + }, + "successTime": { + "description": "游标时间(购买会员、提现、充值)", + "type": "string" + }, + "userId": { + "description": "用户(注册)", + "type": "integer" + } + } + }, + "statcenter.StatcenterSyncResp": { + "type": "object", + "properties": { + "accessList": { + "description": "日活记录", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserVisitMsg" + } + }, + "aiSellList": { + "description": "AI销售流水", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.AiSellMsg" + } + }, + "allRechargeList": { + "description": "全部支付订单", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserRechargeMsg" + } + }, + "bindingList": { + "description": "用户绑定记录", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserBindingMsg" + } + }, + "buyVipList": { + "description": "VIP购买记录", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserBuyVIPMsg" + } + }, + "cardSellList": { + "description": "会员卡销售流水", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.CardSellMsg" + } + }, + "code": { + "description": "200正常 其他异常", + "type": "integer" + }, + "consumeList": { + "description": "消费流水", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.ConsumeRecordMsg" + } + }, + "inviteList": { + "description": "邀请绑定", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserInviteBindMsg" + } + }, + "job": { + "description": "类型", + "type": "string" + }, + "msg": { + "description": "错误消息", + "type": "string" + }, + "rechargeList": { + "description": "充值记录", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserRechargeMsg" + } + }, + "registerList": { + "description": "注册记录", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserRegisterMsg" + } + }, + "visitList": { + "description": "日活记录(旧字段,兼容)", + "type": "array", + "items": { + "$ref": "#/definitions/statcenter.UserVisitMsg" + } + } + } + }, + "statcenter.UserBindingMsg": { + "type": "object", + "properties": { + "appId": { + "description": "appID", + "type": "integer" + }, + "bindingTime": { + "description": "绑定时间", + "type": "string" + }, + "devType": { + "description": "设备类型", + "type": "string" + }, + "mobile": { + "description": "手机号/绑定账号(本项目填邮箱)", + "type": "string" + }, + "platformId": { + "description": "原始平台流水Id", + "type": "string" + }, + "sysType": { + "description": "操作系统类型", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + } + } + }, + "statcenter.UserBuyVIPMsg": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "appId": { + "type": "integer" + }, + "buyVIPType": { + "$ref": "#/definitions/statcenter.BuyVIPType" + }, + "deviceType": { + "type": "string" + }, + "districtCode": { + "type": "string" + }, + "isDirect": { + "type": "boolean" + }, + "money": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "oid": { + "type": "string" + }, + "platformId": { + "type": "string" + }, + "promCode": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "successTime": { + "type": "string" + }, + "uid": { + "type": "integer" + } + } + }, + "statcenter.UserInviteBindMsg": { + "type": "object", + "required": [ + "appId", + "inviteTime", + "userId" + ], + "properties": { + "appId": { + "type": "integer" + }, + "inviteTime": { + "type": "string" + }, + "parentPromCode": { + "type": "string" + }, + "userId": { + "type": "integer" + } + } + }, + "statcenter.UserRechargeMsg": { + "type": "object", + "required": [ + "appId", + "money", + "orderId", + "payMoney", + "sysType", + "userId" + ], + "properties": { + "appId": { + "type": "integer" + }, + "chanShareMod": { + "type": "integer" + }, + "channelName": { + "type": "string" + }, + "cid": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "devType": { + "type": "string" + }, + "districtCode": { + "type": "string" + }, + "isNewOrOldUser": { + "type": "boolean" + }, + "money": { + "type": "integer" + }, + "oid": { + "type": "string" + }, + "orderId": { + "type": "string" + }, + "payMoney": { + "type": "integer" + }, + "paymentAt": { + "type": "string" + }, + "platformId": { + "type": "string" + }, + "productType": { + "type": "integer" + }, + "rate": { + "type": "string" + }, + "registerTime": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "successAt": { + "type": "string" + }, + "sysType": { + "type": "string" + }, + "type": { + "type": "string" + }, + "userId": { + "type": "integer" + } + } + }, + "statcenter.UserRegisterMsg": { + "type": "object", + "required": [ + "appId", + "isDirect", + "registerTime", + "sysType", + "userId" + ], + "properties": { + "appId": { + "description": "appID", + "type": "integer" + }, + "devType": { + "description": "设备类型", + "type": "string" + }, + "districtCode": { + "description": "渠道码", + "type": "string" + }, + "ip": { + "description": "IP", + "type": "string" + }, + "isDirect": { + "description": "true:是直推用户", + "type": "boolean" + }, + "mobile": { + "description": "手机号", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "platformId": { + "description": "原始平台流水Id", + "type": "string" + }, + "promCode": { + "description": "推广序列", + "type": "string" + }, + "promSeqe": { + "description": "全名代理", + "type": "string" + }, + "puc": { + "description": "应用中心推广", + "type": "string" + }, + "registerTime": { + "description": "注册时间", + "type": "string" + }, + "sysType": { + "description": "操作系统类型 安卓 IOS", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + } + } + }, + "statcenter.UserVisitMsg": { + "type": "object", + "properties": { + "appId": { + "description": "appID", + "type": "integer" + }, + "devID": { + "description": "设备ID", + "type": "string" + }, + "devType": { + "description": "设备型号", + "type": "string" + }, + "districtCode": { + "description": "渠道码", + "type": "string" + }, + "ip": { + "description": "IP", + "type": "string" + }, + "isDirect": { + "description": "true:是直推用户", + "type": "boolean" + }, + "platformId": { + "description": "原始平台流水Id", + "type": "string" + }, + "registerTime": { + "description": "用户注册时间", + "type": "string" + }, + "sysType": { + "description": "操作系统类型 安卓 IOS", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + }, + "version": { + "description": "APP版本号", + "type": "string" + }, + "visitAt": { + "description": "访问时间", + "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" + } + } + }, + "vipordermod.OrderType": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "OrderTypeCoin": "金币购买", + "OrderTypeVip": "会员购买" + }, + "x-enum-varnames": [ + "OrderTypeVip", + "OrderTypeCoin" + ] + }, + "vipordermod.VipOrder": { + "type": "object", + "properties": { + "amount": { + "description": "支付金额(分)", + "type": "integer" + }, + "baseCoin": { + "description": "基础金币数(快照)", + "type": "integer" + }, + "cardId": { + "description": "── VIP 专用 ──", + "type": "integer" + }, + "cardName": { + "description": "套餐名快照", + "type": "string" + }, + "coin": { + "description": "到账金币数(快照)", + "type": "integer" + }, + "coinCfgId": { + "description": "── 金币专用 ──", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "districtCode": { + "description": "下单时用户渠道码", + "type": "string" + }, + "finishedAt": { + "description": "完成时间", + "type": "string" + }, + "giftCoin": { + "description": "赠送金币数(快照)", + "type": "integer" + }, + "id": { + "description": "唯一ID", + "type": "integer" + }, + "ipAddr": { + "description": "客户端 IP", + "type": "string" + }, + "oid": { + "description": "第三方订单号", + "type": "string" + }, + "orderType": { + "description": "订单类型:1会员 2金币", + "allOf": [ + { + "$ref": "#/definitions/vipordermod.OrderType" + } + ] + }, + "payChannel": { + "description": "支付渠道", + "type": "string" + }, + "payMode": { + "description": "支付方式:overage/alipay/wechat", + "type": "string" + }, + "payRate": { + "description": "费率", + "type": "string" + }, + "payUrl": { + "description": "支付链接", + "type": "string" + }, + "payUrlMode": { + "description": "链接打开方式", + "type": "string" + }, + "status": { + "description": "订单状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.VipOrderStatus" + } + ] + }, + "sysType": { + "description": "ios/android", + "type": "string" + }, + "tradeNo": { + "description": "本地订单号", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "userId": { + "description": "用户ID", + "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..1d996e9 --- /dev/null +++ b/app/docs/swagger.yaml @@ -0,0 +1,5922 @@ +basePath: / +definitions: + authapi.loginReq: + properties: + account: + description: 账号或邮箱 + type: string + channel: + description: 渠道码(可选,用户无商区码时写入) + type: string + devID: + description: 设备唯一标示(可选) + type: string + password: + description: 明文密码 + type: string + required: + - account + - password + type: object + authapi.registerReq: + properties: + accountNo: + description: 账号 + type: string + agentInviteCode: + description: 邀请人的邀请码(可选) + type: string + channel: + description: 渠道码(可选,用户无商区码时写入) + type: string + devID: + description: 设备唯一标示(可选) + type: string + email: + description: 邮箱 + type: string + password: + description: 明文密码 + type: string + required: + - accountNo + - email + - password + type: object + authapi.resetPasswordReq: + properties: + accountNo: + description: 账号 + type: string + email: + description: 邮箱 + type: string + newPassword: + description: 新密码 + type: string + required: + - accountNo + - email + - newPassword + type: object + authapi.updateProfileReq: + properties: + avatar: + description: 新头像 URL(可选) + type: string + introduction: + description: 简介(可选) + type: string + newPassword: + description: 新密码明文(设置或修改密码时必填) + type: string + nickName: + description: 新昵称(可选) + type: string + oldPassword: + description: 旧密码明文(已设置密码时修改必填) + type: string + privacyMode: + description: 隐私模式:true-开启,false-关闭(可选) + type: boolean + type: object + authser.GuestLoginReq: + properties: + channel: + description: 渠道码(可选,用户无商区码时写入 DistrictCode) + type: string + devID: + description: 设备唯一标示(必填) + type: string + deviceId: + description: 备用设备ID(可选,写入 DevV2) + type: string + mac: + description: 设备 Mac(可选) + type: string + type: object + authser.LoginResp: + properties: + accountNo: + type: string + avatar: + description: 头像 + type: string + currentVipExpire: + description: VIP 过期时间,客户端需结合 vipType 判断是否真正有效 + type: string + email: + type: string + hasPassword: + description: 是否已设置密码 + type: boolean + introduction: + description: 简介 + type: string + inviteCode: + description: 邀请码 + type: string + isNewUser: + description: 本次是否新建游客 + type: boolean + isRegister: + description: 是否已注册 + type: boolean + loginType: + allOf: + - $ref: '#/definitions/pubdef.LoginType' + description: 登陆方式 + nickName: + type: string + privacyMode: + description: 隐私模式 + type: boolean + recharge: + description: 金币余额 + type: integer + score: + description: 积分 + type: integer + showCardId: + description: 最新会员卡id + type: integer + showCardName: + description: 最新会员卡名称 + type: string + status: + $ref: '#/definitions/pubdef.UserStatus' + token: + type: string + type: + allOf: + - $ref: '#/definitions/pubdef.UserType' + description: 身份类型:0游客 1普通用户 + userId: + type: integer + vipType: + $ref: '#/definitions/pubdef.VipType' + type: object + coinapi.buyCoinReq: + properties: + packageId: + description: 购买套餐ID(金币充值) + type: integer + payMode: + description: 支付方式:alipay/wechat + type: string + required: + - packageId + - payMode + type: object + coinser.BuyCoinResp: + properties: + isOpenNewBrowser: + type: boolean + payUrl: + type: string + payUrlMode: + type: string + tradeNo: + type: string + type: object + cointransmod.FlowDir: + enum: + - 1 + - -1 + type: integer + x-enum-comments: + FlowIn: 收入(充值、赠送) + FlowOut: 支出(消费、解锁) + x-enum-varnames: + - FlowIn + - FlowOut + cointransmod.TranType: + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + type: integer + x-enum-comments: + TranTypeAdminAdjust: 管理员手动调整 + TranTypeCoinGift: 金币充值赠送 + TranTypeCoinRecharge: 金币充值(购买金币) + TranTypeDarkAlley: 历史遗留交易类型(已下线) + TranTypeVideoUnlock: 视频解锁 + TranTypeVipGiftCoin: 购买会员赠送金币 + x-enum-varnames: + - TranTypeCoinRecharge + - TranTypeVipGiftCoin + - TranTypeDarkAlley + - TranTypeVideoUnlock + - TranTypeAdminAdjust + - TranTypeCoinGift + proto.AdvitiseData: + properties: + AList: + description: 固定12广告位 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + BList: + description: 固定13广告位 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + bottomBanner: + description: 底部banner + 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 + downList: + 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 + nineGridList: + description: 9宫格 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + pauseyList: + description: 暂停广告 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + playList: + description: 播放强制跳转 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + titleList: + description: 标题广告 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + videoInfoList: + description: 视频详情 + items: + $ref: '#/definitions/proxy.AdvitiseInfo' + type: array + type: object + proto.AnnouncementItem: + properties: + content: + description: 内容 + type: string + id: + description: 公告ID + type: integer + title: + description: 标题 + type: string + 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.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: 广告 + 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 + 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.CoinTransListItemResp: + properties: + amount: + description: 本次交易金币数,始终为正数 + type: integer + balanceAfter: + description: 交易后余额 + type: integer + balanceBefore: + description: 交易前余额 + type: integer + changeAmount: + description: 带方向的金币变动数,收入为正数,支出为负数 + type: integer + createdAt: + description: 交易时间,格式:2006-01-02 15:04:05 + type: string + flowDir: + allOf: + - $ref: '#/definitions/cointransmod.FlowDir' + description: 收支方向:1-收入,-1-支出 + flowDirName: + description: 收支方向名称:收入/支出 + type: string + id: + description: 流水ID + type: integer + orderNo: + description: 关联订单号 + type: string + relatedId: + description: 关联内容ID + type: integer + remark: + description: 备注 + type: string + title: + description: 标题,优先使用备注,备注为空时使用交易类型名称 + type: string + tranType: + allOf: + - $ref: '#/definitions/cointransmod.TranType' + description: 交易类型 1-金币充值(购买金币)2-购买会员赠送金币 3-历史遗留 4-视频解锁 5-管理员手动调整 + tranTypeName: + description: 交易类型名称 + type: string + type: object + proto.CommentInfo: + properties: + children: + description: 二级评论列表 + items: + $ref: '#/definitions/proto.CommentInfo' + type: array + commentCount: + description: 二级评论数量 + type: integer + countLike: + description: 评论点赞数 + type: integer + createdAt: + description: 创建时间 + type: string + id: + description: 评论ID + type: integer + isLike: + description: 当前用户点赞状态 1-已点赞 2-未点赞 + type: integer + level: + description: 评论级别 1-一级评论 2-二级评论 + type: integer + objectId: + description: 对象ID + type: integer + objectType: + description: 对象类型 10-短剧 11-短剧剧集 20-视频 + type: integer + parentsId: + description: 父评论ID,一级评论为0 + type: integer + replyCommentId: + description: 被回复评论ID,一级评论为0 + type: integer + replyUserName: + description: 被回复用户昵称 + type: string + text: + description: 评论内容 + type: string + userAvatar: + description: 用户头像,已拼接图片域名 + type: string + userId: + description: 评论用户ID + type: integer + userName: + description: 用户名称 + type: string + type: object + proto.CommentLikeSetReq: + properties: + commentId: + description: 评论ID + type: integer + isLike: + description: 是否点赞,true-点赞,false-取消点赞 + type: boolean + required: + - commentId + - isLike + type: object + proto.CommentLikeSetResp: + properties: + commentId: + description: 评论ID + type: integer + countLike: + description: 评论点赞数 + type: integer + isLike: + description: 点赞状态 1-已点赞 2-未点赞 + type: integer + type: object + proto.CommentSendReq: + properties: + objectId: + description: 对象ID,短剧评论传短剧ID,剧集评论传剧集ID,视频评论传视频ID + type: integer + objectType: + description: 对象类型 10-短剧 11-短剧剧集 20-视频 + type: integer + parentsId: + description: 一级评论ID,0-发布一级评论,大于0-发布二级评论 + type: integer + replyCommentId: + description: 被回复评论ID,0时默认回复parentsId + type: integer + text: + description: 评论内容 + type: string + type: object + proto.CommonData: + properties: + anchor: + description: 作者/演员 + type: string + banner: + description: banner图,已拼接图片域名 + type: string + categoryIds: + description: 分类ID集合 + items: + type: integer + type: array + countBrowse: + description: 观看量 + type: integer + countCollect: + description: 收藏数 + type: integer + countLike: + description: 点赞数,包含真实点赞数和后台虚拟点赞数 + type: integer + cover: + description: 竖版封面,已拼接图片域名 + type: string + createdAt: + description: 创建时间 + type: string + desc: + description: 简介 + type: string + description: + description: SEO Description + type: string + heatValue: + description: 热度值 + type: integer + horizontalCover: + description: 横版封面,已拼接图片域名 + type: string + id: + description: 短剧id + type: integer + isFeatured: + description: 是否爆剧(true-是,false-否) + 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 + operateType: + description: 运营类型 1-短剧 2-漫剧 + type: integer + playName: + description: 剧名 + type: string + preview: + description: 预告地址 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + showOnBanner: + description: 是否显示在首页banner + type: boolean + slug: + description: slug + type: string + status: + description: '状态: 0下架 1上架' + type: integer + tagIds: + description: 标签ID集合 + items: + type: integer + type: array + tagNames: + description: 标签名称集合 + items: + type: string + type: array + tagSlugs: + description: 标签slug集合 + items: + type: string + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + themeNames: + description: 题材名称集合 + items: + type: string + type: array + themeSlugs: + description: 题材slug集合 + items: + type: string + type: array + title: + description: 标题 + type: string + totalEpisode: + description: 总集数 + type: integer + updateStatus: + description: 更新状态 1-连载中 2-已完结 + type: integer + updatedAt: + description: 修改时间 + type: string + type: object + proto.ConfigInfo: + properties: + actressBackGround: + description: 女优通用背景图 + type: string + advitiesTopicNum: + description: 广告间隔 + type: integer + appAddr: + description: APP下载地址(落地页) + type: string + channelCode: + description: 渠道码 + type: string + customerServiceHost: + 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 + mediaAppUsCdn: + description: 视频CDN(海外app使用) + type: string + newUrlAddr: + description: 最新地址 + type: string + newUrlAddr1: + description: 最新地址(备用地址1) + type: string + newUrlAddr2: + description: 最新地址(备用地址2) + type: string + officialAppAddr: + description: 官网app地址 + 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 + watermarkImage: + 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 + proto.ConfigInfoResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告配置 + config: + allOf: + - $ref: '#/definitions/proto.ConfigInfo' + description: 完整系统配置(与首页等接口的 config 一致,含 potaoGroup 等其余字段) + navPageList: + description: 导航页线路集合 + items: + $ref: '#/definitions/proto.NavPageItem' + type: array + newAddressInfo: + allOf: + - $ref: '#/definitions/proto.NewAddressInfo' + description: 最新地址信息 + reportSdkConfig: + description: 数据上报SDK域名配置 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 全站SEO信息 + seoNavInfo: + allOf: + - $ref: '#/definitions/proto.SeoNavInfo' + description: 导航SEO信息 + type: object + proto.ContactUsAddReq: + properties: + attachments: + description: 附件地址,可存放多张图片或文件地址 + items: + type: string + type: array + content: + description: 内容 + type: string + email: + description: 邮箱 + type: string + title: + description: 标题 + type: string + type: + description: 问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他 + type: integer + required: + - content + - email + - title + - type + type: object + proto.ContactUsAddResp: + properties: + message: + description: 提示信息 + type: string + type: object + proto.FeedbackReq: + properties: + contact: + description: 联系方式,可为空 + type: string + content: + description: 描述内容,最多500字 + type: string + screenshots: + description: 问题截图,可存放多张图片地址 + items: + type: string + type: array + type: + description: 问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他 + type: integer + required: + - content + - type + type: object + proto.FeedbackResp: + properties: + message: + description: 提示信息 + type: string + 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.GetCommentListResp: + properties: + list: + description: 数据 + items: + $ref: '#/definitions/proto.CommentInfo' + type: array + objectId: + description: 对象ID + type: integer + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总数量 + type: integer + type: object + proto.HomeConfigData: + properties: + createdAt: + description: 创建时间 + type: string + desc: + description: 描述 + type: string + id: + description: ID + type: integer + list: + description: 配置对应短剧集合,type=1时返回 + items: + $ref: '#/definitions/proto.CommonData' + type: array + name: + description: 名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + sortType: + description: 排序字段 1-推荐 2-热播 3-最近更新 4-新片预告 5-分类 + type: integer + status: + description: 上下架状态 true-上架 false-下架 + type: boolean + themeList: + description: 题材集合,type=3时返回 + items: + $ref: '#/definitions/proto.HomeConfigThemeData' + type: array + type: + description: 类型 1-短剧 2-视频 3-题材 + type: integer + updatedAt: + description: 修改时间 + type: string + videoList: + description: 视频集合,type=2时返回 + items: + $ref: '#/definitions/proto.VideoItemResp' + type: array + type: object + proto.HomeConfigDetailListResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + desc: + description: 描述 + type: string + homeConfigId: + description: 首页配置ID + type: integer + list: + description: 短剧集合,type=1时返回 + items: + $ref: '#/definitions/proto.CommonData' + type: array + name: + description: 名称 + type: string + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + sortType: + description: 排序字段 1-推荐 2-热播 3-最近更新 4-新片预告 5-分类 + type: integer + themeList: + description: 题材集合,type=3时返回 + items: + $ref: '#/definitions/proto.HomeConfigThemeData' + type: array + totalCount: + description: 总共数量 + type: integer + type: + description: 类型 1-短剧 2-视频 3-题材 + type: integer + videoList: + description: 视频集合,type=2时返回 + items: + $ref: '#/definitions/proto.VideoItemResp' + type: array + type: object + proto.HomeConfigThemeData: + properties: + icon: + description: 题材图标,已拼接图片域名 + type: string + id: + description: 题材ID + type: integer + list: + description: 题材下短剧集合 + items: + $ref: '#/definitions/proto.CommonData' + type: array + name: + description: 题材名称 + type: string + playletCount: + description: 绑定短剧数量 + type: integer + rank: + description: 排序,越大越靠前 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 题材对应页面SEO信息 + slug: + description: 题材slug + type: string + type: object + proto.HomeHtmlData: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + 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.HomePlayletResponse: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + announcements: + description: 启用中的公告,来自独立缓存 + items: + $ref: '#/definitions/proto.AnnouncementItem' + type: array + homeBannerData: + description: 首页banner短剧信息列表 + items: + $ref: '#/definitions/proto.CommonData' + type: array + homeConfigData: + description: 首页配置集合 + items: + $ref: '#/definitions/proto.HomeConfigData' + type: array + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息(短剧列表页SEO) + type: object + proto.HotSearchItem: + properties: + addedTime: + description: 上架shi时间 + type: string + desc: + description: 描述 + type: string + hotValue: + description: 热度值 + type: integer + id: + description: ID + type: integer + images: + description: 封面 + type: string + title: + description: 标题 + type: string + works: + description: 作品数量 + type: integer + type: object + proto.NavPageItem: + properties: + line: + description: 线路序号 + type: integer + url: + description: 线路地址 + type: string + type: object + proto.NewAddressInfo: + properties: + appAddr: + description: APP下载地址(落地页) + type: string + customerServiceHost: + description: 在线客服 + type: string + foreverUrlAddr: + description: 永久海外地址(翻墙访问 永不迷路) + type: string + gmailAddr: + description: 邮箱地址 + type: string + newUrlAddr: + description: 新国内地址 + type: string + newUrlAddr1: + description: 备用地址1 + type: string + newUrlAddr2: + description: 备用地址1 + type: string + officialAppAddr: + description: 官网app地址 + type: string + pdfAddr: + description: 回家的路PDF + type: string + telegram: + description: 商务tg + type: string + tgGroup: + description: tg群 + type: string + twitterAddr: + description: 推特地址 + type: string + type: object + proto.PayMethodInfo: + properties: + type: + description: alipay / wechat / union + type: string + typeName: + description: 支付宝 / 微信 / 银联 + type: string + type: object + proto.PlayletAllListResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + list: + description: 短剧集合 + items: + $ref: '#/definitions/proto.CommonData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息 + totalCount: + description: 总共数量 + type: integer + type: object + proto.PlayletBoughtData: + properties: + anchor: + description: 作者/演员 + type: string + banner: + description: banner图,已拼接图片域名 + type: string + boughtAt: + description: 最近购买时间 + type: string + boughtChapterCount: + description: 已购剧集数量 + type: integer + categoryIds: + description: 分类ID集合 + items: + type: integer + type: array + costCoins: + description: 已消耗金币 + type: integer + countBrowse: + description: 观看量 + type: integer + countCollect: + description: 收藏数 + type: integer + countLike: + description: 点赞数,包含真实点赞数和后台虚拟点赞数 + type: integer + cover: + description: 竖版封面,已拼接图片域名 + type: string + createdAt: + description: 创建时间 + type: string + desc: + description: 简介 + type: string + description: + description: SEO Description + type: string + heatValue: + description: 热度值 + type: integer + horizontalCover: + description: 横版封面,已拼接图片域名 + type: string + id: + description: 短剧id + type: integer + isFeatured: + description: 是否爆剧(true-是,false-否) + type: boolean + isFinished: + description: 当前用户是否已看完 + type: boolean + isNew: + description: 是否上新,本周内上架为true + type: boolean + isRecommend: + description: 是否推荐 true-推荐,false-不推荐 + type: boolean + lastChapterId: + description: 最近观看剧集ID,无观看进度时为0 + type: integer + lastEpisode: + description: 最近观看集数,无观看进度时为0 + type: integer + mediaCreatedAt: + description: 媒资中心创建时间 + type: string + mediaUpdatedAt: + description: 媒资中心更新时间 + type: string + onlineAt: + description: 上架时间 + type: string + operateType: + description: 运营类型 1-短剧 2-漫剧 + type: integer + playName: + description: 剧名 + type: string + preview: + description: 预告地址 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + showOnBanner: + description: 是否显示在首页banner + type: boolean + slug: + description: slug + type: string + status: + description: '状态: 0下架 1上架' + type: integer + tagIds: + description: 标签ID集合 + items: + type: integer + type: array + tagNames: + description: 标签名称集合 + items: + type: string + type: array + tagSlugs: + description: 标签slug集合 + items: + type: string + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + themeNames: + description: 题材名称集合 + items: + type: string + type: array + themeSlugs: + description: 题材slug集合 + items: + type: string + type: array + title: + description: 标题 + type: string + totalEpisode: + description: 总集数 + type: integer + unwatchedCount: + description: 当前用户未看集数,按totalEpisode-watchedCount计算 + type: integer + updateStatus: + description: 更新状态 1-连载中 2-已完结 + type: integer + updatedAt: + description: 修改时间 + type: string + watchedCount: + description: 当前用户已看集数 + type: integer + type: object + proto.PlayletBoughtListResp: + properties: + list: + description: 已购短剧集合 + items: + $ref: '#/definitions/proto.PlayletBoughtData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总已购短剧数量 + type: integer + type: object + proto.PlayletChapterCollectData: + properties: + chapterId: + description: 短剧剧集ID + type: integer + chapterTitle: + description: 剧集标题 + type: string + collectedAt: + description: 收藏时间 + type: string + countBrowse: + description: 剧集观看数 + type: integer + countCollect: + description: 剧集收藏数 + type: integer + currentEpisode: + description: 第几集 + type: integer + isCollect: + description: 收藏状态,收藏列表固定返回1-已收藏 + type: integer + mediaTime: + description: 影片长度(秒) + type: integer + payType: + description: 支付方式 0-免费 1-金币 2-VIP + type: integer + playletCover: + description: 短剧封面,已拼接图片域名 + type: string + playletId: + description: 短剧ID + type: integer + playletTitle: + description: 短剧标题 + type: string + singleCoins: + description: 单集金币 + type: integer + totalEpisode: + description: 短剧总集数 + type: integer + updateStatus: + description: 更新状态 1-连载中 2-已完结 + type: integer + videoUrl: + description: 播放地址 + type: string + type: object + proto.PlayletChapterCollectListResp: + properties: + list: + description: 收藏剧集集合 + items: + $ref: '#/definitions/proto.PlayletChapterCollectData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总收藏数量 + type: integer + type: object + proto.PlayletChapterCollectSetReq: + properties: + chapterId: + description: 短剧剧集ID + type: integer + isCollect: + description: 是否收藏,true-收藏,false-取消收藏 + type: boolean + required: + - chapterId + - isCollect + type: object + proto.PlayletChapterCollectSetResp: + properties: + chapterId: + description: 短剧剧集ID + type: integer + countCollect: + description: 短剧剧集收藏数 + type: integer + isCollect: + description: 收藏状态 1-已收藏 2-未收藏 + type: integer + type: object + proto.PlayletChapterData: + properties: + balance: + description: 当前金币余额 + type: integer + canWatch: + description: 是否可观看 + type: boolean + costCoins: + description: 本次解锁消耗金币 + type: integer + currentEpisode: + description: 第几集 + type: integer + hash: + description: 媒体hash + type: string + height: + description: 高 + type: integer + id: + description: 短剧剧集ID + type: integer + isUnlocked: + description: 当前用户是否已解锁本集 + type: boolean + md5: + description: 媒体md5 + type: string + mediaSize: + description: 资源大小 + type: integer + mediaTime: + description: 影片长度(秒) + type: integer + needUnlock: + description: 是否需要金币解锁 + type: boolean + needVip: + description: 是否需要开通VIP + type: boolean + payType: + description: 支付方式 0-免费 1-金币 2-VIP + type: integer + playletId: + description: 短剧ID + type: integer + ratio: + description: 宽高比 + type: number + singleCoins: + description: 单集金币(支付方式为1-金币时候生效) + type: integer + title: + description: 章节名 + type: string + videoUrl: + description: 视频地址 + type: string + vipValid: + description: 当前用户VIP是否有效 + type: boolean + width: + description: 宽 + type: integer + type: object + proto.PlayletChapterInfoResp: + properties: + balance: + description: 当前金币余额 + type: integer + canWatch: + description: 是否可观看 + type: boolean + costCoins: + description: 本次解锁消耗金币 + type: integer + countBrowse: + description: 短剧剧集观看数 + type: integer + countCollect: + description: 短剧剧集收藏数 + type: integer + countComment: + description: 短剧剧集评论数 + type: integer + countLike: + description: 短剧剧集点赞数 + type: integer + currentEpisode: + description: 第几集 + type: integer + hash: + description: 媒体hash + type: string + height: + description: 高 + type: integer + id: + description: 短剧剧集ID + type: integer + isCollect: + description: 收藏状态 1-已收藏 2-未收藏 + type: integer + isLike: + description: 点赞状态 1-已点赞 2-未点赞 + type: integer + isUnlocked: + description: 当前用户是否已解锁本集 + type: boolean + md5: + description: 媒体md5 + type: string + mediaSize: + description: 资源大小 + type: integer + mediaTime: + description: 影片长度(秒) + type: integer + needUnlock: + description: 是否需要金币解锁 + type: boolean + needVip: + description: 是否需要开通VIP + type: boolean + payType: + description: 支付方式 0-免费 1-金币 2-VIP + type: integer + playletId: + description: 短剧ID + type: integer + ratio: + description: 宽高比 + type: number + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息 + singleCoins: + description: 单集金币(支付方式为1-金币时候生效) + type: integer + title: + description: 章节名 + type: string + videoUrl: + description: 视频地址 + type: string + vipValid: + description: 当前用户VIP是否有效 + type: boolean + width: + description: 宽 + type: integer + type: object + proto.PlayletChapterLikeSetReq: + properties: + chapterId: + description: 短剧剧集ID + type: integer + isLike: + description: 是否点赞,true-点赞,false-取消点赞 + type: boolean + required: + - chapterId + - isLike + type: object + proto.PlayletChapterLikeSetResp: + properties: + chapterId: + description: 短剧剧集ID + type: integer + countLike: + description: 短剧剧集点赞数 + type: integer + isLike: + description: 点赞状态 1-已点赞 2-未点赞 + type: integer + type: object + proto.PlayletChapterListResp: + properties: + list: + description: 短剧剧集集合 + items: + $ref: '#/definitions/proto.PlayletChapterData' + type: array + playletId: + description: 短剧ID + type: integer + type: object + proto.PlayletChapterUnlockReq: + properties: + chapterId: + description: 短剧剧集ID + type: integer + playletId: + description: 短剧ID + type: integer + required: + - chapterId + - playletId + type: object + proto.PlayletChapterUnlockResp: + properties: + balance: + description: 当前金币余额 + type: integer + canWatch: + description: 是否可观看 + type: boolean + changeCoin: + description: 本次实际扣减金币,幂等命中时为0 + type: integer + chapterId: + description: 短剧剧集ID + type: integer + costCoins: + description: 本次解锁消耗金币 + type: integer + playletId: + description: 短剧ID + type: integer + unlocked: + description: 是否已解锁 + type: boolean + type: object + proto.PlayletChapterWatchReportReq: + properties: + chapterId: + description: 短剧剧集ID + type: integer + playletId: + description: 短剧ID,传入时用于校验剧集归属 + type: integer + required: + - chapterId + type: object + proto.PlayletChapterWatchReportResp: + properties: + chapterId: + description: 短剧剧集ID + type: integer + currentEpisode: + description: 当前集数 + type: integer + lastChapterId: + description: 最近观看剧集ID + type: integer + lastEpisode: + description: 最近观看集数 + type: integer + playletId: + description: 短剧ID + type: integer + unwatchedCount: + description: 当前用户未看集数 + type: integer + watchedCount: + description: 当前用户已看集数 + type: integer + type: object + proto.PlayletCollectData: + properties: + collectedAt: + description: 收藏时间 + type: string + countBrowse: + description: 观看量 + type: integer + countCollect: + description: 收藏数 + type: integer + cover: + description: 封面,已拼接图片域名 + type: string + heatValue: + description: 热度值 + type: integer + id: + description: 短剧ID + type: integer + isCollect: + description: 收藏状态,收藏列表固定返回1-已收藏 + type: integer + lastChapterId: + description: 最近观看剧集ID,无观看进度时为0 + type: integer + lastEpisode: + description: 最近观看集数,无观看进度时为0 + type: integer + playName: + description: 剧名 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + tagIds: + description: 标签ID集合 + items: + type: integer + type: array + tagNames: + description: 标签名称集合 + items: + type: string + type: array + tagSlugs: + description: 标签slug集合 + items: + type: string + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + themeNames: + description: 题材名称集合 + items: + type: string + type: array + themeSlugs: + description: 题材slug集合 + items: + type: string + type: array + title: + description: 标题 + type: string + totalEpisode: + description: 总集数 + type: integer + unwatchedCount: + description: 当前用户未看集数,按totalEpisode-watchedCount计算 + type: integer + updateStatus: + description: 更新状态 1-连载中 2-已完结 + type: integer + watchedCount: + description: 当前用户已看集数 + type: integer + type: object + proto.PlayletCollectListResp: + properties: + list: + description: 收藏短剧集合 + items: + $ref: '#/definitions/proto.PlayletCollectData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总收藏数量 + type: integer + type: object + proto.PlayletCollectSetReq: + properties: + id: + description: 短剧ID + type: integer + isCollect: + description: 是否收藏,true-收藏,false-取消收藏 + type: boolean + required: + - id + - isCollect + type: object + proto.PlayletCollectSetResp: + properties: + countCollect: + description: 短剧收藏数 + type: integer + id: + description: 短剧ID + type: integer + isCollect: + description: 收藏状态 1-已收藏 2-未收藏 + type: integer + type: object + proto.PlayletForwardHeatReq: + properties: + id: + description: 短剧ID + type: integer + required: + - id + type: object + proto.PlayletForwardHeatResp: + properties: + heatValue: + description: 加热后的热度值 + type: integer + id: + description: 短剧ID + type: integer + type: object + proto.PlayletGuessLikeListResp: + properties: + list: + description: 猜你喜欢短剧集合 + items: + $ref: '#/definitions/proto.CommonData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总共数量 + type: integer + type: object + proto.PlayletHistoryClearResp: + properties: + deletedCount: + description: 删除记录数量 + type: integer + type: object + proto.PlayletHistoryData: + properties: + anchor: + description: 作者/演员 + type: string + banner: + description: banner图,已拼接图片域名 + type: string + categoryIds: + description: 分类ID集合 + items: + type: integer + type: array + countBrowse: + description: 观看量 + type: integer + countCollect: + description: 收藏数 + type: integer + countLike: + description: 点赞数,包含真实点赞数和后台虚拟点赞数 + type: integer + cover: + description: 竖版封面,已拼接图片域名 + type: string + createdAt: + description: 创建时间 + type: string + desc: + description: 简介 + type: string + description: + description: SEO Description + type: string + heatValue: + description: 热度值 + type: integer + historyAt: + description: 最近观看时间 + type: string + horizontalCover: + description: 横版封面,已拼接图片域名 + type: string + id: + description: 短剧id + type: integer + isFeatured: + description: 是否爆剧(true-是,false-否) + type: boolean + isFinished: + description: 是否已看完 + type: boolean + isNew: + description: 是否上新,本周内上架为true + type: boolean + isRecommend: + description: 是否推荐 true-推荐,false-不推荐 + type: boolean + lastChapterId: + description: 最近观看剧集ID + type: integer + lastEpisode: + description: 最近观看集数 + type: integer + mediaCreatedAt: + description: 媒资中心创建时间 + type: string + mediaUpdatedAt: + description: 媒资中心更新时间 + type: string + onlineAt: + description: 上架时间 + type: string + operateType: + description: 运营类型 1-短剧 2-漫剧 + type: integer + playName: + description: 剧名 + type: string + preview: + description: 预告地址 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + showOnBanner: + description: 是否显示在首页banner + type: boolean + slug: + description: slug + type: string + status: + description: '状态: 0下架 1上架' + type: integer + tagIds: + description: 标签ID集合 + items: + type: integer + type: array + tagNames: + description: 标签名称集合 + items: + type: string + type: array + tagSlugs: + description: 标签slug集合 + items: + type: string + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + themeNames: + description: 题材名称集合 + items: + type: string + type: array + themeSlugs: + description: 题材slug集合 + items: + type: string + type: array + title: + description: 标题 + type: string + totalEpisode: + description: 总集数 + type: integer + unwatchedCount: + description: 当前用户未看集数 + type: integer + updateStatus: + description: 更新状态 1-连载中 2-已完结 + type: integer + updatedAt: + description: 修改时间 + type: string + watchedCount: + description: 当前用户已看集数 + type: integer + type: object + proto.PlayletHistoryDeleteReq: + properties: + playletId: + description: 短剧ID + type: integer + required: + - playletId + type: object + proto.PlayletHistoryDeleteResp: + properties: + deletedCount: + description: 删除记录数量 + type: integer + type: object + proto.PlayletHistoryListResp: + properties: + list: + description: 观看历史集合 + items: + $ref: '#/definitions/proto.PlayletHistoryData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总历史数量 + type: integer + type: object + proto.PlayletHotLikeResp: + properties: + guessLike: + description: 按收藏量最多的15部上架短剧 + items: + $ref: '#/definitions/proto.CommonData' + type: array + hotSearch: + description: 前10个热搜词 + items: + $ref: '#/definitions/proto.PlayletHotSearchItem' + type: array + type: object + proto.PlayletHotRankListResp: + properties: + list: + description: 短剧集合 + items: + $ref: '#/definitions/proto.CommonData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + rankType: + description: 榜单类型 1-热播榜 2-新剧榜 3-追剧榜 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息 + totalCount: + description: 总共数量 + type: integer + type: object + proto.PlayletHotSearchItem: + properties: + count: + description: 搜索次数 + type: integer + keyword: + description: 搜索词 + type: string + type: object + proto.PlayletInfoResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + anchor: + description: 作者/演员 + type: string + banner: + description: banner图,已拼接图片域名 + type: string + categoryIds: + description: 分类ID集合 + items: + type: integer + type: array + countBrowse: + description: 观看量 + type: integer + countCollect: + description: 收藏数 + type: integer + countLike: + description: 点赞数,包含真实点赞数和后台虚拟点赞数 + type: integer + cover: + description: 竖版封面,已拼接图片域名 + type: string + createdAt: + description: 创建时间 + type: string + desc: + description: 简介 + type: string + description: + description: SEO Description + type: string + heatValue: + description: 热度值 + type: integer + horizontalCover: + description: 横版封面,已拼接图片域名 + type: string + id: + description: 短剧id + type: integer + isCollect: + description: 是否收藏(1-已收藏,2-未收藏) + type: integer + isFeatured: + description: 是否爆剧(true-是,false-否) + type: boolean + isLike: + description: 是否点赞(1-已点赞,2-未点赞) + 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 + operateType: + description: 运营类型 1-短剧 2-漫剧 + type: integer + playName: + description: 剧名 + type: string + playletChapterInfo: + description: 短剧章节集合 + items: + $ref: '#/definitions/proto.PlayletChapterData' + type: array + preview: + description: 预告地址 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息 + showOnBanner: + description: 是否显示在首页banner + type: boolean + slug: + description: slug + type: string + status: + description: '状态: 0下架 1上架' + type: integer + tagIds: + description: 标签ID集合 + items: + type: integer + type: array + tagNames: + description: 标签名称集合 + items: + type: string + type: array + tagSlugs: + description: 标签slug集合 + items: + type: string + type: array + themeIds: + description: 题材ID集合 + items: + type: integer + type: array + themeNames: + description: 题材名称集合 + items: + type: string + type: array + themeSlugs: + description: 题材slug集合 + items: + type: string + type: array + title: + description: 标题 + type: string + totalEpisode: + description: 总集数 + type: integer + updateStatus: + description: 更新状态 1-连载中 2-已完结 + type: integer + updatedAt: + description: 修改时间 + type: string + type: object + proto.PlayletLikeSetReq: + properties: + id: + description: 短剧ID + type: integer + isLike: + description: 是否点赞,true-点赞,false-取消点赞 + type: boolean + required: + - id + - isLike + type: object + proto.PlayletLikeSetResp: + properties: + countLike: + description: 短剧点赞数 + type: integer + id: + description: 短剧ID + type: integer + isLike: + description: 点赞状态 1-已点赞 2-未点赞 + type: integer + type: object + proto.PlayletOperateListResp: + properties: + filterType: + description: 筛选类型 1-最近更新 2-当前热播 3-独家原创 + type: integer + list: + description: 短剧集合 + items: + $ref: '#/definitions/proto.CommonData' + type: array + operateType: + description: 运营类型 1-短剧 2-漫剧 + type: integer + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总共数量 + type: integer + type: object + proto.PlayletRankResp: + properties: + list: + description: 短剧集合 + items: + $ref: '#/definitions/proto.CommonData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息(短剧列表页SEO) + totalCount: + description: 总共数量 + type: integer + type: object + proto.PlayletSearchResp: + properties: + everyoneWatching: + description: 大家都在看,搜索无结果时返回 + items: + $ref: '#/definitions/proto.CommonData' + type: array + keyword: + description: 搜索词 + type: string + list: + description: 搜索结果 + items: + $ref: '#/definitions/proto.CommonData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总共数量 + type: integer + type: object + proto.PlayletTagItem: + properties: + id: + description: 标签ID + type: integer + name: + description: 标签名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 标签对应页面 SEO 信息 + slug: + description: slug + type: string + status: + description: 上架状态 + type: boolean + type: object + proto.PlayletTagListResponse: + properties: + list: + description: 标签列表 + items: + $ref: '#/definitions/proto.PlayletTagItem' + type: array + type: object + proto.PlayletTagVideoResp: + properties: + list: + description: 短剧集合 + items: + $ref: '#/definitions/proto.CommonData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息(标签SEO) + totalCount: + description: 总共数量 + type: integer + type: object + proto.PlayletThemeItem: + properties: + icon: + description: 题材图标,已拼接图片域名 + type: string + id: + description: 题材ID + type: integer + isRecommend: + description: 是否推荐 + type: boolean + name: + description: 题材名称 + type: string + playletCount: + description: 该题材绑定的上架短剧数量 + type: integer + rank: + description: 排序,越大越靠前 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 题材对应页面 SEO 信息 + slug: + description: slug + type: string + status: + description: 上架状态 + type: boolean + type: object + proto.PlayletThemeListResponse: + properties: + list: + description: 题材列表 + items: + $ref: '#/definitions/proto.PlayletThemeItem' + type: array + type: object + proto.PlayletTodayUpdateResp: + properties: + list: + description: 短剧集合 + items: + $ref: '#/definitions/proto.CommonData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息(短剧列表页SEO) + totalCount: + description: 总共数量 + type: integer + type: object + proto.PlayletTopicDetailResp: + properties: + covers: + description: 顶部封面集合 + items: + type: string + type: array + desc: + description: 描述 + type: string + icon: + description: 图标 + type: string + id: + description: 专题ID + type: integer + list: + description: 专题短剧列表 + items: + $ref: '#/definitions/proto.CommonData' + type: array + moreTopics: + description: 更多专题 + items: + $ref: '#/definitions/proto.PlayletTopicPageItem' + type: array + name: + description: 专题名称 + type: string + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + playletCount: + description: 上架短剧数量 + type: integer + rank: + description: 排序,越大越靠前 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 专题SEO + slug: + description: slug + type: string + totalCount: + description: 短剧总数 + type: integer + type: object + proto.PlayletTopicPageItem: + properties: + covers: + description: 展示封面集合 + items: + type: string + type: array + desc: + description: 描述 + type: string + icon: + description: 图标 + type: string + id: + description: 专题ID + type: integer + list: + description: 专题下短剧集合 + items: + $ref: '#/definitions/proto.CommonData' + type: array + name: + description: 专题名称 + type: string + playletCount: + description: 上架短剧数量 + type: integer + rank: + description: 排序,越大越靠前 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 专题SEO + slug: + description: slug + type: string + type: object + proto.PlayletTopicPageListResp: + properties: + list: + description: 专题集合 + items: + $ref: '#/definitions/proto.PlayletTopicPageItem' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息 + totalCount: + description: 总共数量 + type: integer + type: object + proto.RechargePackageItem: + properties: + baseCoin: + description: 基础金币数 + type: integer + durationDays: + description: 会员有效天数 + type: integer + durationHours: + description: 会员有效小时数,由durationDays计算 + type: integer + giftCoin: + description: 赠送金币数 + type: integer + id: + description: 套餐ID + type: integer + name: + description: 展示标题 + type: string + originalPrice: + description: 原价,单位分 + type: integer + packageType: + allOf: + - $ref: '#/definitions/rechargepackagemod.PackageType' + description: 套餐类型:1-会员畅享 2-金币充值 + payMethods: + description: 该套餐支持的支付方式 + items: + $ref: '#/definitions/proto.PayMethodInfo' + type: array + price: + description: 售价,单位分 + type: integer + sort: + description: 排序,越小越靠前 + type: integer + subTitle: + description: 展示副标题 + type: string + totalCoin: + description: 到账总金币数,由baseCoin+giftCoin计算 + type: integer + type: object + proto.RechargePackageListResp: + properties: + coinList: + description: 金币充值列表 + items: + $ref: '#/definitions/proto.RechargePackageItem' + type: array + vipList: + description: 会员畅享列表 + items: + $ref: '#/definitions/proto.RechargePackageItem' + type: array + type: object + proto.SeoInfo: + properties: + canonicalUrl: + description: 当前内容的标准URL + type: string + description: + description: 页面描述 + type: string + keywords: + description: 页面关键词 + items: + type: string + type: array + title: + description: 页面标题 + type: string + webSiteName: + description: 网站名称 + type: string + type: object + proto.SeoNavInfo: + properties: + dhDescription: + description: 导航描述 + type: string + dhKeyworld: + description: 关键词 + items: + type: string + type: array + dhSiteName: + description: 导航名称 + type: string + dhTitle: + description: 导航标题 + type: string + type: object + proto.TranslationCategory: + properties: + categoryType: + allOf: + - $ref: '#/definitions/pubdef.TranslationType' + description: 导航类型 + icon: + description: Icon + type: string + id: + type: integer + name: + description: 名称 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + translationList: + items: + $ref: '#/definitions/proto.TranslationInfo' + type: array + urlValue: + description: URl路径 + type: string + type: object + proto.TranslationInfo: + properties: + id: + description: ID + type: integer + isFriendAds: + description: 是否是友链广告位 + type: boolean + name: + description: 名称 + type: string + urlValue: + description: URl路径 + type: string + type: object + proto.UserCenterDataResp: + properties: + boughtChapterCount: + description: 已购剧集数量 + type: integer + chasingCount: + description: 追剧中数量 + type: integer + collectCount: + description: 收藏数量 + type: integer + watchCount: + description: 观看数量 + type: integer + type: object + proto.VideoAllListItemResp: + properties: + addedTime: + description: 上架时间 + type: string + coverImg: + description: 封面 + type: string + creatorId: + description: 作者Id + type: integer + creatorName: + description: 作者名称 + type: string + id: + description: id + type: integer + keyworld: + description: 关键字 + items: + type: string + type: array + mediaType: + description: 类型 1-影集 2-动画 + type: integer + slug: + description: slug + type: string + tags: + description: 标签 + items: + type: integer + type: array + title: + description: 标题 + type: string + topicIds: + description: 题材ID集合 + items: + type: integer + type: array + topicNames: + description: 题材名称集合 + items: + type: string + type: array + topics: + description: 题材集合 + items: + $ref: '#/definitions/proto.VideoTopicInfo' + type: array + videoCount: + description: 视频数量 + type: integer + videoTime: + description: 影片长度 + type: integer + watchTimes: + description: 观看数 + type: integer + type: object + proto.VideoAllListResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + list: + description: 视频集合 + items: + $ref: '#/definitions/proto.VideoAllListItemResp' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息 + totalCount: + description: 总共数量 + type: integer + type: object + proto.VideoCollectData: + properties: + collectedAt: + description: 收藏时间 + type: string + countCollect: + description: 收藏数 + type: integer + countComment: + description: 评论数 + type: integer + countLike: + description: 点赞数 + type: integer + coverImg: + description: 封面 + type: string + id: + description: 视频ID + type: integer + isCollect: + description: 收藏状态,收藏列表固定返回1-已收藏 + type: integer + keyworld: + description: 关键字 + items: + type: string + type: array + slug: + description: slug + type: string + tags: + description: 标签 + items: + type: integer + type: array + title: + description: 标题 + type: string + videoTime: + description: 影片长度 + type: integer + watchTimes: + description: 观看数 + type: integer + type: object + proto.VideoCollectListResp: + properties: + list: + description: 收藏视频集合 + items: + $ref: '#/definitions/proto.VideoCollectData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总收藏数量 + type: integer + type: object + proto.VideoCollectSetReq: + properties: + id: + description: 视频ID + type: integer + isCollect: + description: 是否收藏,true-收藏,false-取消收藏 + type: boolean + required: + - id + - isCollect + type: object + proto.VideoCollectSetResp: + properties: + countCollect: + description: 视频收藏数 + type: integer + id: + description: 视频ID + type: integer + isCollect: + description: 收藏状态 1-已收藏 2-未收藏 + type: integer + type: object + proto.VideoData: + properties: + cover: + description: 视频封面 + type: string + fileId: + description: 媒资库ID + type: string + height: + description: 分辨率高(视频才会存在) + type: integer + preMp4FileNameFs: + description: 预览MP4文件 + type: string + taskId: + description: 视频ID(视频才会存在) + type: string + unencryptedPreMp4: + description: 不加密的预览MP4 + type: string + url: + description: 视频URl + type: string + videoTime: + description: 影片长度 + type: integer + width: + description: 分辨率宽(视频才会存在) + type: integer + type: object + proto.VideoHistoryClearResp: + properties: + deletedCount: + description: 删除记录数量 + type: integer + type: object + proto.VideoHistoryData: + properties: + countCollect: + description: 收藏数 + type: integer + countComment: + description: 评论数 + type: integer + countLike: + description: 点赞数 + type: integer + coverImg: + description: 封面 + type: string + historyAt: + description: 最近观看时间 + type: string + id: + description: 视频ID + type: integer + isCollect: + description: 收藏状态 1-已收藏 2-未收藏 + type: integer + keyworld: + description: 关键字 + items: + type: string + type: array + slug: + description: slug + type: string + tags: + description: 标签 + items: + type: integer + type: array + title: + description: 标题 + type: string + videoTime: + description: 影片长度 + type: integer + watchTimes: + description: 观看数 + type: integer + type: object + proto.VideoHistoryDeleteReq: + properties: + videoId: + description: 视频ID + type: integer + required: + - videoId + type: object + proto.VideoHistoryDeleteResp: + properties: + deletedCount: + description: 删除记录数量 + type: integer + type: object + proto.VideoHistoryListResp: + properties: + list: + description: 观看历史集合 + items: + $ref: '#/definitions/proto.VideoHistoryData' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总历史数量 + type: integer + type: object + proto.VideoInfoResp: + properties: + advitise: + allOf: + - $ref: '#/definitions/proto.AdvitiseData' + description: 广告 + countCollect: + description: 收藏数 + type: integer + countComment: + description: 评论数 + type: integer + countLike: + description: 点赞数 + type: integer + coverImg: + description: 封面 + type: string + id: + description: id + type: integer + isCollect: + description: 当前用户收藏状态 1-已收藏 2-未收藏 + type: integer + isLike: + description: 当前用户点赞状态 1-已点赞 2-未点赞 + type: integer + keyworld: + description: 关键字 + items: + type: string + type: array + seoInfo: + allOf: + - $ref: '#/definitions/proto.SeoInfo' + description: 页面SEO信息 + slug: + description: slug + type: string + tags: + description: 标签 + items: + type: integer + type: array + tagsName: + description: 标签名称 + items: + type: string + type: array + title: + description: 标题 + type: string + topicIds: + description: 题材ID集合 + items: + type: integer + type: array + topics: + description: 题材集合 + items: + $ref: '#/definitions/proto.VideoTopicInfo' + type: array + video: + allOf: + - $ref: '#/definitions/proto.VideoData' + description: 视频 + watchTimes: + description: 观看数(真假之和) + type: integer + type: object + proto.VideoItemResp: + properties: + addedTime: + description: 上架时间 + type: string + coverImg: + description: 封面 + type: string + creatorId: + description: 作者Id + type: integer + creatorName: + description: 作者名称 + type: string + id: + description: id + type: integer + keyworld: + description: 关键字 + items: + type: string + type: array + slug: + description: slug + type: string + tags: + description: 标签 + items: + type: integer + type: array + title: + description: 标题 + type: string + videoTime: + description: 影片长度 + type: integer + watchTimes: + description: 观看数(真假之和) + type: integer + type: object + proto.VideoLikeSetReq: + properties: + id: + description: 视频ID + type: integer + isLike: + description: 是否点赞,true-点赞,false-取消点赞 + type: boolean + required: + - id + - isLike + type: object + proto.VideoLikeSetResp: + properties: + countLike: + description: 视频点赞数 + type: integer + id: + description: 视频ID + type: integer + isLike: + description: 点赞状态 1-已点赞 2-未点赞 + type: integer + type: object + proto.VideoResp: + properties: + list: + description: 视频列表 + items: + $ref: '#/definitions/proto.VideoItemResp' + type: array + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总共数量 + type: integer + type: object + proto.VideoTopicInfo: + properties: + desc: + description: 描述 + type: string + id: + description: Id + type: integer + image: + description: 背景 + type: string + name: + description: 专题名称 + type: string + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + title: + description: APP显示 + type: string + videoCategoryId: + description: 视频分类id + type: integer + works: + description: 作品数 + type: integer + type: object + proto.VideoTopicListResp: + properties: + list: + description: 题材集合 + items: + $ref: '#/definitions/proto.VideoTopicInfo' + type: array + type: object + 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.LoginType: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + LoginTypeAccount: 账号登陆 + LoginTypeDevice: 设备登陆 + LoginTypeEmail: 邮箱登陆 + x-enum-varnames: + - LoginTypeDevice + - LoginTypeAccount + - LoginTypeEmail + 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 + pubdef.UserType: + enum: + - 0 + - 1 + - 0 + type: integer + x-enum-comments: + UserTypeGuest: 游客用户 + UserTypeNormal: 普通用户(已注册) + x-enum-varnames: + - UserTypeGuest + - UserTypeNormal + - UserTypeNone + pubdef.VipOrderStatus: + enum: + - 1 + - 2 + - 3 + type: integer + x-enum-comments: + VipOrderFailed: 支付失败 + VipOrderPending: 待支付 + VipOrderSuccess: 支付成功 + x-enum-varnames: + - VipOrderPending + - VipOrderSuccess + - VipOrderFailed + pubdef.VipType: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + VipTypeMonth: 月卡 + VipTypeNone: 没卡 + VipTypeYear: 年卡 + x-enum-varnames: + - VipTypeNone + - VipTypeMonth + - VipTypeYear + rechargeapi.submitOrderReq: + properties: + packageId: + description: 购买套餐ID(会员畅享) + type: integer + payMode: + description: 支付方式:alipay/wechat + type: string + required: + - packageId + - payMode + type: object + rechargepackagemod.PackageType: + enum: + - 1 + - 2 + type: integer + x-enum-comments: + PackageTypeCoin: 金币充值 + PackageTypeVip: 会员畅享 + x-enum-varnames: + - PackageTypeVip + - PackageTypeCoin + rechargeser.SubmitOrderResp: + properties: + isOpenNewBrowser: + description: 是否需要外部浏览器打开 + type: boolean + payUrl: + description: 支付链接 + type: string + payUrlMode: + description: 链接打开方式 + type: string + tradeNo: + description: 本地订单号(可用于查询) + type: string + type: object + response.BasePageResp: + properties: + list: + description: 集合列表 + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总共数量 + type: integer + type: object + statcenter.AiSellMsg: + properties: + amount: + type: integer + appID: + type: integer + currencyType: + type: string + devType: + type: string + isRepurchase: + type: string + sysType: + type: string + tranCreatedAt: + type: string + tranType: + type: string + uid: + type: integer + uniqID: + type: string + type: object + statcenter.BuyVIPType: + enum: + - 0 + - 1 + type: integer + x-enum-comments: + BuyVIPTypeGold: 金币 + BuyVIPTypeMoney: 现金 + x-enum-varnames: + - BuyVIPTypeMoney + - BuyVIPTypeGold + statcenter.CardSellMsg: + properties: + amount: + type: integer + appID: + type: integer + currencyType: + description: 0金币 1现金 + type: integer + product: + $ref: '#/definitions/statcenter.Product' + sysType: + type: string + tranCreatedAt: + type: string + tranType: + type: string + tranTypeInt: + type: integer + uid: + type: integer + uniqID: + type: string + type: object + statcenter.ConsumeRecordMsg: + properties: + amount: + description: 金币 + type: number + appID: + type: integer + createdAt: + type: string + currencyType: + description: 0金币 1人民币 + type: integer + money: + description: 人民币 元 + type: number + type: + description: 消费类型 + type: integer + uid: + type: integer + uniq: + type: string + type: object + statcenter.Product: + properties: + discountedPrice: + type: integer + id: + type: string + name: + type: string + type: object + statcenter.StatcenterSyncReq: + properties: + job: + description: 同步任务类型 + type: string + maxSize: + description: 最大条数 + type: integer + platformId: + description: 原始平台Id(访问/流水) + type: string + successTime: + description: 游标时间(购买会员、提现、充值) + type: string + userId: + description: 用户(注册) + type: integer + type: object + statcenter.StatcenterSyncResp: + properties: + accessList: + description: 日活记录 + items: + $ref: '#/definitions/statcenter.UserVisitMsg' + type: array + aiSellList: + description: AI销售流水 + items: + $ref: '#/definitions/statcenter.AiSellMsg' + type: array + allRechargeList: + description: 全部支付订单 + items: + $ref: '#/definitions/statcenter.UserRechargeMsg' + type: array + bindingList: + description: 用户绑定记录 + items: + $ref: '#/definitions/statcenter.UserBindingMsg' + type: array + buyVipList: + description: VIP购买记录 + items: + $ref: '#/definitions/statcenter.UserBuyVIPMsg' + type: array + cardSellList: + description: 会员卡销售流水 + items: + $ref: '#/definitions/statcenter.CardSellMsg' + type: array + code: + description: 200正常 其他异常 + type: integer + consumeList: + description: 消费流水 + items: + $ref: '#/definitions/statcenter.ConsumeRecordMsg' + type: array + inviteList: + description: 邀请绑定 + items: + $ref: '#/definitions/statcenter.UserInviteBindMsg' + type: array + job: + description: 类型 + type: string + msg: + description: 错误消息 + type: string + rechargeList: + description: 充值记录 + items: + $ref: '#/definitions/statcenter.UserRechargeMsg' + type: array + registerList: + description: 注册记录 + items: + $ref: '#/definitions/statcenter.UserRegisterMsg' + type: array + visitList: + description: 日活记录(旧字段,兼容) + items: + $ref: '#/definitions/statcenter.UserVisitMsg' + type: array + type: object + statcenter.UserBindingMsg: + properties: + appId: + description: appID + type: integer + bindingTime: + description: 绑定时间 + type: string + devType: + description: 设备类型 + type: string + mobile: + description: 手机号/绑定账号(本项目填邮箱) + type: string + platformId: + description: 原始平台流水Id + type: string + sysType: + description: 操作系统类型 + type: string + userId: + description: 用户ID + type: integer + type: object + statcenter.UserBuyVIPMsg: + properties: + amount: + type: integer + appId: + type: integer + buyVIPType: + $ref: '#/definitions/statcenter.BuyVIPType' + deviceType: + type: string + districtCode: + type: string + isDirect: + type: boolean + money: + type: integer + name: + type: string + oid: + type: string + platformId: + type: string + promCode: + type: string + status: + type: integer + successTime: + type: string + uid: + type: integer + type: object + statcenter.UserInviteBindMsg: + properties: + appId: + type: integer + inviteTime: + type: string + parentPromCode: + type: string + userId: + type: integer + required: + - appId + - inviteTime + - userId + type: object + statcenter.UserRechargeMsg: + properties: + appId: + type: integer + chanShareMod: + type: integer + channelName: + type: string + cid: + type: string + createdAt: + type: string + devType: + type: string + districtCode: + type: string + isNewOrOldUser: + type: boolean + money: + type: integer + oid: + type: string + orderId: + type: string + payMoney: + type: integer + paymentAt: + type: string + platformId: + type: string + productType: + type: integer + rate: + type: string + registerTime: + type: string + status: + type: integer + successAt: + type: string + sysType: + type: string + type: + type: string + userId: + type: integer + required: + - appId + - money + - orderId + - payMoney + - sysType + - userId + type: object + statcenter.UserRegisterMsg: + properties: + appId: + description: appID + type: integer + devType: + description: 设备类型 + type: string + districtCode: + description: 渠道码 + type: string + ip: + description: IP + type: string + isDirect: + description: true:是直推用户 + type: boolean + mobile: + description: 手机号 + type: string + name: + description: 名称 + type: string + platformId: + description: 原始平台流水Id + type: string + promCode: + description: 推广序列 + type: string + promSeqe: + description: 全名代理 + type: string + puc: + description: 应用中心推广 + type: string + registerTime: + description: 注册时间 + type: string + sysType: + description: 操作系统类型 安卓 IOS + type: string + userId: + description: 用户ID + type: integer + required: + - appId + - isDirect + - registerTime + - sysType + - userId + type: object + statcenter.UserVisitMsg: + properties: + appId: + description: appID + type: integer + devID: + description: 设备ID + type: string + devType: + description: 设备型号 + type: string + districtCode: + description: 渠道码 + type: string + ip: + description: IP + type: string + isDirect: + description: true:是直推用户 + type: boolean + platformId: + description: 原始平台流水Id + type: string + registerTime: + description: 用户注册时间 + type: string + sysType: + description: 操作系统类型 安卓 IOS + type: string + userId: + description: 用户ID + type: integer + version: + description: APP版本号 + type: string + visitAt: + description: 访问时间 + 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 + vipordermod.OrderType: + enum: + - 1 + - 2 + type: integer + x-enum-comments: + OrderTypeCoin: 金币购买 + OrderTypeVip: 会员购买 + x-enum-varnames: + - OrderTypeVip + - OrderTypeCoin + vipordermod.VipOrder: + properties: + amount: + description: 支付金额(分) + type: integer + baseCoin: + description: 基础金币数(快照) + type: integer + cardId: + description: ── VIP 专用 ── + type: integer + cardName: + description: 套餐名快照 + type: string + coin: + description: 到账金币数(快照) + type: integer + coinCfgId: + description: ── 金币专用 ── + type: integer + createdAt: + description: 创建时间 + type: string + districtCode: + description: 下单时用户渠道码 + type: string + finishedAt: + description: 完成时间 + type: string + giftCoin: + description: 赠送金币数(快照) + type: integer + id: + description: 唯一ID + type: integer + ipAddr: + description: 客户端 IP + type: string + oid: + description: 第三方订单号 + type: string + orderType: + allOf: + - $ref: '#/definitions/vipordermod.OrderType' + description: 订单类型:1会员 2金币 + payChannel: + description: 支付渠道 + type: string + payMode: + description: 支付方式:overage/alipay/wechat + type: string + payRate: + description: 费率 + type: string + payUrl: + description: 支付链接 + type: string + payUrlMode: + description: 链接打开方式 + type: string + status: + allOf: + - $ref: '#/definitions/pubdef.VipOrderStatus' + description: 订单状态 + sysType: + description: ios/android + type: string + tradeNo: + description: 本地订单号 + type: string + updatedAt: + description: 更新时间 + type: string + userId: + description: 用户ID + type: integer + type: object +host: https://视频站-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/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: + description: 通过 token 获取最新用户信息,包含金币余额、VIP状态等 + produces: + - application/json + responses: + "200": + description: 用户信息 + schema: + $ref: '#/definitions/authser.LoginResp' + "400": + description: 失败 + schema: + type: string + summary: 获取当前用户信息 + tags: + - 用户认证 + /api/app/auth/guest: + post: + consumes: + - application/json + description: 按设备ID创建或找回游客账号;请求头可带 Authorization 复用已有游客会话。需传 X-User-Agent + parameters: + - description: 游客登录参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/authser.GuestLoginReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/authser.LoginResp' + "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: 用户信息通过 token 获取,无需传账号;nickName、avatar、introduction、oldPassword+newPassword + 均为可选,至少提供一项。未设置密码时传 newPassword 可设置密码,已设置密码时修改需传 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: 传入账号、邮箱、明文密码。请求头带游客 Authorization 时升级该游客为普通用户,否则新建用户 + 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/auth/resetPassword: + post: + consumes: + - application/json + description: 通过账号和邮箱双重校验后重置密码,两者必须同时匹配同一用户 + parameters: + - description: 找回密码参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/authapi.resetPasswordReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + summary: 找回密码 + tags: + - 用户认证 + /api/app/coin/buy: + post: + consumes: + - application/json + description: 根据指定金币套餐发起第三方支付,返回支付链接 + parameters: + - description: 购买参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/coinapi.buyCoinReq' + produces: + - application/json + responses: + "200": + description: 支付链接 + schema: + $ref: '#/definitions/coinser.BuyCoinResp' + security: + - ApiKeyAuth: [] + summary: 购买金币 + tags: + - 金币 + /api/app/coin/orders: + get: + parameters: + - description: 页码(默认1) + in: query + name: page + type: integer + - description: 每页数量(默认10) + in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: 订单列表 + schema: + type: object + security: + - ApiKeyAuth: [] + summary: 查询金币购买记录 + tags: + - 金币 + /api/app/coin/trans/export: + get: + description: 导出当前登录用户的金币收入、支出流水CSV;type=0全部 1充值 2消费 3奖励 + parameters: + - description: 类型:0全部 1充值 2消费 3奖励 + in: query + name: type + type: integer + produces: + - text/csv + responses: + "200": + description: CSV文件 + schema: + type: file + security: + - ApiKeyAuth: [] + summary: 导出金币余额明细 -- by 王鸣 + tags: + - 金币 + /api/app/coin/trans/list: + get: + description: 返回当前登录用户的金币收入、支出流水,按交易时间倒序;type=0全部 1充值 2消费 3奖励 + parameters: + - description: 页码(默认1) + in: query + name: page + type: integer + - description: 每页数量(默认10,最大100) + in: query + name: pageSize + type: integer + - description: 类型:0全部 1充值 2消费 3奖励 + in: query + name: type + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/response.BasePageResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CoinTransListItemResp' + type: array + type: object + security: + - ApiKeyAuth: [] + summary: 查询金币余额明细 -- by 旺鸣 + tags: + - 金币 + /api/app/comment/like/set: + post: + consumes: + - application/json + description: 设置当前用户对短剧、短剧剧集或视频评论的点赞状态 + parameters: + - description: 评论点赞设置请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.CommentLikeSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.CommentLikeSetResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 评论点赞/取消点赞 --by 王鸣 + tags: + - 评论APP + /api/app/comment/list: + get: + consumes: + - application/json + description: 分页查询短剧、短剧剧集或视频评论树,一级评论分页返回,二级评论挂在children中,objectType 10-短剧 11-短剧剧集 + 20-视频 + parameters: + - description: 对象ID(短剧id 短剧剧集id 视频id) + in: query + name: objectId + required: true + type: integer + - description: 对象类型 10-短剧 11-短剧剧集 20-视频 + in: query + name: objectType + required: true + type: integer + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 排序 1-热门,按点赞数倒序 2-最新,按创建时间倒序 + in: query + name: sortOrder + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.GetCommentListResp' + - 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: 发布短剧、短剧剧集或视频评论,parentsId为0发布一级评论,大于0发布二级评论,replyCommentId表示实际回复的评论,objectType + 10-短剧 11-短剧剧集 20-视频 + 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/info: + get: + consumes: + - application/json + description: 返回最新地址信息、完整系统配置、广告配置和全站SEO配置 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.ConfigInfoResp' + "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/contactus/add: + post: + consumes: + - application/json + description: 用户提交联系我们表单,问题类型:1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他 + parameters: + - description: 联系我们请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.ContactUsAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.ContactUsAddResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 提交联系我们 -- by 旺鸣 + tags: + - 联系我们相关接口 + /api/app/feedback/add: + post: + consumes: + - application/json + description: 用户提交意见反馈,问题类型,1-功能建议、2-播放问题、3-账号问题、4-其他 + parameters: + - description: 意见反馈请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.FeedbackReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.FeedbackResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 提交意见反馈 -- by 旺鸣 + 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/all/list: + get: + consumes: + - application/json + description: 分页查询已上架短剧,支持题材、更新状态和排序筛选 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 排序 1-最新 2-最热 3-收藏最多 + in: query + name: sortType + type: integer + - description: 题材ID + in: query + name: themeId + type: integer + - description: 题材slug,非空时优先按slug解析题材ID + in: query + name: themeSlug + type: string + - description: 更新状态 0-全部 1-连载中 2-已完结 + in: query + name: updateStatus + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.PlayletAllListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CommonData' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端分页查询全部短剧 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/bought/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: + $ref: '#/definitions/proto.PlayletBoughtListResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端我的已购短剧列表 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/chapter/collect/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: + $ref: '#/definitions/proto.PlayletChapterCollectListResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端我的短剧剧集收藏列表 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/chapter/collect/set: + post: + consumes: + - application/json + description: 设置当前用户短剧剧集收藏状态,收藏数只在状态真实变化时增减 + parameters: + - description: 短剧剧集收藏设置请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.PlayletChapterCollectSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletChapterCollectSetResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端短剧剧集收藏/取消收藏 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/chapter/detail: + get: + description: 根据短剧剧集ID查询播放页当前剧集详情;可传playletId或playletSlug校验剧集归属,slug非空时优先解析短剧ID + parameters: + - description: 短剧剧集ID + in: query + name: chapterId + required: true + type: integer + - description: 短剧ID,传入时用于校验剧集归属 + in: query + name: playletId + type: integer + - description: 短剧slug,非空时优先按slug解析短剧ID + in: query + name: playletSlug + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletChapterInfoResp' + "400": + description: 失败 + schema: + type: string + summary: app端根据ID查询短剧剧集详情 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/chapter/like/set: + post: + consumes: + - application/json + description: 设置当前用户短剧剧集点赞状态,点赞数只在状态真实变化时增减 + parameters: + - description: 短剧剧集点赞设置请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.PlayletChapterLikeSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletChapterLikeSetResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端短剧剧集点赞/取消点赞 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/chapter/list: + get: + description: 根据短剧ID或slug查询右侧选集列表;slug非空时优先按slug解析短剧ID + parameters: + - description: 短剧ID + in: query + name: playletId + type: integer + - description: 短剧slug,非空时优先按slug解析短剧ID + in: query + name: playletSlug + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletChapterListResp' + "400": + description: 失败 + schema: + type: string + summary: app端根据短剧ID查询剧集列表 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/chapter/unlock: + post: + consumes: + - application/json + description: 仅金币付费剧集可调用;接口具备幂等性,已解锁剧集重复调用不会重复扣金币 + parameters: + - description: 短剧剧集金币解锁请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.PlayletChapterUnlockReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletChapterUnlockResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端使用金币逐集解锁短剧剧集 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/chapter/watch/report: + post: + consumes: + - application/json + description: 上报当前用户短剧剧集播放进度,达到已看条件时用于我的收藏未看集数计算 + parameters: + - description: 短剧剧集观看上报请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.PlayletChapterWatchReportReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletChapterWatchReportResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端短剧剧集观看上报 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/collect/list: + get: + consumes: + - application/json + description: 按当前用户收藏时间倒序分页返回短剧收藏列表,并返回已看/未看集数和最近观看位置;watchStatus=0全部 1追剧中 2已看完 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 观看状态 0-全部 1-追剧中 2-已看完 + in: query + name: watchStatus + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletCollectListResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端我的短剧收藏列表 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/collect/set: + post: + consumes: + - application/json + description: 设置当前用户短剧收藏状态,收藏数只在状态真实变化时增减 + parameters: + - description: 短剧收藏设置请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.PlayletCollectSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletCollectSetResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端短剧收藏/取消收藏 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/detail: + get: + description: 根据短剧ID或slug查询已上架短剧详情;slug非空时优先按slug解析短剧ID + 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: + $ref: '#/definitions/proto.PlayletInfoResp' + "400": + description: 失败 + schema: + type: string + summary: app端根据ID查询短剧详情 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/forward/heat: + post: + consumes: + - application/json + description: 请求一次短剧热度值增加5,仅支持已上架短剧 + parameters: + - description: 短剧转发加热度请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.PlayletForwardHeatReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletForwardHeatResp' + "400": + description: 失败 + schema: + type: string + summary: app端短剧转发增加热度 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/guess-like/list: + get: + consumes: + - application/json + description: 根据当前短剧同题材查询猜你喜欢,排除当前短剧 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 当前短剧ID + in: query + name: playletId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.PlayletGuessLikeListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CommonData' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端分页查询猜你喜欢短剧 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/history/clear: + post: + consumes: + - application/json + description: 仅清空观看历史,不影响收藏列表和观看进度 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletHistoryClearResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端清空我的短剧观看历史 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/history/delete: + post: + consumes: + - application/json + description: 删除当前用户指定短剧的观看历史,不影响观看进度、收藏和已购记录 + parameters: + - description: 删除单条观看历史请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.PlayletHistoryDeleteReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletHistoryDeleteResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端删除单条短剧观看历史 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/history/list: + get: + consumes: + - application/json + description: 按当前用户最近观看时间倒序分页返回短剧观看历史;pageSize默认20、最大100 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletHistoryListResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端我的短剧观看历史列表 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/home: + get: + consumes: + - application/json + description: 无请求参数,返回首页banner短剧、公告和SEO信息 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomePlayletResponse' + "400": + description: 失败 + schema: + type: string + summary: app端首页短剧接口 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/home/config/detail/list: + get: + consumes: + - application/json + description: 根据首页配置ID分页查询短剧、视频或题材 + parameters: + - description: 首页配置ID + in: query + name: homeConfigId + required: true + type: integer + - description: type=2时生效,视频类型 0-全部 1-影集 2-动画 + in: query + name: mediaType + type: integer + - description: type=1时生效,运营类型 0-全部 1-短剧 2-漫剧 + in: query + name: operateType + type: integer + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: type=1时生效,题材ID + in: query + name: themeId + type: integer + - description: type=1时生效,题材slug,非空时优先按slug解析题材ID + in: query + name: themeSlug + type: string + - description: type=1/type=2时生效,时间范围 0-综合 1-本周 2-本月 + in: query + name: timeRange + type: integer + - description: type=2时生效,视频题材ID,对应video_topic + in: query + name: topicId + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeConfigDetailListResp' + "400": + description: 失败 + schema: + type: string + summary: app端分页查询首页配置详情列表--by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/hot-like: + get: + consumes: + - application/json + description: 返回缓存的前10个热搜词,以及按收藏量最多的15部猜你喜欢短剧 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletHotLikeResp' + "400": + description: 失败 + schema: + type: string + summary: app端热搜词和猜你喜欢 + tags: + - app短剧相关接口 + /api/app/playlet/hot/rank/list: + get: + consumes: + - application/json + description: 分页查询已上架短剧热榜;rankType=1热播榜 2新剧榜 3追剧榜 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 榜单类型 1-热播榜 2-新剧榜 3-追剧榜 + in: query + name: rankType + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.PlayletHotRankListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CommonData' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端分页查询热榜 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/like/set: + post: + consumes: + - application/json + description: 设置当前用户短剧点赞状态,点赞数只在状态真实变化时增减 + parameters: + - description: 短剧点赞设置请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.PlayletLikeSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletLikeSetResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app端短剧点赞/取消点赞 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/operate/list: + get: + consumes: + - application/json + description: 按运营类型查询已上架短剧;operateType=1短剧 2漫剧;filterType=1最近更新(按上架时间) 2当前热播(按收藏数) + 3独家原创(原创且按推荐排序) + parameters: + - description: 筛选类型 1-最近更新 2-当前热播 3-独家原创 + in: query + name: filterType + type: integer + - description: 运营类型 1-短剧 2-漫剧 + in: query + name: operateType + type: integer + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.PlayletOperateListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CommonData' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端按运营类型分页查询短剧 + tags: + - app短剧相关接口 + /api/app/playlet/rank: + get: + consumes: + - application/json + description: 分页查询已上架短剧排行榜;sortOrder=1按热度值倒序,2按上架时间倒序,3按收藏数倒序 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 排序 1-热播榜,按热度值倒序 2-新剧榜,按上架时间倒序 3-追剧榜,按收藏数倒序 + in: query + name: sortOrder + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.PlayletRankResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CommonData' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: 短剧排行榜 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/search: + get: + consumes: + - application/json + description: 使用ES分页搜索已上架短剧,支持剧名、题材名、简介和标签名检索 + parameters: + - description: 搜索关键词,支持剧名、题材名、简介和标签名 + in: query + name: keyword + 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.PlayletSearchResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CommonData' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端ES分页搜索短剧 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/tag/list: + get: + consumes: + - application/json + description: 根据标签ID或标签slug分页查询已上架短剧;slug非空时优先按slug解析标签ID;sortType=1最新 2最热 3收藏最多 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 排序 1-最新 2-最热 3-收藏最多 + in: query + name: sortType + type: integer + - description: 标签ID,tagSlug为空时必传 + 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.PlayletTagVideoResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CommonData' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端根据标签ID分页查询短剧 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/tag/options: + get: + consumes: + - application/json + description: 无请求参数,不分页,返回已上架短剧标签列表 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletTagListResponse' + "400": + description: 失败 + schema: + type: string + summary: app端短剧标签列表 --by 王鸣 + tags: + - app短剧标签相关接口 + /api/app/playlet/today-update: + 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.PlayletTodayUpdateResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CommonData' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端分页查询今日更新短剧 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/topic/detail: + get: + consumes: + - application/json + description: 按专题ID查询专题信息和专题短剧列表 + parameters: + - description: 专题ID + in: query + name: id + required: true + type: integer + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 排序 1-编辑排序 2-最新 3-最热 + in: query + name: sortType + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.PlayletTopicDetailResp' + - properties: + list: + items: + $ref: '#/definitions/proto.CommonData' + type: array + moreTopics: + items: + $ref: '#/definitions/proto.PlayletTopicPageItem' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端查询专题详情 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/playlet/topic/list: + get: + consumes: + - application/json + description: 分页查询已上架且有短剧数据的专题剧单 + parameters: + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 排序 1-最新 2-最热 + in: query + name: sortType + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.PlayletTopicPageListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.PlayletTopicPageItem' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端分页查询专题剧单 --by 王鸣 + tags: + - app短剧相关接口 + /api/app/recharge/package/list: + get: + description: 返回会员畅享和金币充值两个列表,并附带各套餐可用支付方式 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.RechargePackageListResp' + summary: 查询购买套餐列表 -- by 王鸣 + tags: + - 金币与会员 + /api/app/statcenter/sync: + post: + consumes: + - application/json + description: 按 job 拉取注册/日活/充值/绑定/消费/售卡等增量数据 + parameters: + - description: 参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/statcenter.StatcenterSyncReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/statcenter.StatcenterSyncResp' + "400": + description: 失败 + schema: + type: string + summary: 统计中心增量同步 + tags: + - 数据同步 + /api/app/theme/list: + get: + consumes: + - application/json + description: 无请求参数,不分页,返回已上架题材列表 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletThemeListResponse' + "400": + description: 失败 + schema: + type: string + summary: app端短剧题材列表 --by 王鸣 + tags: + - app短剧题材相关接口 + /api/app/translation/category/list: + get: + consumes: + - application/json + description: 无请求参数,不分页,返回启用的顶部导航分类 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.AppTranslationInfo' + "400": + description: 失败 + schema: + type: string + summary: app端导航分类列表 --by 王鸣 + tags: + - APP导航相关接口 + /api/app/upload/image: + post: + consumes: + - multipart/form-data + description: 将图片转发至存储服务 /media/web/autopublish/image + parameters: + - description: 图片文件 + in: formData + name: file + required: true + type: file + - description: 是否加水印:1加,其他情况不加(头像不加) + in: formData + name: watermark + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/uploadser.ImageUploadResp' + "400": + description: 参数错误 + schema: + type: string + summary: 图片上传 + tags: + - 上传 + /api/app/user/center/data: + get: + consumes: + - application/json + description: 返回当前用户观看、收藏、追剧中、已购剧集统计 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.UserCenterDataResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: app个人中心数据 --by 王鸣 + tags: + - app个人中心相关接口 + /api/app/video/all/list: + get: + consumes: + - application/json + description: 分页查询已上架视频,支持类型、题材和排序筛选 + parameters: + - description: 类型 0-全部 1-影集 2-动画 + in: query + name: mediaType + type: integer + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 排序 1-最新 2-最热 3-收藏最多 + in: query + name: sortType + type: integer + - description: 题材ID,对应video_topic + in: query + name: topicId + type: integer + - description: 题材slug,非空时优先按slug解析题材ID + in: query + name: topicSlug + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.VideoAllListResp' + - properties: + list: + items: + $ref: '#/definitions/proto.VideoAllListItemResp' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: app端分页查询视频列表 --by 王鸣 + tags: + - 视频相关接口 + /api/app/video/collect/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: + $ref: '#/definitions/proto.VideoCollectListResp' + "400": + description: 失败 + schema: + type: string + summary: app端我的视频收藏列表 --by 王鸣 + tags: + - 视频相关接口 + /api/app/video/collect/set: + post: + consumes: + - application/json + description: 设置当前用户视频收藏状态,收藏数只在状态真实变化时增减 + parameters: + - description: 视频收藏设置请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.VideoCollectSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.VideoCollectSetResp' + "400": + description: 失败 + schema: + type: string + summary: app端视频收藏/取消收藏 --by 王鸣 + tags: + - 视频相关接口 + /api/app/video/detail: + 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: + $ref: '#/definitions/proto.VideoInfoResp' + "400": + description: 失败 + schema: + type: string + summary: 查询视频详情 --by 旺鸣 + tags: + - 视频相关接口 + /api/app/video/history/clear: + post: + consumes: + - application/json + description: 清空当前用户全部视频观看历史,不影响收藏和点赞 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.VideoHistoryClearResp' + "400": + description: 失败 + schema: + type: string + summary: app端清空我的视频观看历史 --by 王鸣 + tags: + - 视频相关接口 + /api/app/video/history/delete: + post: + consumes: + - application/json + description: 删除当前用户指定视频观看历史,不影响收藏和点赞 + parameters: + - description: 删除单条视频观看历史请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.VideoHistoryDeleteReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.VideoHistoryDeleteResp' + "400": + description: 失败 + schema: + type: string + summary: app端删除单条视频观看历史 --by 王鸣 + tags: + - 视频相关接口 + /api/app/video/history/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: + $ref: '#/definitions/proto.VideoHistoryListResp' + "400": + description: 失败 + schema: + type: string + summary: app端我的视频观看历史列表 --by 王鸣 + tags: + - 视频相关接口 + /api/app/video/like/set: + post: + consumes: + - application/json + description: 设置当前用户视频点赞状态,点赞数只在状态真实变化时增减 + parameters: + - description: 视频点赞设置请求 + in: body + name: param + required: true + schema: + $ref: '#/definitions/proto.VideoLikeSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.VideoLikeSetResp' + "400": + description: 失败 + schema: + type: string + summary: app端视频点赞/取消点赞 --by 王鸣 + tags: + - 视频相关接口 + /api/app/video/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.VideoResp' + - properties: + list: + items: + $ref: '#/definitions/proto.VideoItemResp' + type: array + type: object + "400": + description: 失败 + schema: + type: string + summary: 普通分页查询视频 --by 旺鸣 + tags: + - 视频相关接口 + /api/app/video/topic/list: + get: + consumes: + - application/json + description: 查询已上架视频题材列表,mediaType=0全部 1影集 2动画 + parameters: + - description: 类型 0-全部 1-影集 2-动画 + in: query + name: mediaType + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.VideoTopicListResp' + "400": + description: 失败 + schema: + type: string + summary: app端查询视频题材列表 --by 王鸣 + tags: + - 视频相关接口 + /api/app/vip/orders: + get: + consumes: + - application/json + parameters: + - description: 页码(默认1) + in: query + name: page + type: integer + - description: 每页数量(默认10) + in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: 订单列表 + schema: + items: + $ref: '#/definitions/vipordermod.VipOrder' + type: array + security: + - ApiKeyAuth: [] + summary: 查询 VIP 充值记录 + tags: + - 充值 + /api/app/vip/pay: + post: + consumes: + - application/json + parameters: + - description: 下单参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/rechargeapi.submitOrderReq' + produces: + - application/json + responses: + "200": + description: 支付链接 + schema: + $ref: '#/definitions/rechargeser.SubmitOrderResp' + security: + - ApiKeyAuth: [] + summary: 创建 VIP 充值订单(第三方支付) + tags: + - 充值 + /richtext/: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: + - 标签搜索 +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..553f3d9 --- /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 https://视频站-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..796e16f --- /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" + "videoweb/pub/pubdef" + + "github.com/gin-gonic/gin" +) + +// gin 验证中间件 + +var ( + // 白名单,不需要验证token的api + whiteList = []string{ + "/swagger", + "/api/app/auth/guest", + "/api/app/auth/register", + "/api/app/auth/login", + "/api/app/auth/resetPassword", + "/api/app/video/all/list", + "/api/app/video/topic/list", + "/api/app/video/list", + "/api/app/video/detail", + "/api/app/playlet/home", + "/api/app/playlet/all/list", + "/api/app/playlet/topic/list", + "/api/app/playlet/topic/detail", + "/api/app/playlet/operate/list", + "/api/app/playlet/rank", + "/api/app/playlet/hot/rank/list", + "/api/app/playlet/today-update", + "/api/app/playlet/tag/options", + "/api/app/playlet/tag/list", + "/api/app/playlet/guess-like/list", + "/api/app/playlet/search", + "/api/app/playlet/hot-like", + "/api/app/playlet/detail", + "/api/app/playlet/chapter/detail", + "/api/app/recharge/package/list", + "/api/app/comment/list", + "/api/app/theme/list", + "/api/app/config/info", + "/api/app/config/footer/info", + "/api/app/config/richtext/list", + "/api/app/applist", + "/api/app/translation/category/list", + "/api/app/navigation/header", + "/api/app/health/ping", + "/api/app/statcenter/sync", + "/h5/m3u8", + "/enkey", + "/newenkey", + "/sitemap.xml", + "/sitemaps", + } +) + +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 + } + + if pubdef.IsCrawlerUser(userId) { + if len(newToken) > 0 { + ctx.Writer.Header().Set("Refresh-Authorization", newToken) + } + ctx.Set(consts.CtxUserID, userId) + ctx.Set(consts.CtxToken, token) + return srvdef.Success + } + + 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..c249400 --- /dev/null +++ b/app/middleware/ipblacklist/ipblacklist.go @@ -0,0 +1,18 @@ +package ipblacklist + +import ( + "errors" + "net/http" + "videoweb/app/service/configser" + "videoweb/pub/common/ipaddr" + + "github.com/gin-gonic/gin" +) + +func IpBlackListMiddleware(ctx *gin.Context) { + ip := ipaddr.ResolveClientIP(ctx) + if configser.CheckIpBlack(ip) { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, errors.New("login limit")) + return + } +} diff --git a/app/middleware/ipstat/ipstat.go b/app/middleware/ipstat/ipstat.go new file mode 100644 index 0000000..7d7892c --- /dev/null +++ b/app/middleware/ipstat/ipstat.go @@ -0,0 +1,78 @@ +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" +) + +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) + + added, err := redis.SAddCount(key, normalized) + if err != nil { + log.Warn("IpStatMiddleware SAdd failed", log.Any("key", key), log.E(err)) + return + } + if added > 0 { + if err := redis.Expire(key, redisconst.IpStatExpire); err != nil { + log.Warn("IpStatMiddleware Expire failed", log.Any("key", key), log.E(err)) + } + } +} + +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..416dce6 --- /dev/null +++ b/app/middleware/monitor/monitor.go @@ -0,0 +1,266 @@ +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/ipaddr" + "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 := ipaddr.ResolveClientIP(ctx) + 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) + if path == "/api/app/feedback/add" { + rawString = "" + } + 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..fae98e6 --- /dev/null +++ b/app/middleware/reptilerecord/reptilerecord.go @@ -0,0 +1,50 @@ +package reptilerecord + +import ( + "strings" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/log" + + "github.com/gin-gonic/gin" +) + +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", ipaddr.ResolveClientIP(c))) + } +} + +// 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..fae3b7e --- /dev/null +++ b/app/middleware/templatecache/cache.go @@ -0,0 +1,72 @@ +package templatecache + +import ( + "bytes" + "github.com/gin-gonic/gin" + "github.com/patrickmn/go-cache" + "net/http" + "time" +) + +var ( + Cache *cache.Cache +) + +func init() { + Cache = cache.New(5*time.Minute, 5*time.Minute) +} + +// 自定义 ResponseWriter 用于捕获 HTML 输出 +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) { + // 只缓存 GET 请求(HEAD 由 _web 层处理,_server 不会收到 HEAD) + if ctx.Request.Method != "GET" { + ctx.Next() + return + } + + // 生成缓存键(包含路径和查询参数) + cacheKey := ctx.Request.URL.String() + var cacheJson []byte + if cacheData, exists := Cache.Get(cacheKey); exists { + if item, ok := cacheData.([]byte); ok { + cacheJson = item + } + } + // 检查缓存 + if cacheJson != nil { + ctx.Data(http.StatusOK, "application/json; charset=utf-8", cacheJson) + ctx.Abort() + return + } + + writer := &templateResponseWriter{ + body: bytes.NewBuffer(nil), + ResponseWriter: ctx.Writer, + } + ctx.Writer = writer + + ctx.Next() + + // 只有成功的响应才缓存 + status := ctx.Writer.Status() + if status == 200 { + cacheJsonData := writer.body.Bytes() + cacheDuration := duration + if cacheDuration == 0 { + cacheDuration = 1 * time.Minute + } + Cache.Set(cacheKey, cacheJsonData, 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..d87f1c3 --- /dev/null +++ b/app/middleware/visit/visit.go @@ -0,0 +1,114 @@ +package visit + +import ( + "context" + "strings" + "time" + "videoweb/app/middleware/reptilerecord" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/useragent" + "videoweb/pub/common/utils" + "videoweb/pub/models/usermod" + "videoweb/pub/models/uservisitlog" + "videoweb/pub/redis" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Visit 用户日活访问中间件:每天首次访问写入 user_visit_log +func Visit(ctx *gin.Context) { + if reptilerecord.IsBot(ctx) { + return + } + val, ok := ctx.Get(consts.CtxUA) + if !ok { + return + } + ua, ok := val.(useragent.UA) + if !ok { + return + } + RecordDailyVisit(ctx, ctx.GetInt64(consts.CtxUserID), ua, ipaddr.ResolveClientIP(ctx)) +} + +// RecordDailyVisit 当天首次访问写入 user_visit_log,游客新建成功后也会立刻记一笔。 +func RecordDailyVisit(ctx context.Context, userId int64, ua useragent.UA, ip string) { + if userId <= 0 { + return + } + now := time.Now() + userVisitLogKey := redisconst.UserVisitLogKey(userId, now) + exist, err := redis.Exists(userVisitLogKey) + if err != nil || exist { + return + } + visitInfo := &uservisitlog.UserVisitLog{ + Id: primitive.NewObjectID(), + UserId: userId, + SysType: ua.SysType, + DevType: ua.DevType, + DevID: ua.DevID, + Version: ua.Ver, + IP: ip, + VisitAt: now, + } + if serr := uservisitlog.InsertOneVisitLog(ctx, visitInfo); serr != nil { + return + } + if serr := userVisitStatus(ctx, userId, ua, visitInfo.IP, visitInfo.VisitAt); serr != nil { + return + } + _ = redis.Set(userVisitLogKey, "1", utils.BeginOfToday().Add(24*time.Hour).Sub(now)+5*time.Minute) +} + +func userVisitStatus(ctx context.Context, uId int64, ua useragent.UA, ip string, visitTime time.Time) *srvdef.SrvErr { + uFilter := primitive.M{usermod.FN.User.Id: uId} + user, serr := usermod.FindOneUser(ctx, uFilter) + if serr != nil { + return serr + } + + todayZero := utils.BeginOfToday() + yesterdayZero := todayZero.AddDate(0, 0, -1) + setDoc := primitive.M{ + usermod.FN.User.AppVer: ua.Ver, + usermod.FN.User.SysType: ua.SysType, + usermod.FN.User.Device: ua.DevType, + usermod.FN.User.LastIp: ip, + usermod.FN.User.LastLoginAt: visitTime, + } + mergeUAExtFields(setDoc, user, ua) + var updateDoc primitive.M + if user.LastLoginAt.Before(todayZero) && user.LastLoginAt.After(yesterdayZero) { + updateDoc = primitive.M{ + "$inc": primitive.M{usermod.FN.User.ConsecutiveLogin: 1}, + "$set": setDoc, + } + } else { + setDoc[usermod.FN.User.ConsecutiveLogin] = int64(1) + updateDoc = primitive.M{"$set": setDoc} + } + return usermod.UpdateOneUser(ctx, uFilter, updateDoc) +} + +func mergeUAExtFields(setDoc primitive.M, user *usermod.User, ua useragent.UA) { + if v := strings.TrimSpace(ua.SessionID); v != "" && v != user.SessionID { + setDoc[usermod.FN.User.SessionID] = v + } + if v := strings.TrimSpace(ua.DeviceBrand); v != "" && v != user.DeviceBrand { + setDoc[usermod.FN.User.DeviceBrand] = v + } + if v := strings.TrimSpace(ua.DeviceModel); v != "" && v != user.DeviceModel { + setDoc[usermod.FN.User.DeviceModel] = v + } + if v := strings.TrimSpace(ua.SystemName); v != "" && v != user.SystemName { + setDoc[usermod.FN.User.SystemName] = v + } + if v := strings.TrimSpace(ua.SystemVersion); v != "" && v != user.SystemVersion { + setDoc[usermod.FN.User.SystemVersion] = v + } +} 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..79dff04 --- /dev/null +++ b/app/router/auth.go @@ -0,0 +1,19 @@ +package router + +import ( + "videoweb/app/api/authapi" + + "github.com/gin-gonic/gin" +) + +func authRouter(r *gin.Engine) { + auth := r.Group("/api/app/auth") + { + auth.POST("/guest", authapi.GuestLogin) + auth.POST("/register", authapi.Register) + auth.POST("/login", authapi.Login) + auth.GET("/getInfo", authapi.GetInfo) + auth.POST("/profile", authapi.UpdateProfile) + auth.POST("/resetPassword", authapi.ResetPassword) + } +} diff --git a/app/router/coin.go b/app/router/coin.go new file mode 100644 index 0000000..07dfb9f --- /dev/null +++ b/app/router/coin.go @@ -0,0 +1,17 @@ +package router + +import ( + "videoweb/app/api/coinapi" + + "github.com/gin-gonic/gin" +) + +func coinRouter(r *gin.Engine) { + coinGroup := r.Group("/api/app/coin") + { + coinGroup.POST("/buy", coinapi.BuyCoin) // 发起购买金币(第三方支付) + coinGroup.GET("/orders", coinapi.GetCoinOrderHistory) // 查询购买记录 + coinGroup.GET("/trans/list", coinapi.GetCoinTransList) // 查询金币余额明细 + coinGroup.GET("/trans/export", coinapi.ExportCoinTrans) // 导出金币余额明细 + } +} diff --git a/app/router/comment.go b/app/router/comment.go new file mode 100644 index 0000000..f0729fc --- /dev/null +++ b/app/router/comment.go @@ -0,0 +1,18 @@ +package router + +import ( + "videoweb/app/api/commentapi" + + "github.com/gin-gonic/gin" +) + +func commentRouter(r *gin.Engine) { + r.POST("/comment/send/", commentapi.SendComment) + group := r.Group("/api/app/comment") + { + group.POST("/send", commentapi.SendComment) + group.POST("/send/", commentapi.SendComment) + group.GET("/list", commentapi.List) + group.POST("/like/set", commentapi.SetLike) + } +} diff --git a/app/router/config.go b/app/router/config.go new file mode 100644 index 0000000..d3b6f7e --- /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/info", configapi.Info) + r.GET("/api/app/config/footer/info", configapi.FooterInfo) + r.GET("/api/app/config/richtext/list", configapi.RichTextList) +} diff --git a/app/router/contactus.go b/app/router/contactus.go new file mode 100644 index 0000000..06a8ba5 --- /dev/null +++ b/app/router/contactus.go @@ -0,0 +1,11 @@ +package router + +import ( + "videoweb/app/api/contactusapi" + + "github.com/gin-gonic/gin" +) + +func contactUsRouter(r *gin.Engine) { + r.POST("/api/app/contactus/add", contactusapi.AddContactUs) +} diff --git a/app/router/feedback.go b/app/router/feedback.go new file mode 100644 index 0000000..31ce427 --- /dev/null +++ b/app/router/feedback.go @@ -0,0 +1,11 @@ +package router + +import ( + "videoweb/app/api/feedbackapi" + + "github.com/gin-gonic/gin" +) + +func feedbackRouter(r *gin.Engine) { + r.POST("/api/app/feedback/add", feedbackapi.AddFeedback) +} diff --git a/app/router/gin.go b/app/router/gin.go new file mode 100644 index 0000000..2b8fbae --- /dev/null +++ b/app/router/gin.go @@ -0,0 +1,134 @@ +package router + +import ( + "fmt" + "net/http" + "strings" + "time" + "videoweb/app/api/home" + "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/middleware/templatecache" + "videoweb/app/middleware/visit" + "videoweb/pub/common" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/log" + "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() + if appg.Conf.TrustedProxies != "" { + proxies := strings.Split(appg.Conf.TrustedProxies, ",") + for i, p := range proxies { + proxies[i] = strings.TrimSpace(p) + } + _ = engine.SetTrustedProxies(proxies) + log.Infof("SetTrustedProxies: %v", proxies) + } else { + _ = engine.SetTrustedProxies([]string{}) + log.Infof("SetTrustedProxies: none (direct TCP IP mode)") + } + engine.HandleMethodNotAllowed = true + engine.MaxMultipartMemory = 60 << 20 + + common.InitGinSecret(appg.Conf.ResKey, consts.RunMod(appg.Conf.RunMod)) + // 注册中间件 + 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", + "sid", + "device_brand", "device-brand", + "device_model", "device-model", + "system_name", "system-name", + "system_version", "system-version", + 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) { + videoRouter(r) + playletRouter(r) + playletThemeRouter(r) + commentRouter(r) + coinRouter(r) + feedbackRouter(r) + contactUsRouter(r) + userCenterRouter(r) + authRouter(r) + rechargeRouter(r) + rechargePackageRouter(r) + uploadRouter(r) + healthRouter(r) + configRouter(r) + appInfoRouter(r) + translationRouter(r) + navigationRouter(r) + statcenterRouter(r) + sitemapRouter(r) + r.GET("/notpage", templatecache.WithCache(time.Minute*1), home.PageNotFound) + +} + +// 设置swag 接口信息 +func setGinSwagger(router *gin.Engine) { + url := ginSwagger.URL(fmt.Sprintf("%s/swagger/doc.json", appg.Conf.Host)) + 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/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..f716af9 --- /dev/null +++ b/app/router/playlet.go @@ -0,0 +1,47 @@ +package router + +import ( + "videoweb/app/api/playletapi" + "videoweb/app/api/playlettagapi" + + "github.com/gin-gonic/gin" +) + +func playletRouter(r *gin.Engine) { + group := r.Group("/api/app/playlet") + { + group.GET("/home", playletapi.HomePlaylet) + group.GET("/home-config/list", playletapi.HomeConfigList) + group.GET("/all/list", playletapi.AllList) + group.GET("/hot/list", playletapi.HotList) + group.GET("/topic/list", playletapi.TopicList) + group.GET("/topic/detail", playletapi.TopicDetail) + group.GET("/operate/list", playletapi.OperateList) + group.GET("/rank", playletapi.Rank) + group.GET("/hot/rank/list", playletapi.HotRankList) + group.GET("/today-update", playletapi.TodayUpdate) + group.GET("/tag/options", playlettagapi.List) + group.GET("/tag/list", playletapi.TagVideoList) + group.GET("/guess-like/list", playletapi.GuessLikeList) + group.GET("/search", playletapi.Search) + group.GET("/search/suggest", playletapi.SearchSuggest) + group.GET("/hot-like", playletapi.HotLike) + group.GET("/like/list", playletapi.LikeList) + group.POST("/like/set", playletapi.SetLike) + group.POST("/collect/set", playletapi.SetCollect) + group.POST("/forward/heat", playletapi.ForwardHeat) + group.GET("/collect/list", playletapi.CollectList) + group.GET("/bought/list", playletapi.BoughtList) + group.GET("/history/list", playletapi.HistoryList) + group.POST("/history/delete", playletapi.DeleteHistory) + group.POST("/history/clear", playletapi.ClearHistory) + group.POST("/chapter/like/set", playletapi.SetChapterLike) + group.POST("/chapter/collect/set", playletapi.SetChapterCollect) + group.GET("/chapter/collect/list", playletapi.ChapterCollectList) + group.POST("/chapter/watch/report", playletapi.ReportChapterWatch) + group.POST("/chapter/unlock", playletapi.UnlockChapter) + group.GET("/detail", playletapi.Detail) + group.GET("/chapter/detail", playletapi.ChapterDetail) + group.GET("/chapter/list", playletapi.ChapterList) + } +} diff --git a/app/router/playlettheme.go b/app/router/playlettheme.go new file mode 100644 index 0000000..c7e3258 --- /dev/null +++ b/app/router/playlettheme.go @@ -0,0 +1,14 @@ +package router + +import ( + "videoweb/app/api/playletthemeapi" + + "github.com/gin-gonic/gin" +) + +func playletThemeRouter(r *gin.Engine) { + group := r.Group("/api/app/theme") + { + group.GET("/list", playletthemeapi.List) + } +} diff --git a/app/router/recharge.go b/app/router/recharge.go new file mode 100644 index 0000000..3025e86 --- /dev/null +++ b/app/router/recharge.go @@ -0,0 +1,15 @@ +package router + +import ( + "videoweb/app/api/rechargeapi" + + "github.com/gin-gonic/gin" +) + +func rechargeRouter(r *gin.Engine) { + vipGroup := r.Group("/api/app/vip") + { + vipGroup.POST("/pay", rechargeapi.SubmitOrder) + vipGroup.GET("/orders", rechargeapi.GetOrderHistory) + } +} diff --git a/app/router/rechargepackage.go b/app/router/rechargepackage.go new file mode 100644 index 0000000..c141868 --- /dev/null +++ b/app/router/rechargepackage.go @@ -0,0 +1,14 @@ +package router + +import ( + "videoweb/app/api/rechargepackageapi" + + "github.com/gin-gonic/gin" +) + +func rechargePackageRouter(r *gin.Engine) { + group := r.Group("/api/app/recharge/package") + { + group.GET("/list", rechargepackageapi.List) + } +} diff --git a/app/router/sitemap.go b/app/router/sitemap.go new file mode 100644 index 0000000..f7898cd --- /dev/null +++ b/app/router/sitemap.go @@ -0,0 +1,14 @@ +package router + +import "github.com/gin-gonic/gin" + +const ( + // 与 sitemap 生成目录一致,进程工作目录为 app/。 + sitemapIndexFile = "../h5/template/pages/sitemap.xml" + sitemapShardDir = "../h5/template/pages/sitemaps" +) + +func sitemapRouter(r *gin.Engine) { + r.StaticFile("/sitemap.xml", sitemapIndexFile) + r.Static("/sitemaps", sitemapShardDir) +} diff --git a/app/router/statcenter.go b/app/router/statcenter.go new file mode 100644 index 0000000..069ea4e --- /dev/null +++ b/app/router/statcenter.go @@ -0,0 +1,14 @@ +package router + +import ( + "videoweb/app/api/statcenterapi" + + "github.com/gin-gonic/gin" +) + +func statcenterRouter(r *gin.Engine) { + group := r.Group("/api/app/statcenter") + { + group.POST("/sync", statcenterapi.StatcenterSyncList) + } +} diff --git a/app/router/translation.go b/app/router/translation.go new file mode 100644 index 0000000..ddd8eb5 --- /dev/null +++ b/app/router/translation.go @@ -0,0 +1,14 @@ +package router + +import ( + "videoweb/app/api/translationapi" + + "github.com/gin-gonic/gin" +) + +func translationRouter(r *gin.Engine) { + group := r.Group("/api/app/translation") + { + group.GET("/category/list", translationapi.CategoryList) + } +} diff --git a/app/router/upload.go b/app/router/upload.go new file mode 100644 index 0000000..98c7c67 --- /dev/null +++ b/app/router/upload.go @@ -0,0 +1,14 @@ +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("/image", uploadapi.UploadImage) + } +} diff --git a/app/router/user_center.go b/app/router/user_center.go new file mode 100644 index 0000000..1722f92 --- /dev/null +++ b/app/router/user_center.go @@ -0,0 +1,14 @@ +package router + +import ( + "videoweb/app/api/usercenterapi" + + "github.com/gin-gonic/gin" +) + +func userCenterRouter(r *gin.Engine) { + group := r.Group("/api/app/user/center") + { + group.GET("/data", usercenterapi.Data) + } +} diff --git a/app/router/video.go b/app/router/video.go new file mode 100644 index 0000000..ca1831e --- /dev/null +++ b/app/router/video.go @@ -0,0 +1,41 @@ +package router + +import ( + "time" + "videoweb/app/api/videoapi" + "videoweb/app/middleware/templatecache" + "videoweb/app/service/videoser" + + "github.com/gin-gonic/gin" + + "videoweb/app/api/home" + _ "videoweb/app/docs" +) + +func videoRouter(r *gin.Engine) { + r.GET("/api/app/video/all/list", videoapi.AllList) + r.GET("/api/app/video/topic/list", videoapi.TopicList) + r.GET("/api/app/video/list", videoapi.List) + r.POST("/api/app/video/like/set", videoapi.SetLike) + r.POST("/api/app/video/collect/set", videoapi.SetCollect) + r.GET("/api/app/video/history/list", videoapi.HistoryList) + r.POST("/api/app/video/history/delete", videoapi.DeleteHistory) + r.POST("/api/app/video/history/clear", videoapi.ClearHistory) + r.GET("/api/app/video/collect/list", videoapi.CollectList) + r.GET("/api/app/video/detail", videoapi.Detail) + + r.GET("/tagcategory/cache", templatecache.WithCache(time.Minute*5), home.GetTagCategoryCache) + + r.GET("/richtext/:id/", templatecache.WithCache(time.Minute*1), home.RichtextApi) // 富文本 + + r.GET("/h5/m3u8/*key", videoser.GetM3U8Ex) + r.GET("/h5/vtt/*key", videoser.GetVTTEx) + r.GET("/enkey/", videoser.Enkey) + r.GET("/newenkey/", videoser.NewEnkey) + r.GET("/lsjenkey", videoser.LsjEnkey) + + r.GET("/urlRedirect/", templatecache.WithCache(time.Minute*1), home.UrlRedirect) + + r.GET("/videoList", home.VideoListPage) + r.GET("/videoDetail", home.VideoDetailPage) +} diff --git a/app/server/server.go b/app/server/server.go new file mode 100644 index 0000000..cb9a922 --- /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/playletcache" + "videoweb/app/service/translationser" + ipstatsync "videoweb/app/skd/ipstat" + "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/payCenter" + "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.BucketVideoAddressV2) + mongos.StartLid() + initRedis() + if err := initElasticSearch(); err != nil { + log.Error("init ES failed", log.E(err)) + os.Exit(1) + } + initData() + ipstatsync.StartSync() + dataReport.Init(dataReport.Config{ + ApiUrl: appg.Conf.DataReport.Url, + RunMod: appg.Conf.RunMod, + }) + + initHttpServer() + //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 + } + payCenter.Init(appg.Conf.NewPayCenter) + // 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() + appser.Start() + playletcache.Start() + seo.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() { + mongos.Shop.DisConnect() + mongos.ShopLog.DisConnect() + redis.Disconnect() + configser.Stop() + 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/appser/appinfo.go b/app/service/appser/appinfo.go new file mode 100644 index 0000000..1d85740 --- /dev/null +++ b/app/service/appser/appinfo.go @@ -0,0 +1,12 @@ +package appser + +import ( + "context" + "videoweb/app/api/proto" + "videoweb/pub/common/srvdef" +) + +// AppCategoryData 查询应用分类列表及当前分类数据 +func AppCategoryData(ctx context.Context, categoryId int64) ([]proto.AppCategoryInfo, proto.AppCategoryInfo, *srvdef.SrvErr) { + return GetAppListCache(), GetCategoryCache(categoryId), nil +} diff --git a/app/service/appser/cache.go b/app/service/appser/cache.go new file mode 100644 index 0000000..d21ae5f --- /dev/null +++ b/app/service/appser/cache.go @@ -0,0 +1,196 @@ +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 + selfAppName := strings.TrimSpace(config.WebSiteName) + + 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 selfAppName != "" && strings.HasPrefix(app.Name, selfAppName) { + 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/appser/cache_test.go b/app/service/appser/cache_test.go new file mode 100644 index 0000000..dc3b553 --- /dev/null +++ b/app/service/appser/cache_test.go @@ -0,0 +1,52 @@ +package appser + +import ( + "strings" + "testing" + + "videoweb/pub/pubdef" +) + +func TestAppendChannelCode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawURL string + channelCode string + wantContain string + wantSkip bool + }{ + { + name: "append custom channel", + rawURL: "https://example.com/app", + channelCode: "custom", + wantContain: "dc=custom", + }, + { + name: "fallback default channel", + rawURL: "https://example.com/app", + channelCode: "", + wantContain: "dc=" + pubdef.DefaultChannelCode, + }, + { + name: "skip existing dc", + rawURL: "https://example.com/app?dc=exists", + channelCode: "custom", + wantContain: "dc=exists", + wantSkip: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := appendChannelCode(tt.rawURL, tt.channelCode) + if !strings.Contains(got, tt.wantContain) { + t.Fatalf("appendChannelCode() = %q, want contain %q", got, tt.wantContain) + } + if tt.wantSkip && strings.Contains(got, "dc=custom") { + t.Fatalf("appendChannelCode() should keep existing dc: %q", got) + } + }) + } +} diff --git a/app/service/authser/auth.go b/app/service/authser/auth.go new file mode 100644 index 0000000..368968b --- /dev/null +++ b/app/service/authser/auth.go @@ -0,0 +1,486 @@ +package authser + +import ( + "context" + "strings" + "time" + "videoweb/app/appg" + "videoweb/app/service/reportser" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/useragent" + "videoweb/pub/common/utils" + "videoweb/pub/models/usermod" + "videoweb/pub/mongos" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "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 := strings.ToUpper(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 strings.ToUpper(utils.GetRandomLetters(12)) +} + +// LoginResp 登录/注册成功后的响应数据 +type LoginResp struct { + Token string `json:"token"` + UserId int64 `json:"userId"` + AccountNo string `json:"accountNo"` + Email string `json:"email"` + NickName string `json:"nickName"` + Introduction string `json:"introduction"` // 简介 + VipType pubdef.VipType `json:"vipType"` + CurrentVipExpire time.Time `json:"currentVipExpire"` // VIP 过期时间,客户端需结合 vipType 判断是否真正有效 + ShowCardId int64 `json:"showCardId"` // 最新会员卡id + ShowCardName string `json:"showCardName"` // 最新会员卡名称 + Status pubdef.UserStatus `json:"status"` + Avatar string `json:"avatar"` // 头像 + InviteCode string `json:"inviteCode"` // 邀请码 + Type pubdef.UserType `json:"type"` // 身份类型:0游客 1普通用户 + LoginType pubdef.LoginType `json:"loginType"` // 登陆方式 + IsRegister bool `json:"isRegister"` // 是否已注册 + IsNewUser bool `json:"isNewUser"` // 本次是否新建游客 + PrivacyMode bool `json:"privacyMode"` // 隐私模式 + HasPassword bool `json:"hasPassword"` // 是否已设置密码 + Score int64 `json:"score"` // 积分 + Recharge int64 `json:"recharge"` // 金币余额 +} + +// 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 +} + +// generateCrawlerToken 签发爬虫观看 JWT,不写入 Redis,避免占用真实用户槽位。 +func generateCrawlerToken() (string, *srvdef.SrvErr) { + token, err := utils.GenerateUserToken(utils.UserClaims{ + UserID: pubdef.CrawlerUserId, + Expire: consts.UserJwtTokenExpire, + }, appg.Conf.JwtKey) + if err != nil { + return "", srvdef.New(srvdef.GenerateTokenFailed, srvdef.GenerateTokenFailed.Msg(), "") + } + return token, nil +} + +// CrawlerLoginResp 爬虫虚拟游客资料,不对应任何数据库用户。 +func CrawlerLoginResp(token string) *LoginResp { + return &LoginResp{ + Token: token, + UserId: pubdef.CrawlerUserId, + NickName: "游客", + Status: pubdef.UserStatusNormal, + Type: pubdef.UserTypeGuest, + LoginType: pubdef.LoginTypeDevice, + IsRegister: false, + IsNewUser: false, + } +} + +// Register 用户注册 +// guestUserId:请求头 Authorization 解析出的用户ID;若为游客则升级该用户,否则新建 +// agentInviteCode:邀请人的邀请码,可为空 +func Register(ctx context.Context, guestUserId int64, accountNo, email, password, agentInviteCode string, ua useragent.UA, ip, channel string) (*LoginResp, *srvdef.SrvErr) { + accountNo = strings.TrimSpace(accountNo) + email = strings.TrimSpace(email) + password = strings.TrimSpace(password) + + // 检查账号是否已存在 + exist, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.AccountNo: accountNo}) + if exist != nil { + return nil, srvdef.New(srvdef.ErrDbInputExist, "账号已存在", "") + } + // 填写邮箱时检查是否已存在 + if email != "" { + exist, _ = usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Email: email}) + if exist != nil { + return nil, srvdef.New(srvdef.ErrDbInputExist, "邮箱已被注册", "") + } + } + + hashed, err := hashPassword(password) + if err != nil { + return nil, srvdef.New(srvdef.Failure, "密码处理失败", "") + } + + now := time.Now() + + // 请求头带游客 token:升级当前游客为普通用户 + if guestUserId > 0 { + guest, serr := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: guestUserId}) + if serr == nil && isGuestUser(guest) { + setDoc := bson.M{ + usermod.FN.User.AccountNo: accountNo, + usermod.FN.User.Email: email, + usermod.FN.User.Password: hashed, + usermod.FN.User.Type: pubdef.UserTypeNormal, + usermod.FN.User.IsRegister: true, + usermod.FN.User.LoginType: pubdef.LoginTypeAccount, + usermod.FN.User.RegisterTime: now, + usermod.FN.User.LastLoginAt: now, + usermod.FN.User.UpdatedAt: now, + usermod.FN.User.LastIp: ip, + } + if agentInviteCode != "" && guest.AgentInviteCode == "" { + setDoc[usermod.FN.User.AgentInviteCode] = agentInviteCode + setDoc[usermod.FN.User.IsDirect] = false + guest.IsDirect = false + } + if v := strings.TrimSpace(ua.DevID); v != "" { + setDoc[usermod.FN.User.DevID] = v + } + if v := strings.TrimSpace(ua.DevType); v != "" { + setDoc[usermod.FN.User.Device] = v + } + if v := strings.TrimSpace(ua.SysType); v != "" { + setDoc[usermod.FN.User.SysType] = v + } + if v := strings.TrimSpace(ua.Ver); v != "" { + setDoc[usermod.FN.User.AppVer] = v + } + mergeUAExtFieldsToSet(setDoc, guest, ua) + if serr = usermod.UpdateOneUser(ctx, bson.M{usermod.FN.User.Id: guest.Id}, bson.M{"$set": setDoc}); serr != nil { + return nil, serr + } + if agentInviteCode != "" && guest.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: now}, + }, + ) + } + guest.AccountNo = accountNo + guest.Email = email + guest.Type = pubdef.UserTypeNormal + guest.IsRegister = true + guest.LoginType = pubdef.LoginTypeAccount + guest.RegisterTime = now + token, serr := generateAndSaveToken(guest.Id) + if serr != nil { + return nil, serr + } + reportser.ReportUserRegister(guest, ua, ip, "username") + reportser.ReportUserLogin(guest, ua, ip, pubdef.LoginTypeAccount) + return toLoginResp(ctx, guest, token, true), nil + } + } + + user := &usermod.User{ + Id: mongos.Lid.User.Get(), + AccountNo: accountNo, + Email: email, + Password: hashed, + NickName: appg.GenerateNickname(), + Avatar: randomAvatar(), + InviteCode: generateUniqueInviteCode(ctx), + AgentInviteCode: agentInviteCode, + Type: pubdef.UserTypeNormal, + Status: pubdef.UserStatusNormal, + IsRegister: true, + LoginType: pubdef.LoginTypeAccount, + RegIp: ip, + LastIp: ip, + DevID: ua.DevID, + Device: ua.DevType, + SysType: ua.SysType, + AppVer: ua.Ver, + OriVer: ua.Ver, + SessionID: ua.SessionID, + DeviceBrand: ua.DeviceBrand, + DeviceModel: ua.DeviceModel, + SystemName: ua.SystemName, + SystemVersion: ua.SystemVersion, + DistrictCode: normalizeChannel(channel), + IsDirect: resolveUserDistrict(channel, agentInviteCode, ip, ua.SysType).IsDirect, + LastLoginAt: now, + RegisterTime: now, + CreatedAt: now, + UpdatedAt: now, + } + + if serr := usermod.InsertOneUser(ctx, user); serr != nil { + return nil, serr + } + + // 若填写了邀请码,给对应用户的 invites +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 + } + reportser.ReportUserRegister(user, ua, ip, "username") + reportser.ReportUserLogin(user, ua, ip, pubdef.LoginTypeAccount) + return toLoginResp(ctx, user, token, false), nil +} + +// Login 用户登录(账号或邮箱均可) +func Login(ctx context.Context, account, password string, ua useragent.UA, ip, channel string) (*LoginResp, *srvdef.SrvErr) { + // 账号或邮箱均可登录 + user, _ := usermod.FindOneUser(ctx, bson.M{ + "$or": []bson.M{ + {usermod.FN.User.AccountNo: account}, + {usermod.FN.User.Email: 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 + } + + now := time.Now() + setDoc := bson.M{ + usermod.FN.User.LastLoginAt: now, + usermod.FN.User.UpdatedAt: now, + } + if ip != "" { + setDoc[usermod.FN.User.LastIp] = ip + } + if v := strings.TrimSpace(ua.DevID); v != "" { + setDoc[usermod.FN.User.DevID] = v + } + if v := strings.TrimSpace(ua.DevType); v != "" { + setDoc[usermod.FN.User.Device] = v + } + if v := strings.TrimSpace(ua.SysType); v != "" { + setDoc[usermod.FN.User.SysType] = v + } + if v := strings.TrimSpace(ua.Ver); v != "" { + setDoc[usermod.FN.User.AppVer] = v + } + // 已有账号的历史用户统一修正为普通用户 + if strings.TrimSpace(user.AccountNo) != "" && user.Type != pubdef.UserTypeNormal { + setDoc[usermod.FN.User.Type] = pubdef.UserTypeNormal + setDoc[usermod.FN.User.IsRegister] = true + user.Type = pubdef.UserTypeNormal + user.IsRegister = true + } + mergeUAExtFieldsToSet(setDoc, user, ua) + _ = usermod.UpdateOneUser(ctx, + bson.M{usermod.FN.User.Id: user.Id}, + bson.M{"$set": setDoc}, + ) + reportser.ReportUserLogin(user, ua, ip, pubdef.LoginTypeAccount) + return toLoginResp(ctx, user, token, true), nil +} + +// GetInfo 获取当前登录用户的最新信息(不刷新 token) +func GetInfo(ctx context.Context, userId int64, ua useragent.UA) (*LoginResp, *srvdef.SrvErr) { + if pubdef.IsCrawlerUser(userId) { + return CrawlerLoginResp(""), nil + } + user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) + if user == nil { + return nil, srvdef.New(srvdef.ErrUserNotExsit, srvdef.ErrUserNotExsit.Msg(), "") + } + now := time.Now() + setDoc := bson.M{} + if v := strings.TrimSpace(ua.DevID); v != "" && v != user.DevID { + setDoc[usermod.FN.User.DevID] = v + } + if v := strings.TrimSpace(ua.DevType); v != "" && v != user.Device { + setDoc[usermod.FN.User.Device] = v + } + if v := strings.TrimSpace(ua.SysType); v != "" && v != user.SysType { + setDoc[usermod.FN.User.SysType] = v + } + if v := strings.TrimSpace(ua.Ver); v != "" && v != user.AppVer { + setDoc[usermod.FN.User.AppVer] = v + } + if strings.TrimSpace(user.AccountNo) != "" && user.Type != pubdef.UserTypeNormal { + setDoc[usermod.FN.User.Type] = pubdef.UserTypeNormal + setDoc[usermod.FN.User.IsRegister] = true + user.Type = pubdef.UserTypeNormal + user.IsRegister = true + } + mergeUAExtFieldsToSet(setDoc, user, ua) + if len(setDoc) > 0 { + setDoc[usermod.FN.User.UpdatedAt] = now + _ = usermod.UpdateOneUser(ctx, bson.M{usermod.FN.User.Id: user.Id}, bson.M{"$set": setDoc}) + } + return toLoginResp(ctx, user, "", true), nil +} + +func mergeUAExtFieldsToSet(setDoc bson.M, user *usermod.User, ua useragent.UA) { + if user == nil { + return + } + if v := strings.TrimSpace(ua.SessionID); v != "" && v != user.SessionID { + setDoc[usermod.FN.User.SessionID] = v + } + if v := strings.TrimSpace(ua.DeviceBrand); v != "" && v != user.DeviceBrand { + setDoc[usermod.FN.User.DeviceBrand] = v + } + if v := strings.TrimSpace(ua.DeviceModel); v != "" && v != user.DeviceModel { + setDoc[usermod.FN.User.DeviceModel] = v + } + if v := strings.TrimSpace(ua.SystemName); v != "" && v != user.SystemName { + setDoc[usermod.FN.User.SystemName] = v + } + if v := strings.TrimSpace(ua.SystemVersion); v != "" && v != user.SystemVersion { + setDoc[usermod.FN.User.SystemVersion] = v + } +} + +// UpdateProfileReq 修改用户资料的参数(所有字段均为可选) +type UpdateProfileReq struct { + UserId int64 // 用户ID(从 token 中获取) + NickName *string // 新昵称(可选) + Avatar *string // 新头像 URL(可选) + Introduction *string // 简介(可选) + PrivacyMode *bool // 隐私模式:true-开启,false-关闭(可选) + OldPassword *string // 旧密码明文(已设置密码时修改必传) + NewPassword *string // 新密码明文(设置或修改密码时必传) +} + +// UpdateProfile 修改用户资料:支持修改头像、简介、密码,或组合修改 +func UpdateProfile(ctx context.Context, req *UpdateProfileReq) *srvdef.SrvErr { + user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: req.UserId}) + if user == nil { + return srvdef.New(srvdef.ErrUserNotExsit, srvdef.ErrUserNotExsit.Msg(), "") + } + + setDoc := bson.M{usermod.FN.User.UpdatedAt: time.Now()} + + if req.NickName != nil && *req.NickName != "" { + setDoc[usermod.FN.User.NickName] = *req.NickName + } + if req.Avatar != nil && *req.Avatar != "" { + setDoc[usermod.FN.User.Avatar] = *req.Avatar + } + if req.Introduction != nil { + setDoc[usermod.FN.User.Introduction] = strings.TrimSpace(*req.Introduction) + } + if req.PrivacyMode != nil { + setDoc[usermod.FN.User.PrivacyMode] = *req.PrivacyMode + } + + // 设置或修改密码 + if req.OldPassword != nil || req.NewPassword != nil { + if req.NewPassword == nil || strings.TrimSpace(*req.NewPassword) == "" { + return srvdef.New(srvdef.ErrParamError, "新密码不能为空", "") + } + if strings.TrimSpace(user.Password) != "" { + if req.OldPassword == nil || strings.TrimSpace(*req.OldPassword) == "" { + return srvdef.New(srvdef.ErrParamError, "修改密码时旧密码和新密码均需提供", "") + } + if !checkPassword(*req.OldPassword, user.Password) { + return srvdef.New(srvdef.AccountErrPasswordError, "旧密码错误", "") + } + } else if req.OldPassword != nil && strings.TrimSpace(*req.OldPassword) != "" { + return srvdef.New(srvdef.ErrParamError, "修改密码时旧密码和新密码均需提供", "") + } + 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}, + ) +} + +// ResetPassword 通过账号+邮箱双重校验后重置密码 +func ResetPassword(ctx context.Context, accountNo, email, newPassword string) *srvdef.SrvErr { + user, _ := usermod.FindOneUser(ctx, bson.M{ + usermod.FN.User.AccountNo: accountNo, + usermod.FN.User.Email: email, + }) + if user == nil { + return srvdef.New(srvdef.ErrUserNotExsit, "账号或邮箱不匹配", "") + } + + hashed, err := hashPassword(newPassword) + if err != nil { + return srvdef.New(srvdef.Failure, "密码处理失败", "") + } + + return usermod.UpdateOneUser(ctx, + bson.M{usermod.FN.User.Id: user.Id}, + bson.M{"$set": bson.M{ + usermod.FN.User.Password: hashed, + usermod.FN.User.UpdatedAt: time.Now(), + }}, + ) +} diff --git a/app/service/authser/district.go b/app/service/authser/district.go new file mode 100644 index 0000000..c4a4977 --- /dev/null +++ b/app/service/authser/district.go @@ -0,0 +1,68 @@ +package authser + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "videoweb/app/appg" + "videoweb/pub/common/log" + "videoweb/pub/common/statcenter" + "videoweb/pub/common/utils" +) + +// UserDistrictInfo 统计中心返回的渠道/直推信息 +type UserDistrictInfo struct { + IsDirect bool `json:"isDirect"` + DistrictCode string `json:"districtCode"` + ParentPromCode string `json:"parentPromCode"` + ParentId uint64 `json:"parentId"` + ParentInviteCount int64 `json:"parentInviteCount"` +} + +type userDistrictReq struct { + AppId int32 `json:"appId"` + DistrictCode string `json:"dc"` + PromSeqe string `json:"pc"` + IP string `json:"ip"` + SysType string `json:"sysType"` +} + +// resolveUserDistrict 有邀请码视为非直推;否则向统计中心查询补量/直推。 +func resolveUserDistrict(channel, inviteCode, ip, sysType string) UserDistrictInfo { + inviteCode = strings.TrimSpace(inviteCode) + if inviteCode != "" { + return UserDistrictInfo{IsDirect: false, ParentPromCode: inviteCode} + } + return GetUserDistictInfo(channel, inviteCode, ip, sysType) +} + +// GetUserDistictInfo 查询统计中心用户渠道信息,对齐 ksx_server。 +func GetUserDistictInfo(dc, pc, ip, sysType string) (districtInfo UserDistrictInfo) { + base := strings.TrimSpace(appg.Conf.NewProductUrl) + if base == "" { + return + } + request := userDistrictReq{ + AppId: statcenter.STAT_APPID, + SysType: sysType, + DistrictCode: dc, + PromSeqe: pc, + IP: ip, + } + bodyStr, err := json.Marshal(request) + if err != nil { + log.Info(fmt.Sprintf("GetUserDistictInfo json.Marshal is fail error:%+v/data:%+v", err, request)) + return + } + url := strings.TrimRight(base, "/") + "/api/stat/userInvite/district" + code, err := utils.POSTWithJResp(&districtInfo, url, nil, bodyStr) + if err != nil { + log.Error("GetUserDistictInfo POSTWithJResp", log.Any("url", url), log.E(err)) + return + } + if code != http.StatusOK { + log.Error("GetUserDistictInfo response status", log.Any("code", code)) + } + return +} diff --git a/app/service/authser/guest.go b/app/service/authser/guest.go new file mode 100644 index 0000000..deb03b1 --- /dev/null +++ b/app/service/authser/guest.go @@ -0,0 +1,262 @@ +package authser + +import ( + "context" + "fmt" + "strings" + "time" + "videoweb/app/appg" + "videoweb/app/middleware/reptilerecord" + "videoweb/app/middleware/visit" + "videoweb/app/service/configser" + "videoweb/app/service/reportser" + "videoweb/pub/common/log" + "videoweb/pub/common/netutil" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/useragent" + "videoweb/pub/models/usermod" + "videoweb/pub/mongos" + "videoweb/pub/pubdef" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) + +// GuestLoginReq 游客登录请求 +type GuestLoginReq struct { + DevID string `json:"devID"` // 设备唯一标示(必填) + Mac string `json:"mac"` // 设备 Mac(可选) + DeviceId string `json:"deviceId"` // 备用设备ID(可选,写入 DevV2) + Channel string `json:"channel"` // 渠道码(可选,用户无商区码时写入 DistrictCode) +} + +// isGuestUser 是否为可升级的游客身份 +func isGuestUser(user *usermod.User) bool { + if user == nil { + return false + } + if user.Type == pubdef.UserTypeNormal { + return false + } + return !user.IsRegister && strings.TrimSpace(user.AccountNo) == "" +} + +// GuestLogin 游客登录:按 token / DevID / Mac / DeviceId 查找,不存在则创建游客 +func GuestLogin(ctx context.Context, userId int64, ip string, req *GuestLoginReq, ua useragent.UA) (*LoginResp, *srvdef.SrvErr) { + if isBotRequest(ctx) { + return crawlerGuestLogin() + } + if req == nil || strings.TrimSpace(req.DevID) == "" { + return nil, srvdef.New(srvdef.ErrParamError, "devID不能为空", "") + } + ua.DevID = strings.TrimSpace(req.DevID) + + var user *usermod.User + var serr *srvdef.SrvErr + reuseToken := false + + if userId > 0 { + user, serr = usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) + if serr != nil { + if serr.Code != srvdef.ErrUserNotExsit { + return nil, serr + } + userId = 0 + user = nil + } else { + reuseToken = true + } + } + + isNewUser := false + if userId == 0 { + user, serr = findGuestUser(ctx, req) + if serr != nil { + if serr.Code != srvdef.ErrUserNotExsit { + return nil, serr + } + user, serr = createGuestUser(ctx, ua, req, ip) + if serr != nil { + return nil, serr + } + isNewUser = true + } + } + + if user.Status == pubdef.UserStatusDisable { + return nil, srvdef.New(srvdef.AccountErrAccessForbid, srvdef.AccountErrAccessForbid.Msg(), "") + } + + now := time.Now() + setDoc := bson.M{ + usermod.FN.User.LastLoginAt: now, + usermod.FN.User.UpdatedAt: now, + usermod.FN.User.LastIp: ip, + usermod.FN.User.LoginType: pubdef.LoginTypeDevice, + } + if v := strings.TrimSpace(ua.DevID); v != "" { + setDoc[usermod.FN.User.DevID] = v + } + if v := strings.TrimSpace(ua.DevType); v != "" { + setDoc[usermod.FN.User.Device] = v + } + if v := strings.TrimSpace(ua.SysType); v != "" { + setDoc[usermod.FN.User.SysType] = v + } + if v := strings.TrimSpace(ua.Ver); v != "" { + setDoc[usermod.FN.User.AppVer] = v + } + if v := strings.TrimSpace(req.Mac); v != "" { + setDoc[usermod.FN.User.Mac] = v + } + if v := strings.TrimSpace(req.DeviceId); v != "" { + setDoc[usermod.FN.User.DevV2] = v + } + // 历史用户 Type 为空/旧值时,未注册则归为游客 + if isGuestUser(user) && user.Type != pubdef.UserTypeGuest { + setDoc[usermod.FN.User.Type] = pubdef.UserTypeGuest + user.Type = pubdef.UserTypeGuest + } + mergeUAExtFieldsToSet(setDoc, user, ua) + _ = usermod.UpdateOneUser(ctx, bson.M{usermod.FN.User.Id: user.Id}, bson.M{"$set": setDoc}) + + var token string + if reuseToken { + if old, err := tokener.Get(user.Id); err == nil && len(old) > 0 { + token = string(old) + } + } + if token == "" { + token, serr = generateAndSaveToken(user.Id) + if serr != nil { + return nil, serr + } + } + + resp := toLoginResp(ctx, user, token, true) + resp.IsNewUser = isNewUser + if isNewUser { + visit.RecordDailyVisit(ctx, user.Id, ua, ip) + reportser.ReportUserRegister(user, ua, ip, "deviceid") + } + reportser.ReportUserLogin(user, ua, ip, pubdef.LoginTypeDevice) + return resp, nil +} + +func findGuestUser(ctx context.Context, req *GuestLoginReq) (*usermod.User, *srvdef.SrvErr) { + user, serr := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.DevID: req.DevID}) + if serr == nil { + return user, nil + } + if serr.Code != srvdef.ErrUserNotExsit { + return nil, serr + } + + mac := strings.TrimSpace(req.Mac) + if mac != "" && !strings.Contains(mac, "00:00:00:00") { + user, serr = usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Mac: mac}) + if serr == nil { + return user, nil + } + if serr.Code != srvdef.ErrUserNotExsit { + return nil, serr + } + } + + deviceId := strings.TrimSpace(req.DeviceId) + if deviceId != "" && !strings.Contains(deviceId, "00:00:00:00") { + return usermod.FindOneUser(ctx, bson.M{usermod.FN.User.DevV2: deviceId}) + } + return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "") +} + +func createGuestUser(ctx context.Context, ua useragent.UA, req *GuestLoginReq, ip string) (*usermod.User, *srvdef.SrvErr) { + now := time.Now() + uid := mongos.Lid.User.Get() + districtInfo := resolveUserDistrict(req.Channel, "", ip, ua.SysType) + user := &usermod.User{ + Id: uid, + Type: pubdef.UserTypeGuest, + Status: pubdef.UserStatusNormal, + LoginType: pubdef.LoginTypeDevice, + IsRegister: false, + NickName: fmt.Sprintf("游客%d", uid), + Avatar: randomAvatar(), + InviteCode: generateUniqueInviteCode(ctx), + DistrictCode: normalizeChannel(req.Channel), + IsDirect: districtInfo.IsDirect, + RegIp: ip, + LastIp: ip, + DevID: ua.DevID, + Mac: strings.TrimSpace(req.Mac), + DevV2: strings.TrimSpace(req.DeviceId), + Device: ua.DevType, + SysType: ua.SysType, + AppVer: ua.Ver, + OriVer: ua.Ver, + SessionID: ua.SessionID, + DeviceBrand: ua.DeviceBrand, + DeviceModel: ua.DeviceModel, + SystemName: ua.SystemName, + SystemVersion: ua.SystemVersion, + LastLoginAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if nick := appg.GenerateNickname(); nick != "" { + user.NickName = nick + } + if serr := usermod.InsertOneUser(ctx, user); serr != nil { + log.Error("authser:createGuestUser InsertOneUser failed", log.E(serr), log.Any("devID", ua.DevID)) + return nil, serr + } + return user, nil +} + +func toLoginResp(ctx context.Context, user *usermod.User, token string, withCDN bool) *LoginResp { + avatar := user.Avatar + if withCDN { + cdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + avatar = netutil.MergeURL(cdn, user.Avatar) + } + return &LoginResp{ + Token: token, + UserId: user.Id, + AccountNo: user.AccountNo, + Email: user.Email, + NickName: user.NickName, + Introduction: user.Introduction, + VipType: user.VipType, + CurrentVipExpire: user.CurrentVipExpire, + ShowCardId: user.ShowCardId, + ShowCardName: user.ShowCardName, + Status: user.Status, + Avatar: avatar, + InviteCode: user.InviteCode, + Type: user.Type, + LoginType: user.LoginType, + IsRegister: user.IsRegister, + PrivacyMode: user.PrivacyMode, + HasPassword: strings.TrimSpace(user.Password) != "", + Score: user.Score, + Recharge: user.Recharge, + } +} + +func normalizeChannel(channel string) string { + return strings.TrimSpace(channel) +} + +func isBotRequest(ctx context.Context) bool { + gc, ok := ctx.(*gin.Context) + return ok && reptilerecord.IsBot(gc) +} + +// crawlerGuestLogin 给爬虫签发可观看的虚拟游客 token,不建用户、不写日活、不上报。 +func crawlerGuestLogin() (*LoginResp, *srvdef.SrvErr) { + token, serr := generateCrawlerToken() + if serr != nil { + return nil, serr + } + return CrawlerLoginResp(token), nil +} diff --git a/app/service/coinser/coin_buy.go b/app/service/coinser/coin_buy.go new file mode 100644 index 0000000..a08b5fb --- /dev/null +++ b/app/service/coinser/coin_buy.go @@ -0,0 +1,205 @@ +package coinser + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + "videoweb/app/service/rechargeser" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/useragent" + "videoweb/pub/dataReport" + "videoweb/pub/models/eventmod" + "videoweb/pub/models/rechargepackagemod" + "videoweb/pub/models/usermod" + "videoweb/pub/models/vipordermod" + "videoweb/pub/mongos" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// BuyCoinResp 购买金币下单响应 +type BuyCoinResp struct { + TradeNo string `json:"tradeNo"` + PayUrl string `json:"payUrl"` + PayUrlMode string `json:"payUrlMode"` + IsOpenNewBrowser bool `json:"isOpenNewBrowser"` +} + +// GetUserInfo 获取用户信息 +func GetUserInfo(ctx context.Context, userId int64) (*usermod.User, *srvdef.SrvErr) { + return usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) +} + +// CreateCoinOrder 创建金币购买订单(统一写入 vip_order 表,OrderType=2)。 +// 防重复:15 秒内同一用户+同一套餐的 pending 订单直接返回原支付链接。 +func CreateCoinOrder(ctx context.Context, + userId int64, userName string, + packageId int64, payMode string, + sysType string, devType string, deviceID string, ip string, + districtCode string, sourcePageKey string, sourcePageName string, +) (*BuyCoinResp, *srvdef.SrvErr) { + + pkg, serr := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{ + rechargepackagemod.FN.RechargePackage.Id: packageId, + rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeCoin, + rechargepackagemod.FN.RechargePackage.Status: rechargepackagemod.StatusEnabled, + }) + if serr != nil || pkg == nil { + return nil, srvdef.New(srvdef.ErrNoData, "金币套餐不存在或已下架", "") + } + coinAmount := pkg.BaseCoin + pkg.GiftCoin + if coinAmount <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "金币套餐到账数量无效", "") + } + + now := time.Now() + dupFilter := bson.M{ + vipordermod.FN.VipOrder.UserId: userId, + vipordermod.FN.VipOrder.CoinCfgId: packageId, + vipordermod.FN.VipOrder.OrderType: vipordermod.OrderTypeCoin, + vipordermod.FN.VipOrder.PayMode: payMode, + vipordermod.FN.VipOrder.Status: pubdef.VipOrderPending, + vipordermod.FN.VipOrder.CreatedAt: bson.M{"$gte": now.Add(-15 * time.Second)}, + } + existing, _ := vipordermod.FindOneVipOrder(ctx, dupFilter) + if existing != nil && existing.PayUrl != "" { + return &BuyCoinResp{ + TradeNo: existing.TradeNo, + PayUrl: existing.PayUrl, + PayUrlMode: existing.PayUrlMode, + IsOpenNewBrowser: true, + }, nil + } + + tradeNo := pubdef.GetTrandNoWithUUID() + + payInfo, serr := rechargeser.SubmitPayOrder(ctx, userId, userName, tradeNo, pkg.Price, payMode, sysType, devType, deviceID, ip) + if serr != nil { + return nil, serr + } + + order := &vipordermod.VipOrder{ + Id: mongos.Lid.VipOrder.Get(), + OrderType: vipordermod.OrderTypeCoin, + UserId: userId, + CoinCfgId: packageId, // 兼容历史字段,存购买套餐ID + Coin: coinAmount, + BaseCoin: pkg.BaseCoin, + GiftCoin: pkg.GiftCoin, + Amount: pkg.Price, + PayMode: payMode, + TradeNo: tradeNo, + Oid: payInfo.Oid, + Status: pubdef.VipOrderPending, + PayUrl: payInfo.PayUrl, + PayUrlMode: payInfo.PayUrlMode, + PayChannel: payInfo.PayChannel, + PayRate: payInfo.PayRate, + IPAddr: ip, + SysType: sysType, + DistrictCode: strings.TrimSpace(districtCode), + Client: vipordermod.NewOrderClient(useragent.FromContext(ctx), sourcePageKey, sourcePageName), + CreatedAt: now, + UpdatedAt: now, + } + if serr = vipordermod.InsertOneVipOrder(ctx, order); serr != nil { + log.Error("coinser:CreateCoinOrder InsertOneVipOrder failed", + log.Any("tradeNo", tradeNo), log.E(serr)) + return nil, serr + } + + go reportCoinOrderCreated(order, pkg, ip) + + return &BuyCoinResp{ + TradeNo: tradeNo, + PayUrl: payInfo.PayUrl, + PayUrlMode: payInfo.PayUrlMode, + IsOpenNewBrowser: true, + }, nil +} + +func reportCoinOrderCreated(order *vipordermod.VipOrder, pkg *rechargepackagemod.RechargePackage, ip string) { + ctx := context.Background() + user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: order.UserId}) + districtCode := order.DistrictCode + if user != nil && strings.TrimSpace(user.DistrictCode) != "" { + districtCode = user.DistrictCode + } + + sourceKey, sourceName := orderSource(order) + ua := order.Client.UA(order.SysType) + eventId := newEventId(pubdef.TrackerAppId(), order.UserId, order.TradeNo, order.CreatedAt.Unix()) + commonField := dataReport.NewCommonField(eventId, dataReport.EventTypeOrderCreated, districtCode, ua, strconv.FormatInt(order.UserId, 10), ip) + commonField.Payload = dataReport.JsonRawMessage(&dataReport.OrderCreatedEvent{ + OrderID: order.TradeNo, + OrderType: "coin_purchase", + ProductID: strconv.FormatInt(pkg.Id, 10), + ProductName: pkg.Name, + Amount: dataReport.JsonNumber(order.Amount), + Currency: "CNY", + CoinQuantity: dataReport.JsonNumber(order.Coin), + SourcePageKey: sourceKey, + SourcePageName: sourceName, + CreateTime: dataReport.JsonNumber(order.CreatedAt.Unix()), + }) + + eventData, _ := json.Marshal(commonField) + if err := eventmod.InsertOneEvent(ctx, &eventmod.Event{ + EventId: commonField.EventID, + EventType: commonField.Event, + Data: string(eventData), + Status: eventmod.Default, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }); err != nil { + log.Error("reportCoinOrderCreated InsertOneEvent failed", + log.Any("tradeNo", order.TradeNo), log.E(err)) + } +} + +func orderSource(order *vipordermod.VipOrder) (string, string) { + key, name := "deposit", "金币充值" + if order != nil && order.Client != nil { + if value := strings.TrimSpace(order.Client.SourcePageKey); value != "" { + key = value + } + if value := strings.TrimSpace(order.Client.SourcePageName); value != "" { + name = value + } + } + return key, name +} + +func newEventId(appId string, uid int64, orderId interface{}, createTime int64) string { + input := fmt.Sprintf("%v%v%v%v", appId, uid, orderId, createTime) + h := md5.New() + h.Write([]byte(input)) + return hex.EncodeToString(h.Sum(nil)) +} + +// GetCoinOrderHistory 查询用户金币购买记录 +func GetCoinOrderHistory(ctx context.Context, userId int64, page, pageSize int64) (vipordermod.VipOrderList, int64, *srvdef.SrvErr) { + filter := bson.M{ + vipordermod.FN.VipOrder.UserId: userId, + vipordermod.FN.VipOrder.OrderType: vipordermod.OrderTypeCoin, + } + total, serr := vipordermod.CountVipOrder(ctx, filter) + if serr != nil { + return nil, 0, serr + } + skip := (page - 1) * pageSize + opts := options.Find(). + SetSort(bson.M{vipordermod.FN.VipOrder.CreatedAt: -1}). + SetSkip(skip). + SetLimit(pageSize) + list, serr := vipordermod.FindManyVipOrder(ctx, filter, opts) + return list, total, serr +} diff --git a/app/service/coinser/coin_trans.go b/app/service/coinser/coin_trans.go new file mode 100644 index 0000000..040fe95 --- /dev/null +++ b/app/service/coinser/coin_trans.go @@ -0,0 +1,254 @@ +package coinser + +import ( + "bytes" + "context" + "encoding/csv" + "strconv" + "time" + "videoweb/app/api/proto" + "videoweb/pub/common/response" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/cointransmod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + // coinTransDefaultPageSize 金币余额明细默认每页数量 + coinTransDefaultPageSize int64 = 10 + // coinTransMaxPageSize 金币余额明细最大每页数量,避免单次请求拉取过多流水 + coinTransMaxPageSize int64 = 100 + // coinTransTypeAll 全部交易 + coinTransTypeAll int64 = 0 + // coinTransTypeRecharge 充值交易 + coinTransTypeRecharge int64 = 1 + // coinTransTypeConsume 消费交易 + coinTransTypeConsume int64 = 2 + // coinTransTypeReward 奖励交易 + coinTransTypeReward int64 = 3 +) + +var ( + // coinTransListProjection 余额明细只读取列表展示需要的字段,降低Mongo读取成本 + coinTransListProjection = bson.D{ + {Key: cointransmod.FN.CoinTrans.Id, Value: 1}, + {Key: cointransmod.FN.CoinTrans.FlowDir, Value: 1}, + {Key: cointransmod.FN.CoinTrans.TranType, Value: 1}, + {Key: cointransmod.FN.CoinTrans.Amount, Value: 1}, + {Key: cointransmod.FN.CoinTrans.BalanceBefore, Value: 1}, + {Key: cointransmod.FN.CoinTrans.BalanceAfter, Value: 1}, + {Key: cointransmod.FN.CoinTrans.OrderNo, Value: 1}, + {Key: cointransmod.FN.CoinTrans.RelatedId, Value: 1}, + {Key: cointransmod.FN.CoinTrans.Remark, Value: 1}, + {Key: cointransmod.FN.CoinTrans.CreatedAt, Value: 1}, + } + + // coinTransListSort 按交易时间倒序展示余额明细 + coinTransListSort = bson.D{ + {Key: cointransmod.FN.CoinTrans.CreatedAt, Value: -1}, + } + + // coinTransListHint 匹配 coin_trans 的 userId+createdAt 复合索引 + coinTransListHint = bson.D{ + {Key: cointransmod.FN.CoinTrans.UserId, Value: 1}, + {Key: cointransmod.FN.CoinTrans.CreatedAt, Value: -1}, + } +) + +// GetCoinTransList 查询当前用户金币余额明细 +// +// 只查询当前登录用户的流水,按交易时间倒序分页,pageSize 最大限制为100 +func GetCoinTransList(ctx context.Context, userId int64, page, pageSize int64, transType int64) (*response.BasePageResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "") + } + page, pageSize = normalizeCoinTransPage(page, pageSize) + filter := bson.M{ + cointransmod.FN.CoinTrans.UserId: userId, + } + if err := applyCoinTransTypeFilter(filter, transType); err != nil { + return nil, err + } + total, err := cointransmod.CountCoinTrans(ctx, filter) + if err != nil { + return nil, err + } + if total <= 0 { + return &response.BasePageResp{ + TotalCount: 0, + PageSize: int(pageSize), + PageNum: int(page), + List: make([]*proto.CoinTransListItemResp, 0), + }, nil + } + list, err1 := cointransmod.FindManyCoinTrans(ctx, filter, options.Find(). + SetProjection(coinTransListProjection). + SetSort(coinTransListSort). + SetHint(coinTransListHint). + SetSkip((page-1)*pageSize). + SetLimit(pageSize)) + if err1 != nil { + return nil, err1 + } + resp := make([]*proto.CoinTransListItemResp, 0, len(list)) + for _, item := range list { + resp = append(resp, coinTransToListItemResp(item)) + } + return &response.BasePageResp{ + TotalCount: total, + PageSize: int(pageSize), + PageNum: int(page), + List: resp, + }, nil +} + +// ExportCoinTrans 导出当前用户金币余额明细CSV +func ExportCoinTrans(ctx context.Context, userId int64, transType int64) ([]byte, string, *srvdef.SrvErr) { + if userId <= 0 { + return nil, "", srvdef.New(srvdef.ErrUserNotExsit, "", "") + } + filter := bson.M{ + cointransmod.FN.CoinTrans.UserId: userId, + } + if err := applyCoinTransTypeFilter(filter, transType); err != nil { + return nil, "", err + } + list, err := cointransmod.FindManyCoinTrans(ctx, filter, options.Find(). + SetProjection(coinTransListProjection). + SetSort(coinTransListSort). + SetHint(coinTransListHint)) + if err != nil { + return nil, "", err + } + data, serr := buildCoinTransCSV(list) + if serr != nil { + return nil, "", serr + } + return data, coinTransExportFileName(transType), nil +} + +// buildCoinTransCSV 构建金币流水CSV文件内容 +func buildCoinTransCSV(list cointransmod.CoinTransList) ([]byte, *srvdef.SrvErr) { + var buf bytes.Buffer + buf.WriteString("\xEF\xBB\xBF") + writer := csv.NewWriter(&buf) + if err := writer.Write([]string{"类型", "说明", "时间", "金币变动"}); err != nil { + return nil, srvdef.New(srvdef.ExcelExportErr, "文件导出错误", "文件导出错误") + } + for _, item := range list { + resp := coinTransToListItemResp(item) + if resp == nil { + continue + } + if err := writer.Write([]string{ + resp.TranTypeName, + resp.Title, + resp.CreatedAt, + formatCoinTransChange(resp.ChangeAmount), + }); err != nil { + return nil, srvdef.New(srvdef.ExcelExportErr, "文件导出错误", "文件导出错误") + } + } + writer.Flush() + if err := writer.Error(); err != nil { + return nil, srvdef.New(srvdef.ExcelExportErr, "文件导出错误", "文件导出错误") + } + return buf.Bytes(), nil +} + +// coinTransExportFileName 生成金币流水导出文件名 +func coinTransExportFileName(transType int64) string { + name := "交易明细" + switch transType { + case coinTransTypeRecharge: + name = "充值明细" + case coinTransTypeConsume: + name = "消费明细" + case coinTransTypeReward: + name = "奖励明细" + } + return name + "_" + time.Now().In(utils.LocBJ).Format("20060102150405") + ".csv" +} + +// formatCoinTransChange 格式化金币变动值 +func formatCoinTransChange(change int64) string { + if change > 0 { + return "+" + strconv.FormatInt(change, 10) + } + return strconv.FormatInt(change, 10) +} + +// applyCoinTransTypeFilter 按交易明细Tab追加筛选条件 +func applyCoinTransTypeFilter(filter bson.M, transType int64) *srvdef.SrvErr { + switch transType { + case coinTransTypeAll: + return nil + case coinTransTypeRecharge: + filter[cointransmod.FN.CoinTrans.TranType] = cointransmod.TranTypeCoinRecharge + case coinTransTypeConsume: + filter[cointransmod.FN.CoinTrans.FlowDir] = cointransmod.FlowOut + case coinTransTypeReward: + filter[cointransmod.FN.CoinTrans.FlowDir] = cointransmod.FlowIn + filter[cointransmod.FN.CoinTrans.TranType] = bson.M{"$ne": cointransmod.TranTypeCoinRecharge} + default: + return srvdef.New(srvdef.ErrParamError, "类型错误", "类型错误") + } + return nil +} + +// normalizeCoinTransPage 规范化金币流水分页参数 +// +// page 小于等于0时按第1页处理;pageSize 未传时使用默认值,超过上限时截断 +func normalizeCoinTransPage(page, pageSize int64) (int64, int64) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = coinTransDefaultPageSize + } + if pageSize > coinTransMaxPageSize { + pageSize = coinTransMaxPageSize + } + return page, pageSize +} + +// coinTransToListItemResp 转换金币流水响应项 +// +// amount 保持数据库中的正数金额,changeAmount 根据收支方向转换为带正负号的变动金额 +func coinTransToListItemResp(item *cointransmod.CoinTrans) *proto.CoinTransListItemResp { + title := item.Remark + if title == "" { + title = item.TranType.Desc() + } + changeAmount := item.Amount + if item.FlowDir == cointransmod.FlowOut { + changeAmount = -item.Amount + } + return &proto.CoinTransListItemResp{ + Id: item.Id, + Title: title, + FlowDir: item.FlowDir, + FlowDirName: item.FlowDir.Desc(), + TranType: item.TranType, + TranTypeName: item.TranType.Desc(), + Amount: item.Amount, + ChangeAmount: changeAmount, + BalanceBefore: item.BalanceBefore, + BalanceAfter: item.BalanceAfter, + OrderNo: item.OrderNo, + RelatedId: item.RelatedId, + Remark: item.Remark, + CreatedAt: formatCoinTransTime(item.CreatedAt), + } +} + +func formatCoinTransTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.In(utils.LocBJ).Format(pubdef.TimeISO) +} diff --git a/app/service/commentser/comment.go b/app/service/commentser/comment.go new file mode 100644 index 0000000..9eaecba --- /dev/null +++ b/app/service/commentser/comment.go @@ -0,0 +1,705 @@ +package commentser + +import ( + "context" + "strconv" + "strings" + "time" + "unicode/utf8" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/reportser" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/log" + "videoweb/pub/common/netutil" + "videoweb/pub/common/srvdef" + "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/redis" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + ObjectTypePlaylet int64 = 10 // 短剧评论 + ObjectTypePlayletChapter int64 = 11 // 短剧剧集评论 + ObjectTypeVideo int64 = 20 // 视频评论 + commentLevelFirst int64 = 1 // 一级评论 + commentLevelSecond int64 = 2 // 二级评论 + commentReviewPending int = 0 // 待审核 + commentReviewPass int = 1 // 审核通过 + commentMaxTextLen int = 200 // 评论最大字数 + commentDefaultPageSize int64 = 20 // 默认分页条数 + commentMaxPageSize int64 = 100 // 最大分页条数 + commentSortHot int64 = 1 // 热门 + commentSortNewest int64 = 2 // 最新 + commentPlayletHeatInc int64 = 3 // 发布评论增加的短剧热度值 + operateStatusYes int64 = 1 // 已操作 + operateStatusNo int64 = 2 // 未操作 +) + +var commentListProjection = bson.D{ + {Key: commentmod.FN.Comment.Id, Value: 1}, + {Key: commentmod.FN.Comment.ObjectId, Value: 1}, + {Key: commentmod.FN.Comment.ObjectType, Value: 1}, + {Key: commentmod.FN.Comment.UserId, Value: 1}, + {Key: commentmod.FN.Comment.UserName, Value: 1}, + {Key: commentmod.FN.Comment.UserAvatar, Value: 1}, + {Key: commentmod.FN.Comment.Text, Value: 1}, + {Key: commentmod.FN.Comment.Level, Value: 1}, + {Key: commentmod.FN.Comment.ParentsId, Value: 1}, + {Key: commentmod.FN.Comment.ReplyCommentId, Value: 1}, + {Key: commentmod.FN.Comment.ReplyUserName, Value: 1}, + {Key: commentmod.FN.Comment.CommentCount, Value: 1}, + {Key: commentmod.FN.Comment.CountLike, Value: 1}, + {Key: commentmod.FN.Comment.CreatedAt, Value: 1}, +} + +// SendComment 发布评论 +func SendComment(ctx context.Context, userId int64, ip string, req *proto.CommentSendReq) *srvdef.SrvErr { + // 评论入口先做频控和基础参数校验,再校验短剧/剧集是否可评论,避免无效请求进入写库链路。 + if userId <= 0 { + return srvdef.New(srvdef.InvalidToken, "请先登录", "") + } + if err := checkCommentLimit(ip, req); err != nil { + return err + } + text, err := normalizeCommentText(req) + if err != nil { + return err + } + objectName, playletId, err := validateCommentTarget(ctx, req.ObjectType, req.ObjectId) + if err != nil { + return err + } + level := commentLevelFirst + parentsId := int64(0) + replyCommentId := int64(0) + replyUserName := "" + if req.ParentsId > 0 { + replyComment, err := findReplyTargetComment(ctx, req) + if err != nil { + return err + } + level = commentLevelSecond + parentsId = req.ParentsId + replyCommentId = replyComment.Id + replyUserName = replyComment.UserName + } + user, err := findCommentUser(ctx, userId) + if err != nil { + return err + } + now := time.Now() + info := commentmod.CommentModel{ + Id: mongos.Lid.Comment.Get(), + Ip: ip, + ObjectId: req.ObjectId, + ObjectName: objectName, + ObjectType: req.ObjectType, + UserId: user.Id, + UserName: commentUserName(user), + UserAvatar: user.Avatar, + Level: level, + ParentsId: parentsId, + ReplyCommentId: replyCommentId, + ReplyUserName: replyUserName, + Text: text, + CommentCount: 0, + CountLike: 0, + ReviewStatus: commentReviewPending, + UpdatedAt: now, + CreatedAt: now, + } + if err := commentmod.InsertOneComment(ctx, &info); err != nil { + log.Error("插入评论失败", log.Any("评论信息", info), log.E(err)) + return err + } + if isPlayletCommentType(req.ObjectType) { + if err := incrementCommentPlayletHeatValue(ctx, playletId, commentPlayletHeatInc); err != nil { + log.Error("评论发布后更新短剧热度失败", log.Any("短剧ID", playletId), log.Any("评论信息", info), log.E(err)) + return err + } + chapterId := int64(0) + if req.ObjectType == ObjectTypePlayletChapter { + chapterId = req.ObjectId + } + reportser.ReportPlayletComment(ctx, user, playletId, chapterId, text) + } else if req.ObjectType == ObjectTypeVideo { + reportser.ReportVideoComment(ctx, user, req.ObjectId, text) + } + // app端发布评论先进入待审核状态,不立即增加业务对象评论数;后台审核通过时再统一同步 countComment + return nil +} + +func findCommentUser(ctx context.Context, userId int64) (*usermod.User, *srvdef.SrvErr) { + user, err := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) + if err != nil { + log.Error("查询评论用户失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + return user, nil +} + +func commentUserName(user *usermod.User) string { + if user == nil { + return "" + } + if name := strings.TrimSpace(user.NickName); name != "" { + return name + } + if name := strings.TrimSpace(user.AccountNo); name != "" { + return name + } + return "用户" + strconv.FormatInt(user.Id, 10) +} + +// GetCommentPage 分页查询评论列表 +func GetCommentPage(ctx context.Context, userId int64, req *proto.GetCommentListReq) (*proto.GetCommentListResp, *srvdef.SrvErr) { + // 列表只查一级且已审核评论,并通过一次批量查询补齐当前用户点赞状态,避免列表 N+1 查询。 + if req == nil || req.ObjectId <= 0 || !isSupportedCommentType(req.ObjectType) { + return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "") + } + pageNum, pageSize := normalizeCommentPage(req.PageNum, req.PageSize) + return getCommentPageData(ctx, userId, req.ObjectId, req.ObjectType, req.SortOrder, pageNum, pageSize) +} + +// GetCommentList 兼容旧详情接口的评论查询 +func GetCommentList(ctx context.Context, objectId, objectType, page int64) (*proto.GetCommentListResp, *srvdef.SrvErr) { + pageNum, _ := normalizeCommentPage(page, commentDefaultPageSize) + return getCommentPageData(ctx, 0, objectId, objectType, commentSortNewest, pageNum, commentDefaultPageSize) +} + +// SetCommentLike 设置当前用户评论点赞状态 +func SetCommentLike(ctx context.Context, userId int64, req *proto.CommentLikeSetReq) (*proto.CommentLikeSetResp, *srvdef.SrvErr) { + // 使用评论点赞关系表做去重;只有关系真实变化时才更新 _comment.countLike,重复点赞/取消不会重复增减计数。 + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "") + } + if req == nil || req.CommentId <= 0 || req.IsLike == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "") + } + comment, err := findLikeTargetComment(ctx, req.CommentId) + if err != nil { + return nil, err + } + filter := bson.M{ + commentlikemod.FN.CommentLike.UserId: userId, + commentlikemod.FN.CommentLike.CommentId: req.CommentId, + } + changed := false + if *req.IsLike { + inserted, err := commentlikemod.InsertOneCommentLike(ctx, &commentlikemod.CommentLike{ + UserId: userId, + CommentId: req.CommentId, + ObjectId: comment.ObjectId, + ObjectType: comment.ObjectType, + }) + if err != nil { + log.Error("新增评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", req.CommentId), log.E(err)) + return nil, err + } + changed = inserted + } else { + deleted, err := commentlikemod.DeleteOneCommentLike(ctx, filter) + if err != nil { + log.Error("删除评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", req.CommentId), log.E(err)) + return nil, err + } + changed = deleted > 0 + } + if changed { + delta := int64(1) + if !*req.IsLike { + delta = -1 + } + if err := incrementCommentLikeCount(ctx, req.CommentId, delta); err != nil { + rollbackCommentLike(ctx, userId, comment, *req.IsLike) + return nil, err + } + } + return &proto.CommentLikeSetResp{ + CommentId: req.CommentId, + IsLike: operateStatusByFlag(*req.IsLike), + CountLike: applyLocalCounter(comment.CountLike, *req.IsLike, changed), + }, nil +} + +func checkCommentLimit(ip string, req *proto.CommentSendReq) *srvdef.SrvErr { + curValue, err := redis.Incr(redisconst.CommentLimitKey(ip), redisconst.CommentLimitKeyExpire) + if err != nil { + log.Error("评论频率限制写入Redis失败", log.E(err), log.Any("IP", ip)) + } + if curValue > 1 { + log.Error("评论操作过于频繁", log.Any("IP", ip), log.Any("请求参数", req)) + return srvdef.New(srvdef.ErrOperationTooFrequently, "", "评论太频繁") + } + dayCurValue, err := redis.Incr(redisconst.CommentOneDayLimitKey(ip), redisconst.CommentDayLimitKeyExpire) + if err != nil { + log.Error("评论每日限制写入Redis失败", log.E(err), log.Any("IP", ip)) + } + if dayCurValue > 50 { + log.Error("评论每日次数超限", log.Any("IP", ip), log.Any("请求参数", req)) + return srvdef.New(srvdef.ErrOperationTooFrequently, "", "评论太频繁") + } + return nil +} + +func normalizeCommentText(req *proto.CommentSendReq) (string, *srvdef.SrvErr) { + if req == nil || req.ObjectId <= 0 || !isSupportedCommentType(req.ObjectType) { + return "", srvdef.New(srvdef.ErrParamError, "参数错误", "") + } + text := strings.TrimSpace(req.Text) + if text == "" { + return "", srvdef.New(srvdef.CommentTextIsEmpty, "评论内容不能为空", "") + } + if utf8.RuneCountInString(text) > commentMaxTextLen { + return "", srvdef.New(srvdef.ErrParamError, "评论内容不能超过200字", "") + } + return text, nil +} + +func validateCommentTarget(ctx context.Context, objectType, objectId int64) (string, int64, *srvdef.SrvErr) { + switch objectType { + case ObjectTypePlaylet: + playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: objectId, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }, options.FindOne().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Title, Value: 1}, + })) + if err != nil { + log.Error("查询评论短剧目标失败", log.Any("短剧ID", objectId), log.E(err)) + return "", 0, srvdef.New(srvdef.ErrParamError, "短剧不存在或未上架", "") + } + return playlet.Title, playlet.Id, nil + case ObjectTypePlayletChapter: + chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: objectId, + playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp, + }, options.FindOne().SetProjection(bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, + })) + if err != nil { + log.Error("查询评论剧集目标失败", log.Any("剧集ID", objectId), log.E(err)) + return "", 0, srvdef.New(srvdef.ErrParamError, "剧集不存在", "") + } + if _, err = playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: chapter.PlayletId, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }, options.FindOne().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + })); err != nil { + log.Error("查询剧集所属短剧失败", log.Any("剧集ID", objectId), log.Any("短剧ID", chapter.PlayletId), log.E(err)) + return "", 0, srvdef.New(srvdef.ErrParamError, "短剧不存在或未上架", "") + } + if chapter.Title != "" { + return chapter.Title, chapter.PlayletId, nil + } + return "第" + strconv.FormatInt(chapter.CurrentEpisode, 10) + "集", chapter.PlayletId, nil + case ObjectTypeVideo: + video, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: objectId, + videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK, + }, options.FindOne().SetProjection(bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Id, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Title, Value: 1}, + })) + if err != nil { + log.Error("查询评论视频目标失败", log.Any("视频ID", objectId), log.E(err)) + return "", 0, srvdef.New(srvdef.ErrParamError, "视频不存在或未上架", "") + } + return video.Title, 0, nil + default: + return "", 0, srvdef.New(srvdef.ErrParamError, "评论类型有误", "") + } +} + +func findReplyTargetComment(ctx context.Context, req *proto.CommentSendReq) (*commentmod.CommentModel, *srvdef.SrvErr) { + parentComment, err := 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.Level: commentLevelFirst, + commentmod.FN.Comment.ParentsId: 0, + commentmod.FN.Comment.ReviewStatus: commentReviewPass, + }, options.FindOne().SetProjection(bson.D{ + {Key: commentmod.FN.Comment.Id, Value: 1}, + {Key: commentmod.FN.Comment.UserName, Value: 1}, + })) + if err != nil { + log.Error("查询回复父评论失败", log.Any("父评论ID", req.ParentsId), log.Any("请求参数", req), log.E(err)) + return nil, srvdef.New(srvdef.ErrParamError, "父评论不存在或不可回复", "") + } + replyCommentId := req.ReplyCommentId + if replyCommentId <= 0 { + replyCommentId = req.ParentsId + } + if replyCommentId == parentComment.Id { + return parentComment, nil + } + replyComment, err := commentmod.FindOneComment(ctx, bson.M{ + commentmod.FN.Comment.Id: replyCommentId, + commentmod.FN.Comment.ObjectId: req.ObjectId, + commentmod.FN.Comment.ObjectType: req.ObjectType, + commentmod.FN.Comment.Level: commentLevelSecond, + commentmod.FN.Comment.ParentsId: req.ParentsId, + commentmod.FN.Comment.ReviewStatus: commentReviewPass, + }, options.FindOne().SetProjection(bson.D{ + {Key: commentmod.FN.Comment.Id, Value: 1}, + {Key: commentmod.FN.Comment.UserName, Value: 1}, + })) + if err != nil { + log.Error("查询被回复评论失败", log.Any("被回复评论ID", replyCommentId), log.Any("请求参数", req), log.E(err)) + return nil, srvdef.New(srvdef.ErrParamError, "被回复评论不存在或不可回复", "") + } + return replyComment, nil +} + +func incrementTargetCommentCount(ctx context.Context, objectType, objectId int64, delta int64) *srvdef.SrvErr { + switch objectType { + case ObjectTypePlaylet: + if err := playletmod.UpdateOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: objectId, + }, bson.M{"$inc": bson.M{playletmod.FN.Playlet.CountComment: delta}}); err != nil { + log.Error("更新短剧评论数失败", log.Any("短剧ID", objectId), log.Any("增量", delta), log.E(err)) + return err + } + case ObjectTypePlayletChapter: + if err := playletchaptermod.UpdateOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: objectId, + }, bson.M{"$inc": bson.M{playletchaptermod.FN.PlayletChapter.CountComment: delta}}); err != nil { + log.Error("更新剧集评论数失败", log.Any("剧集ID", objectId), log.Any("增量", delta), log.E(err)) + return err + } + case ObjectTypeVideo: + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Id: objectId, + } + if delta < 0 { + filter[videoinformationmod.FN.VideoInformation.Real.Comments] = bson.M{"$gt": 0} + } + if err := videoinformationmod.UpdateOneVideoInformation(ctx, filter, bson.M{ + "$inc": bson.M{videoinformationmod.FN.VideoInformation.Real.Comments: delta}, + }); err != nil { + if delta < 0 && err.Code == srvdef.ErrNoData { + log.Warn("视频评论数已为0,跳过扣减", log.Any("视频ID", objectId)) + return nil + } + log.Error("更新视频评论数失败", log.Any("视频ID", objectId), log.Any("增量", delta), log.E(err)) + return err + } + } + return nil +} + +func incrementCommentPlayletHeatValue(ctx context.Context, playletId int64, delta int64) *srvdef.SrvErr { + if playletId <= 0 || delta == 0 { + return nil + } + return playletmod.UpdateOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: playletId, + }, bson.M{ + "$inc": bson.M{ + playletmod.FN.Playlet.HeatValue: delta, + }, + }) +} + +func getCommentPageData(ctx context.Context, userId int64, objectId, objectType, sortOrder, pageNum, pageSize int64) (*proto.GetCommentListResp, *srvdef.SrvErr) { + resp := &proto.GetCommentListResp{ + ObjectId: objectId, + TotalCount: 0, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.CommentInfo, 0), + } + if objectId <= 0 || !isSupportedCommentType(objectType) { + return resp, nil + } + filter := bson.M{ + commentmod.FN.Comment.ObjectId: objectId, + commentmod.FN.Comment.ObjectType: objectType, + commentmod.FN.Comment.ReviewStatus: commentReviewPass, + commentmod.FN.Comment.Level: commentLevelFirst, + commentmod.FN.Comment.ParentsId: 0, + } + total, err := commentmod.CountComments(ctx, filter) + if err != nil { + log.Warn("统计评论列表失败", log.Any("filter", filter), log.E(err)) + return resp, err + } + resp.TotalCount = total + if total == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= total { + return resp, nil + } + opts := options.Find(). + SetProjection(commentListProjection). + SetSkip(skip). + SetLimit(pageSize). + SetSort(commentListSort(sortOrder)) + list, err := commentmod.FindManyComment(ctx, filter, opts) + if err != nil { + log.Warn("查询评论列表失败", log.Any("filter", filter), log.E(err)) + return resp, err + } + parentIds := commentIdsFromList(list) + children, err := findChildComments(ctx, parentIds) + if err != nil { + return resp, err + } + allComments := make(commentmod.CommentModelList, 0, len(list)+len(children)) + allComments = append(allComments, list...) + allComments = append(allComments, children...) + likedCommentIds, err := findLikedCommentIdMap(ctx, userId, allComments) + if err != nil { + return resp, err + } + childrenByParent := make(map[int64][]proto.CommentInfo) + for _, item := range children { + if item == nil { + continue + } + _, liked := likedCommentIds[item.Id] + childrenByParent[item.ParentsId] = append(childrenByParent[item.ParentsId], buildCommentInfo(ctx, item, liked)) + } + resp.List = make([]proto.CommentInfo, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + _, liked := likedCommentIds[item.Id] + info := buildCommentInfo(ctx, item, liked) + info.Children = childrenByParent[item.Id] + resp.List = append(resp.List, info) + } + return resp, nil +} + +func commentListSort(sortOrder int64) bson.D { + switch sortOrder { + case commentSortHot: + return bson.D{ + {Key: commentmod.FN.Comment.CountLike, Value: -1}, + {Key: commentmod.FN.Comment.Id, Value: -1}, + } + case commentSortNewest: + fallthrough + default: + return bson.D{ + {Key: commentmod.FN.Comment.CreatedAt, Value: -1}, + {Key: commentmod.FN.Comment.Id, Value: -1}, + } + } +} + +func commentIdsFromList(list commentmod.CommentModelList) []int64 { + commentIds := make([]int64, 0, len(list)) + for _, item := range list { + if item != nil { + commentIds = append(commentIds, item.Id) + } + } + return commentIds +} + +func findChildComments(ctx context.Context, parentIds []int64) (commentmod.CommentModelList, *srvdef.SrvErr) { + if len(parentIds) == 0 { + return make(commentmod.CommentModelList, 0), nil + } + filter := bson.M{ + commentmod.FN.Comment.ParentsId: bson.M{"$in": parentIds}, + commentmod.FN.Comment.Level: commentLevelSecond, + commentmod.FN.Comment.ReviewStatus: commentReviewPass, + } + opts := options.Find(). + SetProjection(commentListProjection). + SetSort(bson.D{ + {Key: commentmod.FN.Comment.ParentsId, Value: 1}, + {Key: commentmod.FN.Comment.CreatedAt, Value: 1}, + {Key: commentmod.FN.Comment.Id, Value: 1}, + }) + children, err := commentmod.FindManyComment(ctx, filter, opts) + if err != nil { + log.Warn("查询二级评论列表失败", log.Any("filter", filter), log.E(err)) + return nil, err + } + return children, nil +} + +func findLikedCommentIdMap(ctx context.Context, userId int64, list commentmod.CommentModelList) (map[int64]struct{}, *srvdef.SrvErr) { + likedMap := make(map[int64]struct{}) + if userId <= 0 || len(list) == 0 { + return likedMap, nil + } + commentIds := commentIdsFromList(list) + if len(commentIds) == 0 { + return likedMap, nil + } + likes, err := commentlikemod.FindManyCommentLike(ctx, bson.M{ + commentlikemod.FN.CommentLike.UserId: userId, + commentlikemod.FN.CommentLike.CommentId: bson.M{"$in": commentIds}, + }, options.Find().SetProjection(bson.D{ + {Key: commentlikemod.FN.CommentLike.CommentId, Value: 1}, + })) + if err != nil { + log.Error("批量查询评论点赞状态失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + for _, item := range likes { + if item != nil { + likedMap[item.CommentId] = struct{}{} + } + } + return likedMap, nil +} + +func buildCommentInfo(ctx context.Context, item *commentmod.CommentModel, liked bool) proto.CommentInfo { + return proto.CommentInfo{ + Id: item.Id, + ObjectId: item.ObjectId, + ObjectType: item.ObjectType, + UserId: item.UserId, + UserName: item.UserName, + UserAvatar: withCommentAvatarDomain(ctx, item.UserAvatar), + Text: item.Text, + Level: item.Level, + ParentsId: item.ParentsId, + ReplyCommentId: item.ReplyCommentId, + ReplyUserName: item.ReplyUserName, + CommentCount: item.CommentCount, + CountLike: item.CountLike, + IsLike: operateStatusByFlag(liked), + CreatedAt: item.CreatedAt, + Children: make([]proto.CommentInfo, 0), + } +} + +func withCommentAvatarDomain(ctx context.Context, path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + if imgCdn == "" { + return path + } + return netutil.MergeURL(imgCdn, path) +} + +func findLikeTargetComment(ctx context.Context, commentId int64) (*commentmod.CommentModel, *srvdef.SrvErr) { + comment, err := commentmod.FindOneComment(ctx, bson.M{ + commentmod.FN.Comment.Id: commentId, + commentmod.FN.Comment.ReviewStatus: commentReviewPass, + commentmod.FN.Comment.ObjectType: bson.M{ + "$in": []int64{ObjectTypePlaylet, ObjectTypePlayletChapter, ObjectTypeVideo}, + }, + }, options.FindOne().SetProjection(bson.D{ + {Key: commentmod.FN.Comment.Id, Value: 1}, + {Key: commentmod.FN.Comment.ObjectId, Value: 1}, + {Key: commentmod.FN.Comment.ObjectType, Value: 1}, + {Key: commentmod.FN.Comment.CountLike, Value: 1}, + })) + if err != nil { + log.Error("查询评论点赞目标失败", log.Any("评论ID", commentId), log.E(err)) + return nil, srvdef.New(srvdef.ErrParamError, "评论不存在", "") + } + return comment, nil +} + +func incrementCommentLikeCount(ctx context.Context, commentId int64, delta int64) *srvdef.SrvErr { + filter := bson.M{ + commentmod.FN.Comment.Id: commentId, + } + if delta < 0 { + filter[commentmod.FN.Comment.CountLike] = bson.M{"$gt": 0} + } + err := commentmod.UpdateOneComment(ctx, filter, bson.M{ + "$inc": bson.M{ + commentmod.FN.Comment.CountLike: delta, + }, + }) + if err != nil { + if delta < 0 && err.Code == srvdef.ErrNoData { + log.Warn("评论点赞数已为0,跳过扣减", log.Any("评论ID", commentId)) + return nil + } + log.Error("更新评论点赞数失败", log.Any("评论ID", commentId), log.Any("增量", delta), log.E(err)) + return err + } + return nil +} + +func rollbackCommentLike(ctx context.Context, userId int64, comment *commentmod.CommentModel, enabled bool) { + filter := bson.M{ + commentlikemod.FN.CommentLike.UserId: userId, + commentlikemod.FN.CommentLike.CommentId: comment.Id, + } + if enabled { + if _, err := commentlikemod.DeleteOneCommentLike(ctx, filter); err != nil { + log.Error("回滚评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", comment.Id), log.E(err)) + } + return + } + if _, err := commentlikemod.InsertOneCommentLike(ctx, &commentlikemod.CommentLike{ + UserId: userId, + CommentId: comment.Id, + ObjectId: comment.ObjectId, + ObjectType: comment.ObjectType, + }); err != nil { + log.Error("恢复评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", comment.Id), log.E(err)) + } +} + +func normalizeCommentPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = commentDefaultPageSize + } + if pageSize > commentMaxPageSize { + pageSize = commentMaxPageSize + } + return pageNum, pageSize +} + +func isPlayletCommentType(objectType int64) bool { + return objectType == ObjectTypePlaylet || objectType == ObjectTypePlayletChapter +} + +func isSupportedCommentType(objectType int64) bool { + return isPlayletCommentType(objectType) || objectType == ObjectTypeVideo +} + +func applyLocalCounter(current int64, enabled bool, changed bool) int64 { + if !changed { + return current + } + if enabled { + return current + 1 + } + if current <= 0 { + return 0 + } + return current - 1 +} + +func operateStatusByFlag(enabled bool) int64 { + if enabled { + return operateStatusYes + } + return operateStatusNo +} diff --git a/app/service/configser/announcement.go b/app/service/configser/announcement.go new file mode 100644 index 0000000..9829514 --- /dev/null +++ b/app/service/configser/announcement.go @@ -0,0 +1,97 @@ +package configser + +import ( + "strconv" + "videoweb/app/api/proto" + "videoweb/app/appg" + "videoweb/pub/adcenter" + "videoweb/pub/common/log" + "videoweb/pub/common/netutil" + "videoweb/pub/storage/proxy" +) + +// 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 + } + adMap := make(map[string]adcenter.AdCacheInfo) + for _, loc := 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(), + }) + adMap[ad.AdvertiseCode] = adcenter.AdCacheInfo{ + AdvertiseCode: ad.AdvertiseCode, + CustomerCode: ad.CustomerCode, + AdvertiseName: ad.AdvertiseName, + AdvertiseUrl: ad.AdvertiseUrl, + AdvertiseIcon: ad.AdvertiseIcon, + AdvertiseType: ad.AdvertiseType, + AdMode: ad.AdMode, + AppCode: ad.AppCode, + LocationType: ad.LocationType, + AdvertiseDesc: ad.AdvertiseDesc, + PageType: ad.PageType, + StartTimeStamp: ad.StartTimeStamp, + EndTimeStamp: ad.EndTimeStamp, + Sort: ad.Sort, + AdExtData: ad.AdExtData, + DeptCode: loc.DeptCode, + MerchantCode: loc.MerchantCode, + AdvertiseLocationCode: loc.AdvertiseLocationCode, + AdvertiseLocationName: loc.AdvertiseLocationName, + DisplayMode: loc.DisplayMode, + AdvertiseHeight: loc.AdvertiseHeight, + AdvertiseWidth: loc.AdvertiseWidth, + MaterialType: loc.MaterialType, + AdLimit: loc.AdLimit, + } + } + switch pos { + case 1: //弹窗 + resp.DialogList = ads + case 2: // 应用 + + resp.NineGridList = ads + case 3: // 横幅 + resp.DetailbannerList = ads + + case 12: + resp.AList = ads + case 13: + resp.BList = ads + } + } + CacheMgr.Lock.Lock() + defer CacheMgr.Lock.Unlock() + CacheMgr.Advitise = resp + CacheMgr.AdvitiseMap = adMap +} + +func GetAdvertiseLocation(adverId string) (res adcenter.AdCacheInfo) { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + if ad, ok := CacheMgr.AdvitiseMap[adverId]; ok { + res = ad + } + return +} diff --git a/app/service/configser/catch.go b/app/service/configser/catch.go new file mode 100644 index 0000000..3b8e217 --- /dev/null +++ b/app/service/configser/catch.go @@ -0,0 +1,578 @@ +package configser + +import ( + "context" + "regexp" + "strconv" + "strings" + "sync" + "videoweb/app/api/proto" + "videoweb/app/service/translationser" + "videoweb/pub/adcenter" + "videoweb/pub/common/log" + "videoweb/pub/common/netutil" + "videoweb/pub/common/srvdef" + "videoweb/pub/dataReport" + "videoweb/pub/models/announcementmod" + "videoweb/pub/models/cfg" + "videoweb/pub/models/ipblackmod" + "videoweb/pub/models/richtextcontentmod" + "videoweb/pub/models/videotagmod" + + "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 // 系统配置缓存 + Advitise proto.AdvitiseData + AdvitiseMap map[string]adcenter.AdCacheInfo + TagCategoryList []proto.VideoTagCategoryData + TotalHistory int64 + IpBlackMaps map[string]struct{} + TagMaps map[int64]proto.TagsInfoData + NewAddressInfo proto.NewAddressInfo + AppRichTexts []proto.AppRichTextItemResp + Announcements []*proto.AnnouncementItem +} + +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.IpBlackMaps = make(map[string]struct{}) +} + +func Start() { + ctx := context.Background() + dataReport.StartSDKConfigCron() + UpdateConfigInfo(ctx) + UpdateAdvitiseDataFromJt() + updateIpBlack() + updateTagsCache() + updateAppRichTexts() + updateAnnouncements() + _, _ = skdCron.AddFunc("0 */5 * * * ?", func() { + UpdateConfigInfo(ctx) + UpdateAdvitiseDataFromJt() + updateIpBlack() + updateTagsCache() + updateAppRichTexts() + }) + _, _ = skdCron.AddFunc("0 */10 * * * ?", func() { + updateAnnouncements() + }) + + skdCron.Start() +} + +func updateTagsCache() { + filter := bson.M{videotagmod.FN.VideoTagInfo.Status: true} + opts := &options.FindOptions{} + opts.SetLimit(5000) + opts.SetSort(bson.M{videotagmod.FN.VideoTagInfo.Sort: -1}) + list, err := videotagmod.FindManyVideoTagInfos(context.Background(), filter, opts) + if err != nil { + log.Warn("updateTaggCache.FFindManyVideoTagInfos", log.Any("err", err)) + return + } + tempMaps := make(map[int64]proto.TagsInfoData) + for _, v := range list { + tempMaps[v.Id] = proto.TagsInfoData{ + Id: v.Id, + Name: v.Title, + Slug: v.Slug, + SeoInfo: v.SeoInfo, + } + } + CacheMgr.Lock.Lock() + defer CacheMgr.Lock.Unlock() + CacheMgr.TagMaps = tempMaps +} + +func UpdateConfigInfo(ctx context.Context) { + config := proto.ConfigInfo{} + newAddressInfo := proto.NewAddressInfo{} + + 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.OfficialAppAddr = sysCfg.OfficialAppAddr + 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.AdvitiesTopicNum = sysCfg.AdvitiesTopicNum + + config.DHKeyworld = sysCfg.DHKeyworld + config.DHDescription = sysCfg.DHDescription + config.DHSiteName = sysCfg.DHSiteName + config.DHTitle = sysCfg.DHTitle + config.ActressBackGround = netutil.MergeURL(sysCfg.ImgAppCdn, sysCfg.ActressBackGround) + config.ReportSDKConfig = dataReport.GetSDKDomainConfig() + config.WatermarkImage = sysCfg.WatermarkImage + config.CustomerServiceHost = sysCfg.CustomerServiceHost + config.ChannelCode = sysCfg.ChannelCode + config.EnableH265 = sysCfg.EnableH265 + cfg.CacheAppKey(sysCfg.AppKey) + + // NewAddressInfo 来源于系统配置 sys_cfg,用于只返回前端需要展示的最新地址信息。 + newAddressInfo.NewUrlAddr = sysCfg.NewUrlAddr + newAddressInfo.NewUrlAddr1 = sysCfg.NewUrlAddr1 + newAddressInfo.NewUrlAddr2 = sysCfg.NewUrlAddr2 + newAddressInfo.ForeverUrlAddr = sysCfg.ForeverUrlAddr + newAddressInfo.GmailAddr = sysCfg.GmailAddr + newAddressInfo.TgGroup = sysCfg.TgGroup + newAddressInfo.Telegram = sysCfg.Telegram + newAddressInfo.CustomerServiceHost = sysCfg.CustomerServiceHost + newAddressInfo.TwitterAddr = sysCfg.TwitterAddr + newAddressInfo.PdfAddr = sysCfg.PdfAddr + newAddressInfo.AppAddr = sysCfg.AppAddr + newAddressInfo.OfficialAppAddr = sysCfg.OfficialAppAddr + + CacheMgr.Lock.Lock() + defer CacheMgr.Lock.Unlock() + CacheMgr.Config = config + CacheMgr.NewAddressInfo = newAddressInfo + +} +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 GetNewAddress() proto.NewAddressInfo { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + return CacheMgr.NewAddressInfo +} + +// GetFooterInfo 查询APP底部信息 +func GetFooterInfo() proto.FooterInfo { + // 底部富文本和系统配置都来自缓存,接口请求不直接访问Mongo。 + config := GetConfigInfo() + return proto.FooterInfo{ + Brand: buildFooterBrand(config), + NavList: buildFooterNavList(), + ContactList: buildFooterContactList(config), + } +} + +func getFooterRichTextMap() map[string]proto.AppRichTextItemResp { + data := getCachedAppRichTexts() + if len(data) == 0 { + updateAppRichTexts() + data = getCachedAppRichTexts() + } + result := make(map[string]proto.AppRichTextItemResp, len(data)) + for _, item := range data { + title := strings.TrimSpace(item.Title) + if title == "" { + continue + } + result[title] = item + } + return result +} + +func buildFooterBrand(config proto.ConfigInfo) proto.FooterBrand { + return proto.FooterBrand{ + Title: config.WebSiteName, + Content: config.WebDescription, + } +} + +func buildFooterNavList() []proto.TranslationCategory { + bottom := translationser.GetTranslationBottomList() + return append([]proto.TranslationCategory{}, bottom.CategoryList...) +} + +func buildFooterContactList(config proto.ConfigInfo) []proto.FooterContact { + // 联系方式名称按页面截图固定返回,右侧只包含 E-mail、商务合作、官方群 三项。 + return []proto.FooterContact{ + {Name: "E-mail", Value: config.GmailAddr}, + {Name: "商务合作:立即联系", Value: config.Telegram}, + {Name: "官方群:立即加入", Value: firstNotEmpty(config.TgGroup, config.TelegramAdd)}, + } +} + +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)} +} + +func getCachedAppRichTexts() []proto.AppRichTextItemResp { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + return append([]proto.AppRichTextItemResp{}, CacheMgr.AppRichTexts...) +} + +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 +} + +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 +} + +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 +} + +// GetSiteSeoInfo 返回全站SEO配置,供统一渲染 title、meta keywords、description 和 canonical。 +func GetSiteSeoInfo() proto.SeoInfo { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + webAddr := normalizeWebAddr(CacheMgr.Config.WebAddr) + return proto.SeoInfo{ + Title: CacheMgr.Config.WebTitle, + Keywords: append([]string{}, CacheMgr.Config.WebKeyworld...), + Description: CacheMgr.Config.WebDescription, + WebSiteName: CacheMgr.Config.WebSiteName, + CanonicalURL: webAddr, + } +} + +// GetNavSeoInfo 返回导航页SEO配置 +func GetNavSeoInfo() proto.SeoNavInfo { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + return proto.SeoNavInfo{ + DHTitle: CacheMgr.Config.DHTitle, + DHKeyworld: append([]string{}, CacheMgr.Config.DHKeyworld...), + DHDescription: CacheMgr.Config.DHDescription, + DHSiteName: CacheMgr.Config.DHSiteName, + } +} + +// GetNavPageList 返回导航页线路集合。 +func GetNavPageList() []proto.NavPageItem { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + list := make([]proto.NavPageItem, 0, 3) + for _, item := range []proto.NavPageItem{ + {Line: 1, Url: strings.TrimSpace(CacheMgr.Config.DHNewUrlAddr1)}, + {Line: 2, Url: strings.TrimSpace(CacheMgr.Config.DHNewUrlAddr2)}, + {Line: 3, Url: strings.TrimSpace(CacheMgr.Config.DHNewUrlAddr3)}, + } { + if item.Url == "" { + continue + } + list = append(list, item) + } + return list +} + +func normalizeWebAddr(webAddr string) string { + webAddr = strings.TrimSpace(webAddr) + webAddr = strings.TrimRight(webAddr, "/") + if webAddr == "" { + return "" + } + if !strings.HasPrefix(webAddr, "http://") && !strings.HasPrefix(webAddr, "https://") { + return "https://" + webAddr + } + return webAddr +} + +func GetRandCDN() string { + config := GetConfigInfo() + return config.TsCdn +} + +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 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 updateAnnouncements() { + list, err := queryAnnouncements(context.Background()) + if err != nil { + log.Warn("查询公告失败", log.E(err)) + return + } + CacheMgr.Lock.Lock() + defer CacheMgr.Lock.Unlock() + CacheMgr.Announcements = list +} + +func queryAnnouncements(ctx context.Context) ([]*proto.AnnouncementItem, *srvdef.SrvErr) { + filter := bson.M{announcementmod.FN.Announcement.Status: true} + opts := options.Find(). + SetProjection(bson.M{ + announcementmod.FN.Announcement.Id: 1, + announcementmod.FN.Announcement.Title: 1, + announcementmod.FN.Announcement.Content: 1, + }). + SetSort(bson.D{ + {Key: announcementmod.FN.Announcement.Sort, Value: -1}, + {Key: announcementmod.FN.Announcement.UpdatedAt, Value: -1}, + }). + SetLimit(50) + list, err := announcementmod.FindManyAnnouncement(ctx, filter, opts) + if err != nil { + log.Warn("查询公告失败", log.Any("filter", filter), log.E(err)) + return nil, err + } + resp := make([]*proto.AnnouncementItem, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + resp = append(resp, &proto.AnnouncementItem{ + Id: item.Id, + Title: item.Title, + Content: item.Content, + }) + } + return resp, nil +} + +// GetAnnouncements 返回启用中的公告缓存,每10分钟刷新。 +func GetAnnouncements() []*proto.AnnouncementItem { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + if len(CacheMgr.Announcements) == 0 { + return make([]*proto.AnnouncementItem, 0) + } + resp := make([]*proto.AnnouncementItem, len(CacheMgr.Announcements)) + copy(resp, CacheMgr.Announcements) + return resp +} + +func CheckIpBlack(ip string) bool { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + if _, ok := CacheMgr.IpBlackMaps[ip]; ok { + return true + } + return false +} + +func GetVideoTagCategoryDataList() []proto.VideoTagCategoryData { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + rsl := make([]proto.VideoTagCategoryData, 0) + for _, v := range CacheMgr.TagCategoryList { + rsl = append(rsl, v) + } + return rsl +} +func Stop() { + dataReport.StopSDKConfigCron() + skdCron.Stop() +} + +func GetTag(tagId int64) *proto.TagsInfoData { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + tag, ok := CacheMgr.TagMaps[tagId] + if !ok { + return nil + } + return &tag +} diff --git a/app/service/configser/cdn.go b/app/service/configser/cdn.go new file mode 100644 index 0000000..7a3c875 --- /dev/null +++ b/app/service/configser/cdn.go @@ -0,0 +1,47 @@ +package configser + +import ( + "context" + "strings" + "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) +} diff --git a/app/service/configser/h265.go b/app/service/configser/h265.go new file mode 100644 index 0000000..6bc6669 --- /dev/null +++ b/app/service/configser/h265.go @@ -0,0 +1,22 @@ +package configser + +import "videoweb/app/api/proto" + +// 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 +} diff --git a/app/service/contactusser/contactus.go b/app/service/contactusser/contactus.go new file mode 100644 index 0000000..32f3ec7 --- /dev/null +++ b/app/service/contactusser/contactus.go @@ -0,0 +1,93 @@ +package contactusser + +import ( + "context" + "net/mail" + "strings" + "time" + "unicode/utf8" + "videoweb/app/api/proto" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/contactusmod" +) + +const ( + contactUsMaxEmailLen = 100 + contactUsMaxTitleLen = 100 + contactUsMaxContentLen = 1000 + contactUsMaxAttachmentNum = 9 + contactUsSubmitMessage = "提交成功,我们会在24小时内回复。" +) + +const ( + ContactUsTypeOrder int32 = 1 // 订单问题 + ContactUsTypeAccount int32 = 2 // 账号问题 + ContactUsTypeFeature int32 = 3 // 功能问题 + ContactUsTypeExp int32 = 4 // 体验问题 + ContactUsTypeOther int32 = 5 // 其他 +) + +// AddContactUs 提交联系我们记录 +func AddContactUs(ctx context.Context, userId int64, req *proto.ContactUsAddReq) (*proto.ContactUsAddResp, *srvdef.SrvErr) { + if req == nil || userId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + email := strings.TrimSpace(req.Email) + if !isValidEmail(email) || utf8.RuneCountInString(email) > contactUsMaxEmailLen { + return nil, srvdef.New(srvdef.ErrParamError, "邮箱格式错误") + } + if !isValidContactUsType(req.Type) { + return nil, srvdef.New(srvdef.ErrParamError, "问题类型无效") + } + title := strings.TrimSpace(req.Title) + if title == "" || utf8.RuneCountInString(title) > contactUsMaxTitleLen { + return nil, srvdef.New(srvdef.ErrParamError, "标题不能为空且不能超过100字") + } + content := strings.TrimSpace(req.Content) + if content == "" || utf8.RuneCountInString(content) > contactUsMaxContentLen { + return nil, srvdef.New(srvdef.ErrParamError, "内容不能为空且不能超过1000字") + } + attachments := normalizeAttachments(req.Attachments) + if len(attachments) > contactUsMaxAttachmentNum { + return nil, srvdef.New(srvdef.ErrParamError, "附件不能超过9个") + } + now := time.Now() + doc := &contactusmod.ContactUs{ + UserId: userId, + Email: email, + QuestionType: req.Type, + Title: title, + Content: content, + Attachments: attachments, + Status: 0, + CreatedAt: now, + UpdatedAt: now, + } + if err := contactusmod.InsertOneContactUs(ctx, doc); err != nil { + return nil, err + } + return &proto.ContactUsAddResp{Message: contactUsSubmitMessage}, nil +} + +func isValidEmail(email string) bool { + if email == "" { + return false + } + _, err := mail.ParseAddress(email) + return err == nil +} + +func isValidContactUsType(questionType int32) bool { + return questionType >= ContactUsTypeOrder && questionType <= ContactUsTypeOther +} + +func normalizeAttachments(rawAttachments []string) []string { + attachments := make([]string, 0, len(rawAttachments)) + for _, attachment := range rawAttachments { + attachment = strings.TrimSpace(attachment) + if attachment != "" { + attachments = append(attachments, attachment) + } + } + return attachments +} diff --git a/app/service/feedbackser/feedback.go b/app/service/feedbackser/feedback.go new file mode 100644 index 0000000..bfa28e0 --- /dev/null +++ b/app/service/feedbackser/feedback.go @@ -0,0 +1,77 @@ +package feedbackser + +import ( + "context" + "strings" + "time" + "unicode/utf8" + "videoweb/app/api/proto" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/feedbackmod" +) + +const ( + feedbackMaxContentLen = 500 + feedbackMaxContactLen = 100 + feedbackMaxScreenshotNum = 9 + feedbackSubmitMessage = "反馈提交成功" +) + +const ( //问题类型,1-功能建议、2-播放问题、3-账号问题、4-其他 + FeedbackTypeFeature int32 = 1 + FeedbackTypePlay int32 = 2 + FeedbackTypeAccount int32 = 3 + FeedbackTypeOther int32 = 4 +) + +// AddFeedback 提交用户意见反馈 +func AddFeedback(ctx context.Context, userId int64, req *proto.FeedbackReq) (*proto.FeedbackResp, *srvdef.SrvErr) { + if req == nil || userId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + if !isValidFeedbackType(req.Type) { + return nil, srvdef.New(srvdef.ErrParamError, "问题类型无效") + } + content := strings.TrimSpace(req.Content) + if content == "" || utf8.RuneCountInString(content) > feedbackMaxContentLen { + return nil, srvdef.New(srvdef.ErrParamError, "描述内容不能为空且不能超过500字") + } + contact := strings.TrimSpace(req.Contact) + if utf8.RuneCountInString(contact) > feedbackMaxContactLen { + return nil, srvdef.New(srvdef.ErrParamError, "联系方式不能超过100字") + } + screenshots := normalizeScreenshots(req.Screenshots) + if len(screenshots) > feedbackMaxScreenshotNum { + return nil, srvdef.New(srvdef.ErrParamError, "问题截图不能超过9张") + } + now := time.Now() + doc := &feedbackmod.Feedback{ + UserId: userId, + FeedbackType: req.Type, + Content: content, + Screenshots: screenshots, + Contact: contact, + Status: 0, + CreatedAt: now, + UpdatedAt: now, + } + if err := feedbackmod.InsertOneFeedback(ctx, doc); err != nil { + return nil, err + } + return &proto.FeedbackResp{Message: feedbackSubmitMessage}, nil +} + +func isValidFeedbackType(feedbackType int32) bool { + return feedbackType >= FeedbackTypeFeature && feedbackType <= FeedbackTypeOther +} + +func normalizeScreenshots(rawScreenshots []string) []string { + screenshots := make([]string, 0, len(rawScreenshots)) + for _, screenshot := range rawScreenshots { + screenshot = strings.TrimSpace(screenshot) + if screenshot != "" { + screenshots = append(screenshots, screenshot) + } + } + return screenshots +} 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/navigationser/header.go b/app/service/navigationser/header.go new file mode 100644 index 0000000..ba662f6 --- /dev/null +++ b/app/service/navigationser/header.go @@ -0,0 +1,138 @@ +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}, + {Key: translationcategorymod.FN.TranslationCategory.SeoInfo.Title, Value: 1}, + {Key: translationcategorymod.FN.TranslationCategory.SeoInfo.Description, Value: 1}, + {Key: translationcategorymod.FN.TranslationCategory.SeoInfo.Keyworld, 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.GetConfigInfo().ImgCdn + 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, + SeoInfo: item.SeoInfo, + 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/playletcache/cache.go b/app/service/playletcache/cache.go new file mode 100644 index 0000000..7638623 --- /dev/null +++ b/app/service/playletcache/cache.go @@ -0,0 +1,499 @@ +package playletcache + +import ( + "context" + "sort" + "strings" + "sync" + "videoweb/pub/common/log" + "videoweb/pub/models/hotsearchmod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/pub/models/playletthememod" + "videoweb/pub/pubdef" + + "github.com/robfig/cron/v3" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var ( + skdCron *cron.Cron + cache struct { + lock sync.RWMutex + themeByID map[int64]*ThemeItem + themeBySlug map[string]*ThemeItem + tagByID map[int64]*TagItem + tagBySlug map[string]*TagItem + hotSearch []HotSearchItem + guessLike []*playletmod.Playlet + } +) + +// HotSearchItem 热搜词缓存项 +type HotSearchItem struct { + Keyword string + Count int64 +} + +// ThemeItem 题材缓存项(含 SEO) +type ThemeItem struct { + Id int64 + Name string + Slug string + Icon string + Rank int64 + Status bool + IsRecommend bool + PlayletCount int64 + SeoInfo pubdef.SeoValue +} + +// TagItem 标签缓存项(含 SEO) +type TagItem struct { + Id int64 + Name string + Slug string + Rank int64 + Status bool + SeoInfo pubdef.SeoValue +} + +// Start 启动题材/标签/热搜词/猜你喜欢缓存,每 10 分钟刷新一次。 +func Start() { + skdCron = cron.New(cron.WithSeconds()) + UpdateCache(context.Background()) + _, _ = skdCron.AddFunc("0 */10 * * * ?", func() { + UpdateCache(context.Background()) + }) + skdCron.Start() +} + +// UpdateCache 从 Mongo 全量刷新题材、标签、热搜词和猜你喜欢缓存。 +func UpdateCache(ctx context.Context) { + updateThemeCache(ctx) + updateTagCache(ctx) + updateHotSearchCache(ctx) + updateGuessLikeCache(ctx) +} + +func updateThemeCache(ctx context.Context) { + list, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{}, 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.Status, Value: 1}, + {Key: playletthememod.FN.PlayletTheme.IsRecommend, Value: 1}, + {Key: "seoInfo", Value: 1}, + }). + SetSort(bson.D{ + {Key: playletthememod.FN.PlayletTheme.Rank, Value: -1}, + {Key: playletthememod.FN.PlayletTheme.Id, Value: -1}, + })) + if err != nil { + log.Warn("playletcache.updateThemeCache failed", log.E(err)) + return + } + countMap := countUpPlayletsByTheme(ctx) + temp := make(map[int64]*ThemeItem, len(list)) + tempBySlug := make(map[string]*ThemeItem, len(list)) + for _, item := range list { + if item == nil || item.Id <= 0 { + continue + } + cached := &ThemeItem{ + Id: item.Id, + Name: item.Name, + Slug: item.Slug, + Icon: item.Icon, + Rank: item.Rank, + Status: item.Status, + IsRecommend: item.IsRecommend, + PlayletCount: countMap[item.Id], + SeoInfo: item.SeoInfo, + } + temp[item.Id] = cached + if slug := strings.TrimSpace(item.Slug); slug != "" { + tempBySlug[slug] = cached + } + } + cache.lock.Lock() + cache.themeByID = temp + cache.themeBySlug = tempBySlug + cache.lock.Unlock() +} + +type themePlayletCount struct { + Id int64 `bson:"_id"` + Count int64 `bson:"count"` +} + +// countUpPlayletsByTheme 按题材统计已上架短剧数量。 +func countUpPlayletsByTheme(ctx context.Context) map[int64]int64 { + result := make(map[int64]int64) + list := make([]themePlayletCount, 0) + err := playletmod.AggregatePlaylet(ctx, &list, []bson.M{ + { + "$match": bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }, + }, + { + "$unwind": "$" + playletmod.FN.Playlet.ThemeIds, + }, + { + "$group": bson.M{ + "_id": "$" + playletmod.FN.Playlet.ThemeIds, + "count": bson.M{"$sum": 1}, + }, + }, + }) + if err != nil { + log.Warn("playletcache.countUpPlayletsByTheme failed", log.E(err)) + return result + } + for _, item := range list { + if item.Id <= 0 || item.Count <= 0 { + continue + } + result[item.Id] = item.Count + } + return result +} + +func updateTagCache(ctx context.Context) { + list, err := playlettagmod.FindManyPlayletTag(ctx, bson.M{}, options.Find(). + SetProjection(bson.D{ + {Key: playlettagmod.FN.PlayletTag.Id, Value: 1}, + {Key: playlettagmod.FN.PlayletTag.Name, Value: 1}, + {Key: playlettagmod.FN.PlayletTag.Slug, Value: 1}, + {Key: playlettagmod.FN.PlayletTag.Rank, Value: 1}, + {Key: playlettagmod.FN.PlayletTag.Status, Value: 1}, + {Key: "seoInfo", Value: 1}, + }). + SetSort(bson.D{ + {Key: playlettagmod.FN.PlayletTag.Rank, Value: -1}, + {Key: playlettagmod.FN.PlayletTag.Id, Value: -1}, + })) + if err != nil { + log.Warn("playletcache.updateTagCache failed", log.E(err)) + return + } + temp := make(map[int64]*TagItem, len(list)) + tempBySlug := make(map[string]*TagItem, len(list)) + for _, item := range list { + if item == nil || item.Id <= 0 { + continue + } + cached := &TagItem{ + Id: item.Id, + Name: item.Name, + Slug: item.Slug, + Rank: item.Rank, + Status: item.Status, + SeoInfo: item.SeoInfo, + } + temp[item.Id] = cached + if slug := strings.TrimSpace(item.Slug); slug != "" { + tempBySlug[slug] = cached + } + } + cache.lock.Lock() + cache.tagByID = temp + cache.tagBySlug = tempBySlug + cache.lock.Unlock() +} + +func updateHotSearchCache(ctx context.Context) { + list, err := hotsearchmod.FindTopHotSearch(ctx, hotsearchmod.TopLimit) + if err != nil { + log.Warn("playletcache.updateHotSearchCache failed", log.E(err)) + return + } + temp := make([]HotSearchItem, 0, len(list)) + for _, item := range list { + if item == nil || strings.TrimSpace(item.Keyword) == "" { + continue + } + temp = append(temp, HotSearchItem{ + Keyword: item.Keyword, + Count: item.Count, + }) + } + cache.lock.Lock() + cache.hotSearch = temp + cache.lock.Unlock() +} + +// ListHotSearch 返回缓存的前10个热搜词。 +func ListHotSearch() []HotSearchItem { + cache.lock.RLock() + defer cache.lock.RUnlock() + if len(cache.hotSearch) == 0 { + return make([]HotSearchItem, 0) + } + result := make([]HotSearchItem, len(cache.hotSearch)) + copy(result, cache.hotSearch) + return result +} + +// GetTheme 按 ID 取题材缓存;不存在返回 nil。 +func GetTheme(id int64) *ThemeItem { + if id <= 0 { + return nil + } + cache.lock.RLock() + defer cache.lock.RUnlock() + item := cache.themeByID[id] + if item == nil { + return nil + } + cp := *item + return &cp +} + +// GetEnabledTheme 按 ID 取上架题材;未上架或不存在返回 nil。 +func GetEnabledTheme(id int64) *ThemeItem { + item := GetTheme(id) + if item == nil || !item.Status { + return nil + } + return item +} + +// GetThemeBySlug 按 slug 取题材缓存;不存在返回 nil。 +func GetThemeBySlug(slug string) *ThemeItem { + slug = strings.TrimSpace(slug) + if slug == "" { + return nil + } + cache.lock.RLock() + defer cache.lock.RUnlock() + item := cache.themeBySlug[slug] + if item == nil { + return nil + } + cp := *item + return &cp +} + +// GetTag 按 ID 取标签缓存;不存在返回 nil。 +func GetTag(id int64) *TagItem { + if id <= 0 { + return nil + } + cache.lock.RLock() + defer cache.lock.RUnlock() + item := cache.tagByID[id] + if item == nil { + return nil + } + cp := *item + return &cp +} + +// GetEnabledTag 按 ID 取上架标签;未上架或不存在返回 nil。 +func GetEnabledTag(id int64) *TagItem { + item := GetTag(id) + if item == nil || !item.Status { + return nil + } + return item +} + +// ListEnabledTags 返回已上架标签列表,按缓存排序。 +func ListEnabledTags() []*TagItem { + cache.lock.RLock() + defer cache.lock.RUnlock() + result := make([]*TagItem, 0, len(cache.tagByID)) + seen := make(map[int64]struct{}, len(cache.tagByID)) + for _, item := range cache.tagBySlug { + if item == nil || !item.Status || item.Id <= 0 { + continue + } + if _, ok := seen[item.Id]; ok { + continue + } + cp := *item + result = append(result, &cp) + seen[item.Id] = struct{}{} + } + for _, item := range cache.tagByID { + if item == nil || !item.Status || item.Id <= 0 { + continue + } + if _, ok := seen[item.Id]; ok { + continue + } + cp := *item + result = append(result, &cp) + seen[item.Id] = struct{}{} + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].Rank == result[j].Rank { + return result[i].Id > result[j].Id + } + return result[i].Rank > result[j].Rank + }) + return result +} + +// GetTagBySlug 按 slug 取标签缓存;不存在返回 nil。 +func GetTagBySlug(slug string) *TagItem { + slug = strings.TrimSpace(slug) + if slug == "" { + return nil + } + cache.lock.RLock() + defer cache.lock.RUnlock() + item := cache.tagBySlug[slug] + if item == nil { + return nil + } + cp := *item + return &cp +} + +// ListEnabledThemes 返回全部上架题材,按 Rank、Id 倒序。 +func ListEnabledThemes() []*ThemeItem { + cache.lock.RLock() + defer cache.lock.RUnlock() + result := make([]*ThemeItem, 0, len(cache.themeByID)) + for _, item := range cache.themeByID { + if item == nil || !item.Status { + continue + } + cp := *item + result = append(result, &cp) + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].Rank != result[j].Rank { + return result[i].Rank > result[j].Rank + } + return result[i].Id > result[j].Id + }) + return result +} + +// ListEnabledThemesByPlayletCount 返回有上架短剧的启用题材,按绑定短剧数量倒序 +func ListEnabledThemesByPlayletCount(limit int) []*ThemeItem { + cache.lock.RLock() + defer cache.lock.RUnlock() + result := make([]*ThemeItem, 0, len(cache.themeByID)) + for _, item := range cache.themeByID { + if item == nil || !item.Status || item.PlayletCount <= 0 { + continue + } + cp := *item + result = append(result, &cp) + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].PlayletCount != result[j].PlayletCount { + return result[i].PlayletCount > result[j].PlayletCount + } + if result[i].Rank != result[j].Rank { + return result[i].Rank > result[j].Rank + } + return result[i].Id > result[j].Id + }) + if limit > 0 && len(result) > limit { + return result[:limit] + } + return result +} + +const homeRecommendThemeLimit = 4 + +// ListRecommendedThemes 返回已上架且推荐的题材,按 Rank、Id 倒序,最多 limit 条。 +func ListRecommendedThemes(limit int) []*ThemeItem { + if limit <= 0 { + limit = homeRecommendThemeLimit + } + cache.lock.RLock() + defer cache.lock.RUnlock() + result := make([]*ThemeItem, 0, limit) + for _, item := range cache.themeByID { + if item == nil || !item.Status || !item.IsRecommend { + continue + } + cp := *item + result = append(result, &cp) + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].Rank != result[j].Rank { + return result[i].Rank > result[j].Rank + } + return result[i].Id > result[j].Id + }) + if len(result) > limit { + result = result[:limit] + } + return result +} + +// ThemeNameMap 批量取题材名称(含未上架,便于列表展示历史题材名)。 +func ThemeNameMap(ids []int64) map[int64]string { + result := make(map[int64]string, len(ids)) + if len(ids) == 0 { + return result + } + cache.lock.RLock() + defer cache.lock.RUnlock() + for _, id := range ids { + if item := cache.themeByID[id]; item != nil { + result[id] = item.Name + } + } + return result +} + +// TagNameMap 批量取标签名称(含未上架)。 +func TagNameMap(ids []int64) map[int64]string { + result := make(map[int64]string, len(ids)) + if len(ids) == 0 { + return result + } + cache.lock.RLock() + defer cache.lock.RUnlock() + for _, id := range ids { + if item := cache.tagByID[id]; item != nil { + result[id] = item.Name + } + } + return result +} + +// ThemeSlugMap 批量取题材 slug(含未上架)。 +func ThemeSlugMap(ids []int64) map[int64]string { + result := make(map[int64]string, len(ids)) + if len(ids) == 0 { + return result + } + cache.lock.RLock() + defer cache.lock.RUnlock() + for _, id := range ids { + if item := cache.themeByID[id]; item != nil { + result[id] = item.Slug + } + } + return result +} + +// TagSlugMap 批量取标签 slug(含未上架)。 +func TagSlugMap(ids []int64) map[int64]string { + result := make(map[int64]string, len(ids)) + if len(ids) == 0 { + return result + } + cache.lock.RLock() + defer cache.lock.RUnlock() + for _, id := range ids { + if item := cache.tagByID[id]; item != nil { + result[id] = item.Slug + } + } + return result +} diff --git a/app/service/playletcache/guess_like.go b/app/service/playletcache/guess_like.go new file mode 100644 index 0000000..855c57f --- /dev/null +++ b/app/service/playletcache/guess_like.go @@ -0,0 +1,101 @@ +package playletcache + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/models/playletmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const GuessLikeLimit = 15 + +var guessLikeProjection = 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.MediaCreatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.ThemeIds, Value: 1}, + {Key: playletmod.FN.Playlet.Title, Value: 1}, + {Key: playletmod.FN.Playlet.PlayName, Value: 1}, + {Key: playletmod.FN.Playlet.Description, 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.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.HeatValue, 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.IsOriginal, Value: 1}, + {Key: playletmod.FN.Playlet.OperateType, Value: 1}, + {Key: playletmod.FN.Playlet.CreatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, + {Key: "seoInfo", Value: 1}, +} + +func updateGuessLikeCache(ctx context.Context) { + list, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }, options.Find(). + SetProjection(guessLikeProjection). + SetSort(bson.D{ + {Key: playletmod.FN.Playlet.CountCollect, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }). + SetLimit(GuessLikeLimit)) + if err != nil { + log.Warn("playletcache.updateGuessLikeCache failed", log.E(err)) + return + } + temp := make([]*playletmod.Playlet, 0, len(list)) + for _, item := range list { + if cloned := clonePlaylet(item); cloned != nil { + temp = append(temp, cloned) + } + } + cache.lock.Lock() + cache.guessLike = temp + cache.lock.Unlock() +} + +// ListGuessLike 返回收藏量最多的上架短剧,最多15部。 +func ListGuessLike() []*playletmod.Playlet { + cache.lock.RLock() + defer cache.lock.RUnlock() + if len(cache.guessLike) == 0 { + return make([]*playletmod.Playlet, 0) + } + result := make([]*playletmod.Playlet, 0, len(cache.guessLike)) + for _, item := range cache.guessLike { + if cloned := clonePlaylet(item); cloned != nil { + result = append(result, cloned) + } + } + return result +} + +func clonePlaylet(item *playletmod.Playlet) *playletmod.Playlet { + if item == nil || item.Id <= 0 { + return nil + } + cp := *item + if item.ThemeIds != nil { + cp.ThemeIds = append([]int64(nil), item.ThemeIds...) + } + if item.TagIds != nil { + cp.TagIds = append([]int64(nil), item.TagIds...) + } + return &cp +} diff --git a/app/service/playletser/home_config_test.go b/app/service/playletser/home_config_test.go new file mode 100644 index 0000000..39a5111 --- /dev/null +++ b/app/service/playletser/home_config_test.go @@ -0,0 +1,49 @@ +package playletser + +import ( + "reflect" + "testing" + "videoweb/pub/models/homeconfigmod" + "videoweb/pub/models/playletmod" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestHomeConfigDisplayLimit(t *testing.T) { + tests := []struct { + name string + config *homeconfigmod.HomeConfig + want int64 + }{ + {name: "旧题材配置", config: &homeconfigmod.HomeConfig{Type: homeconfigmod.TypeTheme}, want: 8}, + {name: "自定义题材数量", config: &homeconfigmod.HomeConfig{Type: homeconfigmod.TypeTheme, Limit: 5}, want: 5}, + {name: "旧短剧配置", config: &homeconfigmod.HomeConfig{Type: homeconfigmod.TypePlaylet}, want: 12}, + {name: "自定义短剧数量", config: &homeconfigmod.HomeConfig{Type: homeconfigmod.TypePlaylet, Limit: 20}, want: 20}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := homeConfigDisplayLimit(tt.config); got != tt.want { + t.Fatalf("homeConfigDisplayLimit() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestHomeConfigSortTypeFourUsesOnlineAt(t *testing.T) { + filter, sort := homeConfigPlayletQuery(homeconfigmod.SortTypeOnlineAt, 0) + if _, ok := filter[playletmod.FN.Playlet.Preview]; ok { + t.Fatal("上架时间排序不应过滤无预告片的短剧") + } + want := bson.D{ + {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + if !reflect.DeepEqual(sort, want) { + t.Fatalf("上架时间排序 = %v, want %v", sort, want) + } + + _, latestSort := homeConfigPlayletQuery(homeconfigmod.SortTypeLatest, 0) + if len(latestSort) == 0 || latestSort[0].Key != playletmod.FN.Playlet.UpdatedAt { + t.Fatalf("最近更新应按更新时间排序,得到 %v", latestSort) + } +} diff --git a/app/service/playletser/home_modules.go b/app/service/playletser/home_modules.go new file mode 100644 index 0000000..ebeff34 --- /dev/null +++ b/app/service/playletser/home_modules.go @@ -0,0 +1,231 @@ +package playletser + +import ( + "context" + "strings" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/playletcache" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/homeconfigmod" + "videoweb/pub/models/playletmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func homeConfigToModule(item *proto.HomeConfigData) *proto.HomeModuleData { + moduleType := int32(1) + if item.Type == homeconfigmod.TypeTheme { + moduleType = 2 + } + return &proto.HomeModuleData{Id: item.Id, Slug: item.Slug, Title: item.Name, Desc: item.Desc, Keyword: item.Keyword, Label: item.Label, + Style: item.Style, CardBackgroundImage: item.CardBackgroundImage, ModuleType: moduleType, DataType: 1, + RecommendType: item.RecommendType, CategoryId: item.CategoryId, Rank: item.Rank, Limit: item.Limit, SeoInfo: item.SeoInfo, + List: item.List, TopicList: item.ThemeList} +} + +// recommendType=0 保留历史 sortType 的含义 +func homeConfigPlayletQueryForConfig(config *homeconfigmod.HomeConfig) (bson.M, bson.D, int64) { + categoryID := config.CategoryId + if categoryID == 0 && config.RecommendType == 0 && config.Type == homeconfigmod.TypePlaylet { + categoryID = homeConfigCategoryID(config.SortType, config.Id) + } + if config.RecommendType == 0 { + filter, sort := homeConfigPlayletQuery(config.SortType, categoryID) + return filter, sort, categoryID + } + filter := homeUpPlayletFilter(nil) + field := playletmod.FN.Playlet.UpdatedAt + switch config.RecommendType { + case 2: + field = playletmod.FN.Playlet.CountLike + case 3: + field = playletmod.FN.Playlet.CountBrowse + case 4: + field = playletmod.FN.Playlet.HeatValue + case 5: + if categoryID > 0 { + filter[playletmod.FN.Playlet.CategoryIds] = categoryID + } + field = playletmod.FN.Playlet.RecommendRank + } + return filter, bson.D{{Key: field, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}}, categoryID +} + +func findHomeConfigPlayletListForConfig(ctx context.Context, config *homeconfigmod.HomeConfig, limit int64) (playletmod.PlayletList, *srvdef.SrvErr) { + if config.RecommendType == 0 { + _, _, categoryID := homeConfigPlayletQueryForConfig(config) + return findHomeConfigPlayletList(ctx, config.SortType, categoryID, limit) + } + filter, sort, categoryID := homeConfigPlayletQueryForConfig(config) + if config.RecommendType == 5 { + if categoryID <= 0 { + return make(playletmod.PlayletList, 0), nil + } + return playletmod.FindManyPlayletByCategoryRank(ctx, filter, categoryID, 0, limit, playletmod.CategoryRankSort(), playletHomeProjection) + } + return findHomePlaylets(ctx, filter, sort, limit) +} + +func findHomeConfigThemePlayletsForConfig(ctx context.Context, config *homeconfigmod.HomeConfig, skip, limit int64) ([]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) { + if len(config.TopicIds) == 0 { + if skip == 0 { + return findHomeConfigThemePlaylets(ctx, limit) + } + groups, _, all, err := findHomeConfigThemePlayletsPage(ctx, skip, limit) + return groups, all, err + } + selected := map[int64]bool{} + for _, id := range config.TopicIds { + selected[id] = true + } + result := make([]homeConfigThemePlaylets, 0) + all := make(playletmod.PlayletList, 0) + var offset int64 + for _, theme := range playletcache.ListEnabledThemesByPlayletCount(0) { + if theme == nil || !selected[theme.Id] { + continue + } + filter := topicDetailPlayletFilter(theme.Id) + count, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, nil, err + } + if count == 0 { + continue + } + if offset < skip { + offset++ + continue + } + if int64(len(result)) >= limit { + break + } + list, err := findHomePlaylets(ctx, filter, playletTopicDetailSort(topicDetailSortEdit), topicListPlayletLimit) + if err != nil { + return nil, nil, err + } + result = append(result, homeConfigThemePlaylets{Theme: theme, Playlets: list, TotalCount: count}) + all = append(all, list...) + offset++ + } + return result, all, nil +} + +func countHomeConfigThemes(ctx context.Context, config *homeconfigmod.HomeConfig) (int64, *srvdef.SrvErr) { + if len(config.TopicIds) == 0 { + _, count, _, err := findHomeConfigThemePlayletsPage(ctx, 0, 0) + return count, err + } + selected := map[int64]bool{} + for _, id := range config.TopicIds { + selected[id] = true + } + var count int64 + for _, theme := range playletcache.ListEnabledThemesByPlayletCount(0) { + if theme == nil || !selected[theme.Id] { + continue + } + filter := topicDetailPlayletFilter(theme.Id) + total, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return 0, err + } + if total > 0 { + count++ + } + } + return count, nil +} + +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[homeconfigmod.FN.HomeConfig.Slug] = slug + } else { + filter[homeconfigmod.FN.HomeConfig.Id] = req.Id + } + config, err := homeconfigmod.FindOneHomeConfig(ctx, filter) + if err != nil { + return nil, err + } + if config.Type != homeconfigmod.TypePlaylet && config.Type != homeconfigmod.TypeTheme { + return nil, srvdef.New(srvdef.ErrNoData, "首页配置不存在", "") + } + pageNum, pageSize := req.PageNum, req.PageSize + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + module := &proto.HomeModuleData{Id: config.Id, Slug: config.Slug, Title: config.Name, Desc: config.Desc, Keyword: homeconfigmod.NormalizeKeywords(config.Keyword), Label: config.Label, + Style: config.Style, CardBackgroundImage: withImageDomain(ctx, config.CardBackgroundImage), DataType: 1, + RecommendType: config.RecommendType, CategoryId: config.CategoryId, Rank: config.Rank, Limit: homeConfigDisplayLimit(config), SeoInfo: config.SeoInfo, + List: make([]*proto.CommonData, 0), TopicList: make([]*proto.HomeConfigThemeData, 0)} + if config.Type == homeconfigmod.TypeTheme { + module.ModuleType = 2 + } else { + module.ModuleType = 1 + } + resp := &proto.PlayletHomeConfigListResp{Config: module, PageNum: int(pageNum), PageSize: int(pageSize), + List: make([]*proto.CommonData, 0), TopicList: make([]*proto.HomeConfigThemeData, 0), Advitise: configser.GetAdvitise()} + if !config.Status { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if config.Type == homeconfigmod.TypePlaylet { + playletFilter, sort, categoryID := homeConfigPlayletQueryForConfig(config) + if (config.RecommendType == 5 || (config.RecommendType == 0 && config.SortType == homeconfigmod.SortTypeCategory)) && categoryID <= 0 { + return resp, nil + } + resp.TotalCount, err = playletmod.CountPlaylet(ctx, playletFilter) + if err != nil { + return nil, err + } + var list playletmod.PlayletList + if config.RecommendType == 5 || (config.RecommendType == 0 && config.SortType == homeconfigmod.SortTypeCategory) { + list, err = playletmod.FindManyPlayletByCategoryRank(ctx, playletFilter, categoryID, skip, pageSize, playletmod.CategoryRankSort(), playletHomeProjection) + } else { + list, err = findHomePlaylets(ctx, playletFilter, sort, pageSize, options.Find().SetSkip(skip)) + } + if err != nil { + return nil, err + } + themeNames, err := buildHomeThemeNameMap(ctx, list) + if err != nil { + return nil, err + } + tagNames, err := buildHomeTagNameMap(ctx, list) + if err != nil { + return nil, err + } + resp.List = playletsToHomeCommonData(ctx, list, themeNames, tagNames) + return resp, nil + } + resp.TotalCount, err = countHomeConfigThemes(ctx, config) + if err != nil { + return nil, err + } + groups, all, err := findHomeConfigThemePlayletsForConfig(ctx, config, skip, pageSize) + if err != nil { + return nil, err + } + themeNames, err := buildHomeThemeNameMap(ctx, all) + if err != nil { + return nil, err + } + tagNames, err := buildHomeTagNameMap(ctx, all) + if err != nil { + return nil, err + } + resp.TopicList = homeConfigThemesToHomeData(ctx, groups, themeNames, tagNames) + return resp, nil +} diff --git a/app/service/playletser/home_modules_test.go b/app/service/playletser/home_modules_test.go new file mode 100644 index 0000000..7fae693 --- /dev/null +++ b/app/service/playletser/home_modules_test.go @@ -0,0 +1,38 @@ +package playletser + +import ( + "testing" + "videoweb/pub/models/homeconfigmod" + "videoweb/pub/models/playletmod" +) + +func TestHomeConfigPlayletQueryForConfig(t *testing.T) { + tests := []struct { + name string + config homeconfigmod.HomeConfig + field string + category int64 + recommended bool + }{ + {name: "legacy recommendation", config: homeconfigmod.HomeConfig{SortType: homeconfigmod.SortTypeRecommend}, field: playletmod.FN.Playlet.RecommendRank, recommended: true}, + {name: "new likes", config: homeconfigmod.HomeConfig{RecommendType: 2}, field: playletmod.FN.Playlet.CountLike}, + {name: "new category", config: homeconfigmod.HomeConfig{RecommendType: 5, CategoryId: 42}, field: playletmod.FN.Playlet.RecommendRank, category: 42}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter, sort, category := homeConfigPlayletQueryForConfig(&tt.config) + if category != tt.category { + t.Fatalf("category = %d, want %d", category, tt.category) + } + if len(sort) == 0 || sort[0].Key != tt.field { + t.Fatalf("sort = %v, want %s", sort, tt.field) + } + if _, ok := filter[playletmod.FN.Playlet.IsRecommend]; ok != tt.recommended { + t.Fatalf("recommend filter = %v, want %v", ok, tt.recommended) + } + if tt.category > 0 && filter[playletmod.FN.Playlet.CategoryIds] != tt.category { + t.Fatalf("category filter = %v", filter) + } + }) + } +} diff --git a/app/service/playletser/like_list.go b/app/service/playletser/like_list.go new file mode 100644 index 0000000..365907f --- /dev/null +++ b/app/service/playletser/like_list.go @@ -0,0 +1,91 @@ +package playletser + +import ( + "context" + "videoweb/app/api/proto" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletlikemod" + "videoweb/pub/models/playletmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// LikeList 分页查询当前用户点赞的短剧 +func LikeList(ctx context.Context, userID int64, req *proto.PlayletLikeListReq) (*proto.PlayletLikeListResp, *srvdef.SrvErr) { + if userID <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + filter := bson.M{playletlikemod.FN.PlayletLike.UserId: userID} + totalCount, err := playletlikemod.CountPlayletLike(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletLikeListResp{ + TotalCount: totalCount, PageNum: int(pageNum), PageSize: int(pageSize), + List: make([]proto.PlayletLikeData, 0), + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + likes, err := playletlikemod.FindManyPlayletLike(ctx, filter, options.Find(). + SetProjection(bson.D{ + {Key: playletlikemod.FN.PlayletLike.Id, Value: 1}, + {Key: playletlikemod.FN.PlayletLike.PlayletId, Value: 1}, + {Key: playletlikemod.FN.PlayletLike.CreatedAt, Value: 1}, + }). + SetSort(bson.D{ + {Key: playletlikemod.FN.PlayletLike.CreatedAt, Value: -1}, + {Key: playletlikemod.FN.PlayletLike.Id, Value: -1}, + }). + SetSkip(skip). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + ids := make([]int64, 0, len(likes)) + for _, like := range likes { + if like != nil && like.PlayletId > 0 { + ids = append(ids, like.PlayletId) + } + } + if len(ids) == 0 { + return resp, nil + } + playlets, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": ids}, + }, options.Find().SetProjection(playletHomeProjection)) + if err != nil { + return nil, err + } + themeNameMap, err := buildHomeThemeNameMap(ctx, playlets) + if err != nil { + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, playlets) + if err != nil { + return nil, err + } + dataByID := make(map[int64]*proto.CommonData, len(playlets)) + for _, data := range playletsToHomeCommonData(ctx, playlets, themeNameMap, tagNameMap) { + dataByID[data.Id] = data + } + for _, like := range likes { + if like == nil { + continue + } + data := dataByID[like.PlayletId] + if data == nil { + continue + } + resp.List = append(resp.List, proto.PlayletLikeData{ + CommonData: *data, IsLike: 1, LikedAt: like.CreatedAt, + }) + } + return resp, nil +} diff --git a/app/service/playletser/operate_random.go b/app/service/playletser/operate_random.go new file mode 100644 index 0000000..45333e1 --- /dev/null +++ b/app/service/playletser/operate_random.go @@ -0,0 +1,81 @@ +package playletser + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const operateRandomModulus int64 = 2147483647 + +func operateListFilter(typ int64) bson.M { + filter := homeUpPlayletFilter(nil) + if typ == operateListOriginal { + filter[playletmod.FN.Playlet.IsRecommend] = true + } + return filter +} + +func operateListSort(typ int64) bson.D { + if typ == operateListHot { + return bson.D{{Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}} + } + return playletRecentlyUpdatedSort() +} + +// findRandomOperatePlaylets 按每日固定的伪随机顺序分页查询短剧 +func findRandomOperatePlaylets(ctx context.Context, filter bson.M, skip, limit int64, now time.Time) (playletmod.PlayletList, *srvdef.SrvErr) { + var rows []struct { + Id int64 `bson:"_id"` + } + if err := playletmod.AggregatePlaylet(ctx, &rows, operateRandomPipeline(filter, skip, limit, now)); err != nil { + return nil, err + } + ids := make([]int64, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.Id) + } + if len(ids) == 0 { + return make(playletmod.PlayletList, 0), nil + } + queryFilter := bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}} + for key, value := range filter { + queryFilter[key] = value + } + list, err := playletmod.FindManyPlaylet(ctx, queryFilter, options.Find().SetProjection(playletHomeProjection)) + if err != nil { + return nil, err + } + byID := make(map[int64]*playletmod.Playlet, len(list)) + for _, item := range list { + if item != nil { + byID[item.Id] = item + } + } + ordered := make(playletmod.PlayletList, 0, len(ids)) + for _, id := range ids { + if item := byID[id]; item != nil { + ordered = append(ordered, item) + } + } + return ordered, nil +} + +func operateRandomPipeline(filter bson.M, skip, limit int64, now time.Time) []bson.M { + day := (now.Unix() + 8*60*60) / (24 * 60 * 60) + factor := int64(48271) + day%997*2767 + idMod := bson.M{"$mod": bson.A{"$_id", operateRandomModulus}} + score := bson.M{"$mod": bson.A{bson.M{"$multiply": bson.A{idMod, factor}}, operateRandomModulus}} + return []bson.M{ + {"$match": filter}, + {"$project": bson.M{"_id": 1}}, + {"$addFields": bson.M{"randomScore": score}}, + {"$sort": bson.D{{Key: "randomScore", Value: 1}, {Key: "_id", Value: 1}}}, + {"$skip": skip}, + {"$limit": limit}, + } +} diff --git a/app/service/playletser/operate_random_test.go b/app/service/playletser/operate_random_test.go new file mode 100644 index 0000000..ff00539 --- /dev/null +++ b/app/service/playletser/operate_random_test.go @@ -0,0 +1,46 @@ +package playletser + +import ( + "reflect" + "testing" + "time" + "videoweb/pub/models/playletmod" + + "go.mongodb.org/mongo-driver/bson" +) + +func TestOperateListFiltersAndSorts(t *testing.T) { + for _, typ := range []int64{operateListLatest, operateListHot, operateListOriginal, operateListRandom} { + filter := operateListFilter(typ) + if filter[playletmod.FN.Playlet.Status] != playletmod.StatusUp { + t.Fatalf("type %d must contain the up status filter", typ) + } + _, recommend := filter[playletmod.FN.Playlet.IsRecommend] + if recommend != (typ == operateListOriginal) { + t.Fatalf("type %d recommend filter = %v", typ, recommend) + } + } + if got := operateListSort(operateListLatest)[0].Key; got != playletmod.FN.Playlet.UpdatedAt { + t.Fatalf("latest sort = %s", got) + } + if got := operateListSort(operateListHot)[0].Key; got != playletmod.FN.Playlet.HeatValue { + t.Fatalf("hot sort = %s", got) + } +} + +func TestOperateRandomPipelineIsStableWithinDay(t *testing.T) { + filter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp} + day := time.Date(2026, 10, 24, 1, 0, 0, 0, time.UTC) + first := operateRandomPipeline(filter, 20, 10, day) + second := operateRandomPipeline(filter, 20, 10, day.Add(time.Hour)) + if !reflect.DeepEqual(first, second) { + t.Fatal("random order changed within one China Standard Time day") + } + third := operateRandomPipeline(filter, 20, 10, day.Add(24*time.Hour)) + if reflect.DeepEqual(first, third) { + t.Fatal("random order did not change on the next China Standard Time day") + } + if first[4]["$skip"] != int64(20) || first[5]["$limit"] != int64(10) { + t.Fatalf("pagination stages = %v", first) + } +} diff --git a/app/service/playletser/playlet.go b/app/service/playletser/playlet.go new file mode 100644 index 0000000..65d35a6 --- /dev/null +++ b/app/service/playletser/playlet.go @@ -0,0 +1,4633 @@ +package playletser + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/playletcache" + "videoweb/app/service/reportser" + "videoweb/app/service/videoser" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/log" + "videoweb/pub/common/netutil" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/esmanager" + "videoweb/pub/models/cointransmod" + "videoweb/pub/models/esmod" + "videoweb/pub/models/homeconfigmod" + "videoweb/pub/models/hotsearchmod" + "videoweb/pub/models/playletchaptercollectmod" + "videoweb/pub/models/playletchapterlikemod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletchapterunlockmod" + "videoweb/pub/models/playletcollectmod" + "videoweb/pub/models/playletlikemod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playletwatchhistorymod" + "videoweb/pub/models/playletwatchprogressmod" + "videoweb/pub/models/usermod" + "videoweb/pub/mongos" + "videoweb/pub/payment" + "videoweb/pub/pubdef" + "videoweb/pub/redis" + "videoweb/pub/seo/h5path" + "videoweb/web/service/srv_search" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + homeBannerLimit int64 = 5 // banner图限制 + homeConfigPlayletLimit int64 = 12 // 旧首页配置短剧默认数量 + homeConfigThemeLimit int64 = 8 // 首页配置题材默认数量 + defaultPageSize int64 = 10 // 默认分页条数 + guessLikePageSize int64 = 6 // 猜你喜欢默认分页条数 + hotListPageSize int64 = 6 // 热门短剧默认分页条数 + hotRankPageSize int64 = 20 // 热榜默认分页条数 + recommendPageSize int64 = 20 // 今日推荐/热门好剧默认分页条数 + maxPageSize int64 = 100 // 最大分页条数 + allListSortLatest int64 = 1 // 最新 + allListSortHot int64 = 2 // 最热 + allListSortCollect int64 = 3 // 收藏最多 + topicListSortHot int64 = 2 // 最热 + topicListPlayletLimit int64 = 3 // 专题卡片短剧数量限制 + topicDetailSortEdit int64 = 1 // 编辑排序 + topicDetailSortLatest int64 = 2 // 最新 + topicDetailSortHot int64 = 3 // 最热 + topicDetailPageSize int64 = 12 // 专题详情默认分页条数 + topicDetailCoverLimit int64 = 3 // 专题详情顶部封面数量限制 + topicDetailMoreLimit int64 = 3 // 更多专题数量限制 + operateListLatest int64 = 1 // 最新更新 + operateListHot int64 = 2 // 当前热播 + operateListOriginal int64 = 3 // 独家原创 + operateListRandom int64 = 4 // 随机推荐 + rankHot int64 = 1 // 热播榜 + rankNew int64 = 2 // 新剧榜 + rankCollect int64 = 3 // 追剧榜 + operateStatusYes int64 = 1 // 已操作 + operateStatusNo int64 = 2 // 未操作 + playletHeatClickInc int64 = 100 // 每次查看详情增加的热度值 + playletHeatLikeInc int64 = 1 // 点赞增加的热度值 + playletHeatCollectInc int64 = 2 // 收藏增加的热度值 + playletHeatBuyInc int64 = 10 // 购买剧集增加的短剧热度值 + playletHeatForwardInc int64 = 5 // 转发增加的热度值 + homePlayletCacheSchema = "playlet-banner-home-config-modules-v14" + // playletH5DetailPath 短剧Web/H5播放页路径,供SEO canonical使用。 + playletH5DetailPath = "/play" + // playletChapterH5DetailPath 短剧Web/H5剧集播放页路径,供SEO canonical使用。 + playletChapterH5DetailPath = "/play" +) + +var errPlayletChapterAlreadyUnlocked = errors.New("playlet chapter already unlocked") + +type playletChapterAccessContext struct { + balance int64 + vipValid bool + unlockedChapterIds map[int64]bool +} + +type homeConfigThemePlaylets struct { + Theme *playletcache.ThemeItem + Playlets playletmod.PlayletList + TotalCount int64 +} + +type playletBoughtAggRow struct { + PlayletId int64 `bson:"_id"` + BoughtChapterCount int64 `bson:"boughtChapterCount"` + CostCoins int64 `bson:"costCoins"` + BoughtAt time.Time `bson:"boughtAt"` + LatestUnlockId int64 `bson:"latestUnlockId"` +} + +type playletBoughtCountRow struct { + Count int64 `bson:"count"` +} + +// playletHomeProjection 查询指定字段 +var playletHomeProjection = bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.MediaCenterId, 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.MobileBanner, 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.ThemeIds, Value: 1}, + {Key: playletmod.FN.Playlet.CategoryIds, Value: 1}, + {Key: playletmod.FN.Playlet.Title, Value: 1}, + {Key: playletmod.FN.Playlet.PlayName, Value: 1}, + {Key: playletmod.FN.Playlet.Description, 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.HorizontalCover, Value: 1}, + {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, + {Key: playletmod.FN.Playlet.CompressedHorizontalCover, Value: 1}, + {Key: playletmod.FN.Playlet.Preview, 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.HeatValue, 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.IsOriginal, Value: 1}, + {Key: playletmod.FN.Playlet.ShowOriginal, Value: 1}, + {Key: playletmod.FN.Playlet.OperateType, Value: 1}, + {Key: playletmod.FN.Playlet.CreatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, + {Key: "seoInfo", Value: 1}, +} + +// homeConfigAppProjection 查询首页配置指定字段 +var homeConfigAppProjection = bson.D{ + {Key: homeconfigmod.FN.HomeConfig.Id, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Name, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Slug, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Desc, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Label, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Style, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.CardBackgroundImage, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.TopicIds, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.CategoryId, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.RecommendType, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Type, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Rank, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Limit, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.SortType, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Status, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.SeoInfo.Title, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.SeoInfo.Description, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.SeoInfo.Keyworld, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.CreatedAt, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.UpdatedAt, Value: 1}, +} + +// playletCollectListProjection 我的收藏列表只读取卡片展示和未看集数计算需要的字段。 +var playletCollectListProjection = bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.MediaCenterId, Value: 1}, + {Key: playletmod.FN.Playlet.IsOriginal, Value: 1}, + {Key: playletmod.FN.Playlet.ShowOriginal, Value: 1}, + {Key: playletmod.FN.Playlet.Title, Value: 1}, + {Key: playletmod.FN.Playlet.PlayName, Value: 1}, + {Key: playletmod.FN.Playlet.Slug, Value: 1}, + {Key: playletmod.FN.Playlet.Cover, Value: 1}, + {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, + {Key: playletmod.FN.Playlet.ThemeIds, 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.HeatValue, Value: 1}, + {Key: playletmod.FN.Playlet.CountCollect, Value: 1}, + {Key: playletmod.FN.Playlet.CountLike, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1}, + {Key: "seoInfo", Value: 1}, +} + +// playletChapterSEOProjection 查询剧集详情SEO需要的短剧字段,避免拉取整部短剧大字段。 +var playletChapterSEOProjection = bson.D{ + {Key: playletmod.FN.Playlet.Id, 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.IsOriginal, Value: 1}, + {Key: "seoInfo", Value: 1}, +} + +// playletChapterInfoProjection 查询详情章节集合需要的字段 +var playletChapterInfoProjection = bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Md5, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Hash, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Height, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Width, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Ratio, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.VideoUrl, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.MediaSize, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.PayType, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.SingleCoins, Value: 1}, +} + +// playletChapterDetailProjection 查询剧集详情需要的字段 +var playletChapterDetailProjection = bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Md5, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Hash, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Height, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Width, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Ratio, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.VideoUrl, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.MediaSize, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CountComment, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CountBrowse, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CountCollect, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CountLike, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.PayType, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.SingleCoins, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.WatermarkStatus, Value: 1}, +} + +// playletOperateProjection 查询短剧操作接口需要的字段 +var playletOperateProjection = bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.CountLike, Value: 1}, + {Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1}, + {Key: playletmod.FN.Playlet.CountCollect, Value: 1}, +} + +// playletChapterOperateProjection 查询剧集操作接口需要的字段 +var playletChapterOperateProjection = bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CountLike, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CountCollect, Value: 1}, +} + +// playletChapterWatchReportProjection 观看上报只需要剧集归属和集数。 +var playletChapterWatchReportProjection = bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, +} + +// playletWatchReportProjection 观看上报只需要短剧总集数计算未看数量。 +var playletWatchReportProjection = bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, +} + +// Home 查询app首页短剧数据 +func Home(ctx context.Context) (*proto.HomePlayletResponse, *srvdef.SrvErr) { + cacheKey := homePlayletCacheKey() + if resp := getHomePlayletCache(cacheKey); resp != nil { + attachHomeAnnouncements(resp) + attachHomeAdvitise(resp) + return resp, nil + } + resp, err := buildHomePlayletResponse(ctx) + if err != nil { + return nil, err + } + setHomePlayletCache(cacheKey, resp) + attachHomeAnnouncements(resp) + attachHomeAdvitise(resp) + return resp, nil +} + +// AllList 分页查询app端全部短剧列表 +func AllList(ctx context.Context, req *proto.PlayletAllListReq) (*proto.PlayletAllListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + themeID, err := resolveThemeID(ctx, req.ThemeId, req.ThemeSlug) + if err != nil { + return nil, err + } + req.ThemeId = themeID + filter, err := playletAllListFilter(req) + if err != nil { + return nil, err + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletAllListResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.CommonData, 0), + Advitise: configser.GetAdvitise(), + } + if req.ThemeId > 0 { + resp.SeoInfo = buildPlayletThemePageSeoInfo(req.ThemeId) + } else { + resp.SeoInfo = buildPlayletListPageSeoInfo() + } + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + list, err := findHomePlaylets(ctx, filter, playletAllListSort(req.SortType), pageSize, options.Find().SetSkip(skip)) + if err != nil { + return nil, err + } + themeNameMap, err := buildHomeThemeNameMap(ctx, list) + if err != nil { + log.Error("查询全部短剧列表题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, list) + if err != nil { + log.Error("查询全部短剧列表标签名称失败", log.E(err)) + return nil, err + } + resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) + return resp, nil +} + +// HotList 分页查询热门短剧 +func HotList(ctx context.Context, req *proto.PlayletHotListReq) (*proto.PlayletHotListResp, *srvdef.SrvErr) { + if req == nil || req.ExcludePlayletId < 0 { + return nil, srvdef.New(srvdef.ErrParamError, "排除的短剧ID无效", "") + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = hotListPageSize + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, pageSize) + filter := homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.OperateType: bson.M{"$ne": playletmod.OperateTypeComic}, + }) + if req.ExcludePlayletId > 0 { + filter[playletmod.FN.Playlet.Id] = bson.M{"$ne": req.ExcludePlayletId} + } + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletHotListResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.CommonData, 0), + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + list, err := findHomePlaylets(ctx, filter, playletAllListSort(allListSortHot), pageSize, options.Find().SetSkip(skip)) + if err != nil { + return nil, err + } + themeNameMap, err := buildHomeThemeNameMap(ctx, list) + if err != nil { + log.Error("查询热门短剧题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, list) + if err != nil { + log.Error("查询热门短剧标签名称失败", log.E(err)) + return nil, err + } + resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) + return resp, nil +} + +// TopicList 分页查询app端专题剧单 +func TopicList(ctx context.Context, req *proto.PlayletTopicPageListReq) (*proto.PlayletTopicPageListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + skip := (pageNum - 1) * pageSize + groups, err := enabledTopicGroups(ctx) + if err != nil { + return nil, err + } + resp := &proto.PlayletTopicPageListResp{ + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.PlayletTopicGroupPageItem, 0, len(groups)), + SeoInfo: buildPlayletListPageSeoInfo(), + } + for _, group := range groups { + if group == nil { + continue + } + page, err := topicGroupPageData(ctx, group, skip, pageSize) + if err != nil { + return nil, err + } + resp.List = append(resp.List, page) + } + return resp, nil +} + +// TopicDetail 查询app端专题详情 +func TopicDetail(ctx context.Context, req *proto.PlayletTopicDetailReq) (*proto.PlayletTopicDetailResp, *srvdef.SrvErr) { + if req == nil || (req.Id <= 0 && strings.TrimSpace(req.TopicSlug) == "") || (req.DataType != 0 && req.DataType != 1) { + return nil, srvdef.New(srvdef.ErrParamError, "专题ID无效", "") + } + id, serr := resolveThemeID(ctx, req.Id, req.TopicSlug) + if serr != nil { + return nil, serr + } + req.Id = id + theme := playletcache.GetEnabledTheme(req.Id) + if theme == nil { + return nil, srvdef.New(srvdef.ErrNoData, "专题不存在或已下架", "专题不存在或已下架") + } + pageNum, pageSize := normalizePlayletTopicDetailPage(req.PageNum, req.PageSize) + filter := topicDetailPlayletFilter(req.Id) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletTopicDetailResp{ + Id: theme.Id, + DataType: 1, + Advitise: configser.GetAdvitise(), + Name: theme.Name, + Slug: theme.Slug, + Desc: strings.TrimSpace(theme.SeoInfo.Description), + Icon: withImageDomain(ctx, theme.Icon), + Rank: theme.Rank, + PlayletCount: 0, + Covers: make([]string, 0), + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.CommonData, 0), + MoreTopics: make([]*proto.PlayletTopicPageItem, 0), + SeoInfo: buildPageSeoInfoFromValue(theme.SeoInfo, theme.Name, "", buildPlayletThemeListCanonicalURL(theme.Slug, theme.Id)), + } + if totalCount == 0 { + return fillPlayletTopicDetailMore(ctx, resp, theme.Id, nil) + } + coverList, err := findHomePlaylets(ctx, filter, playletTopicDetailSort(topicDetailSortEdit), topicDetailCoverLimit) + if err != nil { + return nil, err + } + skip := (pageNum - 1) * pageSize + pageList := make(playletmod.PlayletList, 0) + if skip < totalCount { + pageList, err = findHomePlaylets(ctx, filter, playletTopicDetailSort(req.SortType), pageSize, options.Find().SetSkip(skip)) + if err != nil { + return nil, err + } + } + allPlaylets := mergeHomePlayletLists(coverList, pageList) + resp, err = fillPlayletTopicDetailMore(ctx, resp, theme.Id, allPlaylets) + if err != nil { + return nil, err + } + themeNameMap, err := buildHomeThemeNameMap(ctx, allPlaylets) + if err != nil { + log.Error("查询专题详情题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, allPlaylets) + if err != nil { + log.Error("查询专题详情标签名称失败", log.E(err)) + return nil, err + } + resp.Covers = playletTopicCovers(playletsToHomeCommonData(ctx, coverList, themeNameMap, tagNameMap)) + resp.List = commonDataValues(playletsToHomeCommonData(ctx, pageList, themeNameMap, tagNameMap)) + resp.PlayletCount = int64(len(resp.List)) + return resp, nil +} + +// Rank 分页查询app端短剧排行榜 +func Rank(ctx context.Context, req *proto.PlayletRankReq) (*proto.PlayletRankResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + filter := homeUpPlayletFilter(nil) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletRankResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.CommonData, 0), + } + resp.SeoInfo = buildPlayletListPageSeoInfo() + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + list, err := findHomePlaylets(ctx, filter, playletRankSort(req.SortOrder), pageSize, options.Find().SetSkip(skip)) + if err != nil { + return nil, err + } + themeNameMap, err := buildHomeThemeNameMap(ctx, list) + if err != nil { + log.Error("查询短剧排行榜题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, list) + if err != nil { + log.Error("查询短剧排行榜标签名称失败", log.E(err)) + return nil, err + } + resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) + return resp, nil +} + +// HotRankList 分页查询app端热榜短剧列表 +func HotRankList(ctx context.Context, req *proto.PlayletHotRankListReq) (*proto.PlayletHotRankListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + rankType := normalizePlayletHotRankType(req.RankType) + if rankType == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "榜单类型无效", "") + } + pageNum, pageSize := normalizePlayletHotRankPage(req.PageNum, req.PageSize) + filter := homeUpPlayletFilter(nil) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletHotRankListResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + RankType: rankType, + List: make([]proto.CommonData, 0), + SeoInfo: buildPlayletListPageSeoInfo(), + } + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + list, err := findHomePlaylets(ctx, filter, playletHotRankSort(rankType), pageSize, options.Find().SetSkip(skip)) + if err != nil { + return nil, err + } + themeNameMap, err := buildHomeThemeNameMap(ctx, list) + if err != nil { + log.Error("查询热榜题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, list) + if err != nil { + log.Error("查询热榜标签名称失败", log.E(err)) + return nil, err + } + resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) + return resp, nil +} + +// TodayUpdate 分页查询app端今日更新短剧列表 +func TodayUpdate(ctx context.Context, req *proto.PlayletTodayUpdateReq) (*proto.PlayletTodayUpdateResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + filter := homeUpPlayletFilter(nil) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletTodayUpdateResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.CommonData, 0), + } + resp.SeoInfo = buildPlayletListPageSeoInfo() + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + list, err := findHomePlaylets(ctx, filter, playletTodayUpdateSort(), pageSize, options.Find().SetSkip(skip)) + if err != nil { + return nil, err + } + themeNameMap, err := buildHomeThemeNameMap(ctx, list) + if err != nil { + log.Error("查询今日更新短剧题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, list) + if err != nil { + log.Error("查询今日更新短剧标签名称失败", log.E(err)) + return nil, err + } + resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) + return resp, nil +} + +// OperateList 按类型分页查询已上架短剧 +func OperateList(ctx context.Context, req *proto.PlayletOperateListReq) (*proto.PlayletOperateListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + typ := req.Type + if typ == 0 { + typ = operateListLatest + } + if typ < operateListLatest || typ > operateListRandom { + return nil, srvdef.New(srvdef.ErrParamError, "类型无效", "") + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + filter := operateListFilter(typ) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletOperateListResp{ + TotalCount: totalCount, PageNum: int(pageNum), PageSize: int(pageSize), Type: typ, + List: make([]proto.CommonData, 0), + } + skip := (pageNum - 1) * pageSize + if totalCount == 0 || skip >= totalCount { + return resp, nil + } + var list playletmod.PlayletList + if typ == operateListRandom { + list, err = findRandomOperatePlaylets(ctx, filter, skip, pageSize, time.Now()) + } else { + list, err = findHomePlaylets(ctx, filter, operateListSort(typ), pageSize, options.Find().SetSkip(skip)) + } + if err != nil { + return nil, err + } + themeNameMap, err := buildHomeThemeNameMap(ctx, list) + if err != nil { + log.Error("查询类型短剧列表题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, list) + if err != nil { + log.Error("查询类型短剧列表标签名称失败", log.E(err)) + return nil, err + } + resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) + return resp, nil +} + +// TagVideoList 根据标签ID分页查询app端短剧列表 +func TagVideoList(ctx context.Context, req *proto.PlayletTagVideoReq) (*proto.PlayletTagVideoResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "标签ID不能为空") + } + tagID, err := resolveTagID(ctx, req.TagId, req.TagSlug) + if err != nil { + return nil, err + } + req.TagId = tagID + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + filter := homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.TagIds: req.TagId, + }) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletTagVideoResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.CommonData, 0), + } + resp.SeoInfo = buildPlayletTagPageSeoInfo(req.TagId) + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + list, err := findHomePlaylets(ctx, filter, playletAllListSort(req.SortType), pageSize, options.Find().SetSkip(skip)) + if err != nil { + return nil, err + } + themeNameMap, err := buildHomeThemeNameMap(ctx, list) + if err != nil { + log.Error("查询标签短剧题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, list) + if err != nil { + log.Error("查询标签短剧标签名称失败", log.E(err)) + return nil, err + } + resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) + return resp, nil +} + +// GuessLikeList 分页查询同题材猜你喜欢短剧 +func GuessLikeList(ctx context.Context, req *proto.PlayletGuessLikeListReq) (*proto.PlayletGuessLikeListResp, *srvdef.SrvErr) { + if req == nil || req.PlayletId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") + } + current, err := findGuessLikeCurrentPlaylet(ctx, req.PlayletId) + if err != nil { + return nil, err + } + pageNum, pageSize := normalizePlayletGuessLikePage(req.PageNum, req.PageSize) + resp := &proto.PlayletGuessLikeListResp{ + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.CommonData, 0), + } + if current == nil || len(current.ThemeIds) == 0 { + return resp, nil + } + filter := playletGuessLikeFilter(current) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp.TotalCount = totalCount + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + list, err := findHomePlaylets(ctx, filter, playletGuessLikeSort(), pageSize, options.Find().SetSkip(skip)) + if err != nil { + return nil, err + } + themeNameMap, err := buildHomeThemeNameMap(ctx, list) + if err != nil { + log.Error("查询猜你喜欢题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, list) + if err != nil { + log.Error("查询猜你喜欢标签名称失败", log.E(err)) + return nil, err + } + resp.List = commonDataValues(playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)) + return resp, nil +} + +// Search 使用ES分页搜索app端短剧 +func Search(ctx context.Context, req *proto.PlayletSearchReq) (*proto.PlayletSearchResp, *srvdef.SrvErr) { + // 校验搜索关键词,避免空关键词扫全量ES索引。 + if req == nil || strings.TrimSpace(req.Keyword) == "" { + return nil, srvdef.New(srvdef.ErrParamError, "搜索关键词不能为空", "搜索关键词不能为空") + } + if esmanager.Instance == nil { + log.Error("短剧搜索ES未初始化") + return nil, srvdef.New(srvdef.ErrESSearchError, "", "") + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + keyword := strings.TrimSpace(req.Keyword) + if pageNum == 1 { + recordHotSearch(keyword) + } + query := playletSearchQuery(keyword, (pageNum-1)*pageSize, pageSize) + var hits esmod.ESPlayletHits + if err := esmanager.Instance.Search(esmod.GetPlayletIndex(), &hits, query); err != nil { + if strings.Contains(err.Error(), "index_not_found_exception") { + log.Warn("短剧ES索引不存在,返回空搜索结果", log.Any("关键词", keyword), log.Any("索引", esmod.GetPlayletIndex())) + return emptyPlayletSearchResp(ctx, keyword, pageNum, pageSize), nil + } + log.Error("短剧ES搜索失败", log.Any("关键词", keyword), log.E(err)) + return nil, srvdef.New(srvdef.ErrESSearchError, "", "") + } + resp := &proto.PlayletSearchResp{ + TotalCount: hits.Total.Value, + PageSize: int(pageSize), + PageNum: int(pageNum), + Keyword: keyword, + List: make([]proto.CommonData, 0, len(hits.Hits)), + EveryoneWatching: make([]*proto.CommonData, 0), + } + if hits.Total.Value == 0 { + resp.EveryoneWatching = guessLikeCommonData(ctx) + return resp, nil + } + labels, err := loadESPlayletLabels(ctx, hits.Hits) + if err != nil { + return nil, err + } + for _, hit := range hits.Hits { + if hit == nil || hit.Source == nil { + continue + } + resp.List = append(resp.List, esPlayletToCommonData(ctx, hit.Source, labels[hit.Source.Id])) + } + return resp, nil +} + +// SearchSuggest 查询短剧搜索框联想数据 +func SearchSuggest(ctx context.Context, req *proto.PlayletSearchSuggestReq) (*proto.PlayletSearchSuggestResp, *srvdef.SrvErr) { + if req == nil || strings.TrimSpace(req.Keyword) == "" { + return nil, srvdef.New(srvdef.ErrParamError, "搜索关键词不能为空", "搜索关键词不能为空") + } + keyword := strings.TrimSpace(req.Keyword) + resp := &proto.PlayletSearchSuggestResp{ + Keyword: keyword, + List: make([]proto.CommonData, 0), + } + if esmanager.Instance == nil { + return resp, nil + } + query := playletSearchQuery(keyword, 0, maxPageSize) + var hits esmod.ESPlayletHits + if err := esmanager.Instance.Search(esmod.GetPlayletIndex(), &hits, query); err != nil { + log.Warn("短剧搜索联想ES查询失败", log.Any("关键词", keyword), log.E(err)) + return resp, nil + } + labels, err := loadESPlayletLabels(ctx, hits.Hits) + if err != nil { + return nil, err + } + for _, hit := range hits.Hits { + if hit == nil || hit.Source == nil { + continue + } + resp.List = append(resp.List, esPlayletToCommonData(ctx, hit.Source, labels[hit.Source.Id])) + } + return resp, nil +} + +func recordHotSearch(keyword string) { + keyword = hotsearchmod.NormalizeKeyword(keyword) + if keyword == "" { + return + } + go func(word string) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := hotsearchmod.IncrByKeyword(ctx, word); err != nil { + log.Warn("记录热搜词失败", log.Any("关键词", word), log.E(err)) + } + }(keyword) +} + +// HotLike 返回缓存的热搜词和猜你喜欢。 +func HotLike(ctx context.Context) *proto.PlayletHotLikeResp { + hotSearch := playletcache.ListHotSearch() + resp := &proto.PlayletHotLikeResp{ + HotSearch: make([]proto.PlayletHotSearchItem, 0, len(hotSearch)), + GuessLike: guessLikeCommonData(ctx), + } + for _, item := range hotSearch { + resp.HotSearch = append(resp.HotSearch, proto.PlayletHotSearchItem{ + Keyword: item.Keyword, + Count: item.Count, + }) + } + return resp +} + +// SetLike 设置当前用户短剧点赞状态 +func SetLike(ctx context.Context, userId int64, req *proto.PlayletLikeSetReq) (*proto.PlayletLikeSetResp, *srvdef.SrvErr) { + // 使用指针布尔值校验是否传参,保证 false 能表示取消点赞。 + if req == nil || req.Id <= 0 || req.IsLike == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") + } + playlet, changed, err := setPlayletLike(ctx, userId, req.Id, *req.IsLike) + if err != nil { + return nil, err + } + countLike := displayPlayletCountLike(applyLocalCounter(playlet.CountLike, *req.IsLike, changed), playlet.VirtualCountLike) + resp := &proto.PlayletLikeSetResp{ + Id: req.Id, + IsLike: operateStatusByFlag(*req.IsLike), + CountLike: countLike, + } + if changed { + reportser.ReportPlayletLike(ctx, userId, req.Id, resp.IsLike) + } + return resp, nil +} + +// SetCollect 设置当前用户短剧收藏状态 +func SetCollect(ctx context.Context, userId int64, req *proto.PlayletCollectSetReq) (*proto.PlayletCollectSetResp, *srvdef.SrvErr) { + // 使用指针布尔值校验是否传参,保证 false 能表示取消收藏。 + if req == nil || req.Id <= 0 || req.IsCollect == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") + } + playlet, changed, err := setPlayletCollect(ctx, userId, req.Id, *req.IsCollect) + if err != nil { + return nil, err + } + countCollect := applyLocalCounter(playlet.CountCollect, *req.IsCollect, changed) + resp := &proto.PlayletCollectSetResp{ + Id: req.Id, + IsCollect: operateStatusByFlag(*req.IsCollect), + CountCollect: countCollect, + } + if changed { + reportser.ReportPlayletCollect(ctx, userId, req.Id, resp.IsCollect) + } + return resp, nil +} + +// ForwardHeat 短剧转发增加热度值 +func ForwardHeat(ctx context.Context, userId int64, ip string, req *proto.PlayletForwardHeatReq) (*proto.PlayletForwardHeatResp, *srvdef.SrvErr) { + if req == nil || req.Id <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") + } + limited, limitErr := checkPlayletForwardHeatLimit(userId, ip, req.Id) + if limitErr != nil { + log.Error("短剧转发热度频控失败", log.Any("用户ID", userId), log.Any("IP", ip), log.Any("短剧ID", req.Id), log.E(limitErr)) + } + if limited { + playlet, srvErr := findPlayletForwardHeatTarget(ctx, req.Id) + if srvErr != nil { + return nil, srvErr + } + return &proto.PlayletForwardHeatResp{ + Id: playlet.Id, + HeatValue: playlet.HeatValue, + }, nil + } + playlet, srvErr := incrementPlayletHeatValueAndReturn(ctx, req.Id, playletHeatForwardInc) + if srvErr != nil { + log.Error("短剧转发增加热度失败", log.Any("短剧ID", req.Id), log.E(srvErr)) + return nil, srvErr + } + return &proto.PlayletForwardHeatResp{ + Id: playlet.Id, + HeatValue: playlet.HeatValue, + }, nil +} + +// SetChapterLike 设置当前用户短剧剧集点赞状态 +func SetChapterLike(ctx context.Context, userId int64, req *proto.PlayletChapterLikeSetReq) (*proto.PlayletChapterLikeSetResp, *srvdef.SrvErr) { + // 使用指针布尔值校验是否传参,保证 false 能表示取消点赞。 + if req == nil || req.ChapterId <= 0 || req.IsLike == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") + } + chapter, changed, err := setPlayletChapterLike(ctx, userId, req.ChapterId, *req.IsLike) + if err != nil { + return nil, err + } + countLike := applyLocalCounter(chapter.CountLike, *req.IsLike, changed) + resp := &proto.PlayletChapterLikeSetResp{ + ChapterId: req.ChapterId, + IsLike: operateStatusByFlag(*req.IsLike), + CountLike: countLike, + } + if changed { + reportser.ReportPlayletChapterLike(ctx, userId, chapter.PlayletId, req.ChapterId, resp.IsLike) + } + return resp, nil +} + +// SetChapterCollect 设置当前用户短剧剧集收藏状态 +func SetChapterCollect(ctx context.Context, userId int64, req *proto.PlayletChapterCollectSetReq) (*proto.PlayletChapterCollectSetResp, *srvdef.SrvErr) { + // 使用指针布尔值校验是否传参,保证 false 能表示取消收藏。 + if req == nil || req.ChapterId <= 0 || req.IsCollect == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") + } + chapter, changed, err := setPlayletChapterCollect(ctx, userId, req.ChapterId, *req.IsCollect) + if err != nil { + return nil, err + } + countCollect := applyLocalCounter(chapter.CountCollect, *req.IsCollect, changed) + resp := &proto.PlayletChapterCollectSetResp{ + ChapterId: req.ChapterId, + IsCollect: operateStatusByFlag(*req.IsCollect), + CountCollect: countCollect, + } + if changed { + reportser.ReportPlayletChapterCollect(ctx, userId, chapter.PlayletId, req.ChapterId, resp.IsCollect) + } + return resp, nil +} + +// ReportChapterWatch 上报当前用户短剧剧集观看进度 +func ReportChapterWatch(ctx context.Context, userId int64, req *proto.PlayletChapterWatchReportReq) (*proto.PlayletChapterWatchReportResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil || req.ChapterId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") + } + filter := bson.M{ + playletchaptermod.FN.PlayletChapter.Id: req.ChapterId, + playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp, + } + if req.PlayletId > 0 { + filter[playletchaptermod.FN.PlayletChapter.PlayletId] = req.PlayletId + } + chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, filter, options.FindOne().SetProjection(playletChapterWatchReportProjection)) + if err != nil { + log.Error("查询短剧观看上报剧集失败", log.Any("用户ID", userId), log.Any("短剧ID", req.PlayletId), log.Any("剧集ID", req.ChapterId), log.E(err)) + return nil, err + } + if chapter.CurrentEpisode <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "剧集集数无效", "剧集集数无效") + } + playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: chapter.PlayletId, + }), options.FindOne().SetProjection(playletWatchReportProjection)) + if err != nil { + log.Error("查询短剧观看上报所属短剧失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", req.ChapterId), log.E(err)) + return nil, err + } + progress, err := playletwatchprogressmod.SavePlayletWatchProgress(ctx, userId, chapter.PlayletId, chapter.Id, chapter.CurrentEpisode, playlet.TotalEpisode, true) + if err != nil { + log.Error("保存短剧观看上报进度失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapter.Id), log.E(err)) + return nil, err + } + if err = playletwatchhistorymod.SavePlayletWatchHistory(ctx, userId, chapter.PlayletId, chapter.Id, chapter.CurrentEpisode); err != nil { + log.Error("保存短剧观看历史失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapter.Id), log.E(err)) + return nil, err + } + watchedCount := collectWatchedCount(progress, playlet.TotalEpisode) + return &proto.PlayletChapterWatchReportResp{ + PlayletId: chapter.PlayletId, + ChapterId: chapter.Id, + CurrentEpisode: chapter.CurrentEpisode, + WatchedCount: watchedCount, + UnwatchedCount: collectUnwatchedCount(playlet.TotalEpisode, watchedCount), + LastChapterId: collectLastChapterId(progress), + LastEpisode: collectLastEpisode(progress), + }, nil +} + +// ChapterDetail 根据ID查询app端短剧剧集详情 +func ChapterDetail(ctx context.Context, userId int64, req *proto.PlayletChapterInfoReq) (*proto.PlayletChapterInfoResp, *srvdef.SrvErr) { + // 播放页切换剧集只查询当前剧集和所属短剧状态,不重复查询整部短剧详情、相关推荐和全部剧集列表。 + if req == nil || req.ChapterId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "短剧剧集ID不能为空", "短剧剧集ID不能为空") + } + playletID, err := resolvePlayletID(ctx, req.PlayletId, req.PlayletSlug, false) + if err != nil { + return nil, err + } + req.PlayletId = playletID + filter := bson.M{ + playletchaptermod.FN.PlayletChapter.Id: req.ChapterId, + playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp, + } + if req.PlayletId > 0 { + filter[playletchaptermod.FN.PlayletChapter.PlayletId] = req.PlayletId + } + chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, filter, options.FindOne().SetProjection(playletChapterDetailProjection)) + if err != nil { + log.Error("查询短剧剧集详情失败", log.Any("短剧ID", req.PlayletId), log.Any("剧集ID", req.ChapterId), log.E(err)) + return nil, err + } + playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: chapter.PlayletId, + }), options.FindOne().SetProjection(playletChapterSEOProjection)) + if err != nil { + log.Error("查询短剧剧集详情所属短剧失败", log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapter.Id), log.E(err)) + return nil, err + } + if err = ensureOriginalChapterWatermarkReady(playlet, chapter); err != nil { + return nil, err + } + isLike, isCollect, err := findPlayletChapterUserOperateStatus(ctx, userId, chapter.Id) + if err != nil { + return nil, err + } + resp := playletChapterToInfoResp(chapter) + resp.IsLike = isLike + resp.IsCollect = isCollect + resp.SeoInfo = buildPlayletChapterDetailSeoInfo(playlet, chapter) + if err = applyPlayletChapterWatchPermission(ctx, userId, chapter, resp); err != nil { + return nil, err + } + if !resp.CanWatch { + resp.VideoUrl = "" + return resp, nil + } + if err = incrementPlayletChapterBrowse(ctx, chapter.Id); err != nil { + log.Error("增加短剧剧集观看量失败", log.Any("剧集ID", chapter.Id), log.E(err)) + return nil, err + } + resp.CountBrowse++ + if resp.VideoUrl != "" { + resp.VideoUrl = videoser.GetPlayletM3U8AuthParam( + chapter.Id, + resp.VideoUrl, + videoser.H5M3U8RequestIP(ctx), + strings.TrimSpace(chapter.WatermarkVideoUrl) == "", + userId, + ) + } + return resp, nil +} + +// ChapterList 根据短剧ID查询app端短剧剧集列表 +func ChapterList(ctx context.Context, req *proto.PlayletChapterListReq) (*proto.PlayletChapterListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") + } + playletID, err := resolvePlayletID(ctx, req.PlayletId, req.PlayletSlug, true) + if err != nil { + return nil, err + } + req.PlayletId = playletID + playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: req.PlayletId, + }), options.FindOne().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.IsOriginal, Value: 1}, + })) + if err != nil { + log.Error("查询短剧剧集列表所属短剧失败", log.Any("短剧ID", req.PlayletId), log.E(err)) + return nil, err + } + chapterInfo, err := findPlayletChapters(ctx, req.PlayletId, 0, playlet != nil && playlet.IsOriginal) + if err != nil { + return nil, err + } + return &proto.PlayletChapterListResp{ + PlayletId: req.PlayletId, + List: chapterInfo, + }, nil +} + +// UnlockChapter 使用金币逐集解锁短剧剧集。 +// +// 幂等性约束: +// 1. 事务前先查一次已解锁记录,命中时直接返回成功; +// 2. 事务内先插入 userId+chapterId 唯一解锁记录,再扣金币; +// 3. 并发重复请求只有一个能插入成功,其他请求按已解锁成功处理,不重复扣费。 +func UnlockChapter(ctx context.Context, userId int64, req *proto.PlayletChapterUnlockReq) (*proto.PlayletChapterUnlockResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil || req.PlayletId <= 0 || req.ChapterId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "短剧ID和剧集ID不能为空", "短剧ID和剧集ID不能为空") + } + chapter, playlet, err := findPlayletChapterForUnlock(ctx, req.PlayletId, req.ChapterId) + if err != nil { + return nil, err + } + if chapter.PayType == playletchaptermod.PayTypeFree { + user, err := findPlayletChapterAccessUser(ctx, userId) + if err != nil { + return nil, err + } + return &proto.PlayletChapterUnlockResp{ + PlayletId: chapter.PlayletId, + ChapterId: chapter.Id, + Unlocked: true, + CanWatch: true, + Balance: user.Recharge, + CostCoins: 0, + ChangeCoin: 0, + }, nil + } + if chapter.PayType == playletchaptermod.PayTypeVip { + return nil, srvdef.New(srvdef.ErrParamError, "VIP剧集需开通会员后观看", "VIP剧集需开通会员后观看") + } + if chapter.PayType != playletchaptermod.PayTypeCoin { + return nil, srvdef.New(srvdef.ErrParamError, "剧集支付方式无效", "剧集支付方式无效") + } + if chapter.SingleCoins <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "剧集金币价格无效", "剧集金币价格无效") + } + + unlocked, err := playletchapterunlockmod.ExistsPlayletChapterUnlock(ctx, userId, chapter.Id) + if err != nil { + return nil, err + } + user, err := findPlayletChapterAccessUser(ctx, userId) + if err != nil { + return nil, err + } + if unlocked { + return playletChapterUnlockResp(chapter, user.Recharge, 0), nil + } + if user.Recharge < chapter.SingleCoins { + return nil, srvdef.New(srvdef.UserNotEnoughMoney, "金币不足,请先充值", "金币不足,请先充值") + } + + orderNo := playletChapterUnlockOrderNo(userId, chapter.Id) + var txSrvErr *srvdef.SrvErr + var balanceAfter int64 + txErr := mongos.Shop.Transaction(ctx, func(sctx mongo.SessionContext) error { + inserted, srvErr := playletchapterunlockmod.InsertOnePlayletChapterUnlock(sctx, &playletchapterunlockmod.PlayletChapterUnlock{ + UserId: userId, + PlayletId: chapter.PlayletId, + ChapterId: chapter.Id, + Coins: chapter.SingleCoins, + OrderNo: orderNo, + }) + if srvErr != nil { + txSrvErr = srvErr + return srvErr + } + if !inserted { + return errPlayletChapterAlreadyUnlocked + } + + updatedUser, srvErr := usermod.UserPay(sctx, userId, chapter.SingleCoins) + if srvErr != nil { + txSrvErr = srvdef.New(srvdef.UserNotEnoughMoney, "金币不足,请先充值", "金币不足,请先充值") + return txSrvErr + } + balanceAfter = updatedUser.Recharge + + srvErr = payment.WriteCoinTrans(sctx, payment.WriteCoinTransOpt{ + UserId: userId, + UserName: updatedUser.NickName, + FlowDir: cointransmod.FlowOut, + TranType: cointransmod.TranTypeVideoUnlock, + Amount: chapter.SingleCoins, + OrderNo: orderNo, + RelatedId: chapter.Id, + Remark: fmt.Sprintf("解锁《%s》", playlet.Title), + }, balanceAfter) + if srvErr != nil { + txSrvErr = srvErr + return srvErr + } + return nil + }) + if txErr != nil { + if errors.Is(txErr, errPlayletChapterAlreadyUnlocked) { + user, err = findPlayletChapterAccessUser(ctx, userId) + if err != nil { + return nil, err + } + return playletChapterUnlockResp(chapter, user.Recharge, 0), nil + } + if txSrvErr != nil { + return nil, txSrvErr + } + log.Error("短剧剧集金币解锁事务失败", log.Any("userId", userId), log.Any("chapterId", chapter.Id), log.E(txErr)) + return nil, srvdef.New(srvdef.DBTransaction, "解锁失败,请稍后重试", "解锁失败,请稍后重试") + } + updatePlayletChapterUnlockStatsAsync(chapter.PlayletId, chapter.Id) + reportser.ReportPlayletUnlock(ctx, user, playlet, chapter, chapter.SingleCoins, user.Recharge, balanceAfter, orderNo) + return playletChapterUnlockResp(chapter, balanceAfter, -chapter.SingleCoins), nil +} + +// updatePlayletChapterUnlockStatsAsync 异步更新剧集购买数和短剧热度 +func updatePlayletChapterUnlockStatsAsync(playletId int64, chapterId int64) { + if playletId <= 0 || chapterId <= 0 { + return + } + go func() { + if err := playletchaptermod.UpdateOnePlayletChapter(context.Background(), bson.M{ + playletchaptermod.FN.PlayletChapter.Id: chapterId, + }, bson.M{ + "$inc": bson.M{playletchaptermod.FN.PlayletChapter.CountBuy: 1}, + }); err != nil { + log.Error("短剧剧集解锁后更新购买数失败", log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) + } + if err := incrementPlayletHeatValue(context.Background(), playletId, playletHeatBuyInc); err != nil { + log.Error("短剧剧集解锁后更新短剧热度失败", log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) + return + } + syncPlayletToESAsync(playletId, "短剧剧集解锁后同步ES失败") + }() +} + +// applyPlayletChapterWatchPermission 根据剧集支付方式填充播放权限,并在未授权时清空播放地址。 +func applyPlayletChapterWatchPermission(ctx context.Context, + userId int64, + chapter *playletchaptermod.PlayletChapter, + resp *proto.PlayletChapterInfoResp, +) *srvdef.SrvErr { + if chapter == nil || resp == nil { + return srvdef.New(srvdef.ErrNoData, "剧集不存在", "剧集不存在") + } + resp.CostCoins = chapter.SingleCoins + resp.CanWatch = false + resp.NeedUnlock = false + resp.NeedVip = false + resp.VipValid = false + resp.IsUnlocked = false + + if chapter.PayType == playletchaptermod.PayTypeFree { + resp.CanWatch = true + return nil + } + + var user *usermod.User + if userId > 0 { + var err *srvdef.SrvErr + user, err = findPlayletChapterAccessUser(ctx, userId) + if err != nil { + return err + } + resp.Balance = user.Recharge + resp.VipValid = isUserVipValid(user) + } + + switch chapter.PayType { + case playletchaptermod.PayTypeCoin: + if userId <= 0 { + resp.NeedUnlock = true + return nil + } + unlocked, err := playletchapterunlockmod.ExistsPlayletChapterUnlock(ctx, userId, chapter.Id) + if err != nil { + return err + } + resp.IsUnlocked = unlocked + resp.CanWatch = unlocked + resp.NeedUnlock = !unlocked + case playletchaptermod.PayTypeVip: + resp.CanWatch = resp.VipValid + resp.NeedVip = !resp.VipValid + default: + log.Warn("未知短剧剧集支付方式", log.Any("playletId", chapter.PlayletId), log.Any("chapterId", chapter.Id), log.Any("payType", chapter.PayType)) + resp.NeedUnlock = true + } + + if !resp.CanWatch { + resp.VideoUrl = "" + } + return nil +} + +// findPlayletChapterForUnlock 查询解锁所需的剧集和所属短剧,限制短剧必须已上架。 +func findPlayletChapterForUnlock(ctx context.Context, playletId int64, chapterId int64) (*playletchaptermod.PlayletChapter, *playletmod.Playlet, *srvdef.SrvErr) { + chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: chapterId, + playletchaptermod.FN.PlayletChapter.PlayletId: playletId, + playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp, + }, options.FindOne().SetProjection(playletChapterDetailProjection)) + if err != nil { + log.Error("查询待解锁短剧剧集失败", log.Any("playletId", playletId), log.Any("chapterId", chapterId), log.E(err)) + return nil, nil, err + } + playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: chapter.PlayletId, + }), options.FindOne().SetProjection(playletChapterSEOProjection)) + if err != nil { + log.Error("查询待解锁剧集所属短剧失败", log.Any("playletId", chapter.PlayletId), log.Any("chapterId", chapter.Id), log.E(err)) + return nil, nil, err + } + if err = ensureOriginalChapterWatermarkReady(playlet, chapter); err != nil { + return nil, nil, err + } + return chapter, playlet, nil +} + +// findPlayletChapterAccessUser 查询播放权限判断需要的用户字段,避免读取完整用户文档。 +func findPlayletChapterAccessUser(ctx context.Context, userId int64) (*usermod.User, *srvdef.SrvErr) { + user, err := usermod.FindOneUser(ctx, bson.M{ + usermod.FN.User.Id: userId, + }, options.FindOne().SetProjection(bson.D{ + {Key: usermod.FN.User.Id, Value: 1}, + {Key: usermod.FN.User.NickName, Value: 1}, + {Key: usermod.FN.User.Recharge, Value: 1}, + {Key: usermod.FN.User.CurrentVipExpire, Value: 1}, + })) + if err != nil || user == nil { + return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "") + } + return user, nil +} + +func isUserVipValid(user *usermod.User) bool { + return user != nil && user.CurrentVipExpire.After(time.Now()) +} + +func playletChapterUnlockOrderNo(userId int64, chapterId int64) string { + return fmt.Sprintf("JJS-%d-%d-%d", userId, chapterId, time.Now().UnixNano()) +} + +func playletChapterUnlockResp(chapter *playletchaptermod.PlayletChapter, balance int64, changeCoin int64) *proto.PlayletChapterUnlockResp { + return &proto.PlayletChapterUnlockResp{ + PlayletId: chapter.PlayletId, + ChapterId: chapter.Id, + Unlocked: true, + CanWatch: true, + Balance: balance, + CostCoins: chapter.SingleCoins, + ChangeCoin: changeCoin, + } +} + +// setPlayletLike 设置短剧点赞关系 +func setPlayletLike(ctx context.Context, userId int64, playletId int64, enabled bool) (*playletmod.Playlet, bool, *srvdef.SrvErr) { + // 操作接口必须登录,先查询短剧确保目标存在且已上架,再按点赞表真实变更决定是否更新点赞数。 + if userId <= 0 { + return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + playlet, err := findPlayletOperateTarget(ctx, userId, playletId) + if err != nil { + return nil, false, err + } + filter := bson.M{ + playletlikemod.FN.PlayletLike.UserId: userId, + playletlikemod.FN.PlayletLike.PlayletId: playletId, + } + changed := false + if enabled { + inserted, err := playletlikemod.InsertOnePlayletLike(ctx, &playletlikemod.PlayletLike{ + UserId: userId, + PlayletId: playletId, + }) + if err != nil { + log.Error("新增短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + return nil, false, err + } + changed = inserted + } else { + deleted, err := playletlikemod.DeleteOnePlayletLike(ctx, filter) + if err != nil { + log.Error("删除短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + return nil, false, err + } + changed = deleted > 0 + } + if changed { + delta := int64(1) + if !enabled { + delta = -1 + } + heatDelta := playletHeatLikeInc + if !enabled { + heatDelta = -playletHeatLikeInc + } + if err := incrementPlayletOperateCounter(ctx, playletId, playletmod.FN.Playlet.CountLike, delta, heatDelta); err != nil { + rollbackPlayletLike(ctx, userId, playletId, enabled) + return nil, false, err + } + // 真实点赞数和热度值已变更,异步同步 ES,保证搜索和榜单数据最终一致 + syncPlayletToESAsync(playletId, "短剧点赞状态同步ES失败") + } + return playlet, changed, nil +} + +// setPlayletCollect 设置短剧收藏关系 +func setPlayletCollect(ctx context.Context, userId int64, playletId int64, enabled bool) (*playletmod.Playlet, bool, *srvdef.SrvErr) { + // 操作接口必须登录,先查询短剧确保目标存在且已上架,再按收藏表真实变更决定是否更新收藏数。 + if userId <= 0 { + return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + playlet, err := findPlayletOperateTarget(ctx, userId, playletId) + if err != nil { + return nil, false, err + } + filter := bson.M{ + playletcollectmod.FN.PlayletCollect.UserId: userId, + playletcollectmod.FN.PlayletCollect.PlayletId: playletId, + } + changed := false + if enabled { + inserted, err := playletcollectmod.InsertOnePlayletCollect(ctx, &playletcollectmod.PlayletCollect{ + UserId: userId, + PlayletId: playletId, + }) + if err != nil { + log.Error("新增短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + return nil, false, err + } + changed = inserted + } else { + deleted, err := playletcollectmod.DeleteOnePlayletCollect(ctx, filter) + if err != nil { + log.Error("删除短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + return nil, false, err + } + changed = deleted > 0 + } + if changed { + delta := int64(1) + if !enabled { + delta = -1 + } + heatDelta := playletHeatCollectInc + if !enabled { + heatDelta = -playletHeatCollectInc + } + if err := incrementPlayletOperateCounter(ctx, playletId, playletmod.FN.Playlet.CountCollect, delta, heatDelta); err != nil { + rollbackPlayletCollect(ctx, userId, playletId, enabled) + return nil, false, err + } + // 真实收藏数和热度值已变更,异步同步 ES,保证追剧榜和搜索数据最终一致 + syncPlayletToESAsync(playletId, "短剧收藏状态同步ES失败") + } + return playlet, changed, nil +} + +// setPlayletChapterLike 设置短剧剧集点赞关系 +func setPlayletChapterLike(ctx context.Context, userId int64, chapterId int64, enabled bool) (*playletchaptermod.PlayletChapter, bool, *srvdef.SrvErr) { + // 剧集点赞必须登录,先校验剧集存在且所属短剧已上架,再按点赞关系真实变化更新 playlet_chapter.countLike。 + if userId <= 0 { + return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + chapter, err := findPlayletChapterOperateTarget(ctx, userId, chapterId) + if err != nil { + return nil, false, err + } + filter := bson.M{ + playletchapterlikemod.FN.PlayletChapterLike.UserId: userId, + playletchapterlikemod.FN.PlayletChapterLike.ChapterId: chapterId, + } + changed := false + if enabled { + inserted, err := playletchapterlikemod.InsertOnePlayletChapterLike(ctx, &playletchapterlikemod.PlayletChapterLike{ + UserId: userId, + PlayletId: chapter.PlayletId, + ChapterId: chapterId, + }) + if err != nil { + log.Error("新增短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err)) + return nil, false, err + } + changed = inserted + } else { + deleted, err := playletchapterlikemod.DeleteOnePlayletChapterLike(ctx, filter) + if err != nil { + log.Error("删除短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err)) + return nil, false, err + } + changed = deleted > 0 + } + if changed { + delta := int64(1) + if !enabled { + delta = -1 + } + if err := incrementPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountLike, delta); err != nil { + rollbackPlayletChapterLike(ctx, userId, chapter.PlayletId, chapterId, enabled) + return nil, false, err + } + heatDelta := playletHeatLikeInc + if !enabled { + heatDelta = -playletHeatLikeInc + } + if err := incrementPlayletHeatValue(ctx, chapter.PlayletId, heatDelta); err != nil { + log.Error("更新短剧剧集点赞对应短剧热度失败", log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.Any("增量", heatDelta), log.E(err)) + rollbackPlayletChapterLike(ctx, userId, chapter.PlayletId, chapterId, enabled) + rollbackPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountLike, delta) + return nil, false, err + } + } + return chapter, changed, nil +} + +// setPlayletChapterCollect 设置短剧剧集收藏关系 +func setPlayletChapterCollect(ctx context.Context, userId int64, chapterId int64, enabled bool) (*playletchaptermod.PlayletChapter, bool, *srvdef.SrvErr) { + // 剧集收藏必须登录,先校验剧集存在且所属短剧已上架,再按收藏关系真实变化更新 playlet_chapter.countCollect。 + if userId <= 0 { + return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + chapter, err := findPlayletChapterOperateTarget(ctx, userId, chapterId) + if err != nil { + return nil, false, err + } + filter := bson.M{ + playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId, + playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: chapterId, + } + changed := false + if enabled { + inserted, err := playletchaptercollectmod.InsertOnePlayletChapterCollect(ctx, &playletchaptercollectmod.PlayletChapterCollect{ + UserId: userId, + PlayletId: chapter.PlayletId, + ChapterId: chapterId, + }) + if err != nil { + log.Error("新增短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err)) + return nil, false, err + } + changed = inserted + } else { + deleted, err := playletchaptercollectmod.DeleteOnePlayletChapterCollect(ctx, filter) + if err != nil { + log.Error("删除短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err)) + return nil, false, err + } + changed = deleted > 0 + } + if changed { + delta := int64(1) + if !enabled { + delta = -1 + } + if err := incrementPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountCollect, delta); err != nil { + rollbackPlayletChapterCollect(ctx, userId, chapter.PlayletId, chapterId, enabled) + return nil, false, err + } + heatDelta := playletHeatCollectInc + if !enabled { + heatDelta = -playletHeatCollectInc + } + if err := incrementPlayletHeatValue(ctx, chapter.PlayletId, heatDelta); err != nil { + log.Error("更新短剧剧集收藏对应短剧热度失败", log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.Any("增量", heatDelta), log.E(err)) + rollbackPlayletChapterCollect(ctx, userId, chapter.PlayletId, chapterId, enabled) + rollbackPlayletChapterOperateCounter(ctx, chapterId, playletchaptermod.FN.PlayletChapter.CountCollect, delta) + return nil, false, err + } + } + return chapter, changed, nil +} + +// findPlayletOperateTarget 查询短剧操作目标 +func findPlayletOperateTarget(ctx context.Context, userId int64, playletId int64) (*playletmod.Playlet, *srvdef.SrvErr) { + // 操作前只读取计数字段,减少Mongo返回体;状态过滤保证下架短剧不能被点赞或收藏。 + playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: playletId, + }), options.FindOne().SetProjection(playletOperateProjection)) + if err != nil { + log.Error("查询短剧操作目标失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + return nil, err + } + return playlet, nil +} + +// findPlayletChapterOperateTarget 查询短剧剧集操作目标 +func findPlayletChapterOperateTarget(ctx context.Context, userId int64, chapterId int64) (*playletchaptermod.PlayletChapter, *srvdef.SrvErr) { + // 先用章节ID读取最小字段,再校验所属短剧已上架,避免用户操作已下架短剧的剧集。 + chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: chapterId, + playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp, + }, options.FindOne().SetProjection(playletChapterOperateProjection)) + if err != nil { + log.Error("查询短剧剧集操作目标失败", log.Any("用户ID", userId), log.Any("剧集ID", chapterId), log.E(err)) + return nil, err + } + if _, err = playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: chapter.PlayletId, + }), options.FindOne().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + })); err != nil { + log.Error("查询短剧剧集所属短剧失败", log.Any("用户ID", userId), log.Any("短剧ID", chapter.PlayletId), log.Any("剧集ID", chapterId), log.E(err)) + return nil, err + } + return chapter, nil +} + +// rollbackPlayletLike 回滚短剧点赞关系 +func rollbackPlayletLike(ctx context.Context, userId int64, playletId int64, enabled bool) { + // 点赞表已经变化但短剧点赞数更新失败时做补偿,尽量保持点赞关系和点赞数一致。 + filter := bson.M{ + playletlikemod.FN.PlayletLike.UserId: userId, + playletlikemod.FN.PlayletLike.PlayletId: playletId, + } + if enabled { + if _, err := playletlikemod.DeleteOnePlayletLike(ctx, filter); err != nil { + log.Error("回滚短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + } + return + } + if _, err := playletlikemod.InsertOnePlayletLike(ctx, &playletlikemod.PlayletLike{ + UserId: userId, + PlayletId: playletId, + }); err != nil { + log.Error("恢复短剧点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + } +} + +// rollbackPlayletCollect 回滚短剧收藏关系 +func rollbackPlayletCollect(ctx context.Context, userId int64, playletId int64, enabled bool) { + // 收藏表已经变化但短剧收藏数更新失败时做补偿,尽量保持收藏关系和收藏数一致。 + filter := bson.M{ + playletcollectmod.FN.PlayletCollect.UserId: userId, + playletcollectmod.FN.PlayletCollect.PlayletId: playletId, + } + if enabled { + if _, err := playletcollectmod.DeleteOnePlayletCollect(ctx, filter); err != nil { + log.Error("回滚短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + } + return + } + if _, err := playletcollectmod.InsertOnePlayletCollect(ctx, &playletcollectmod.PlayletCollect{ + UserId: userId, + PlayletId: playletId, + }); err != nil { + log.Error("恢复短剧收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + } +} + +// rollbackPlayletChapterLike 回滚短剧剧集点赞关系 +func rollbackPlayletChapterLike(ctx context.Context, userId int64, playletId int64, chapterId int64, enabled bool) { + // 剧集点赞关系已经变化但剧集点赞数更新失败时做补偿,尽量保持点赞关系和点赞数一致。 + filter := bson.M{ + playletchapterlikemod.FN.PlayletChapterLike.UserId: userId, + playletchapterlikemod.FN.PlayletChapterLike.ChapterId: chapterId, + } + if enabled { + if _, err := playletchapterlikemod.DeleteOnePlayletChapterLike(ctx, filter); err != nil { + log.Error("回滚短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) + } + return + } + if _, err := playletchapterlikemod.InsertOnePlayletChapterLike(ctx, &playletchapterlikemod.PlayletChapterLike{ + UserId: userId, + PlayletId: playletId, + ChapterId: chapterId, + }); err != nil { + log.Error("恢复短剧剧集点赞关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) + } +} + +// rollbackPlayletChapterCollect 回滚短剧剧集收藏关系 +func rollbackPlayletChapterCollect(ctx context.Context, userId int64, playletId int64, chapterId int64, enabled bool) { + // 剧集收藏关系已经变化但剧集收藏数更新失败时做补偿,尽量保持收藏关系和收藏数一致。 + filter := bson.M{ + playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId, + playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: chapterId, + } + if enabled { + if _, err := playletchaptercollectmod.DeleteOnePlayletChapterCollect(ctx, filter); err != nil { + log.Error("回滚短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) + } + return + } + if _, err := playletchaptercollectmod.InsertOnePlayletChapterCollect(ctx, &playletchaptercollectmod.PlayletChapterCollect{ + UserId: userId, + PlayletId: playletId, + ChapterId: chapterId, + }); err != nil { + log.Error("恢复短剧剧集收藏关系失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.Any("剧集ID", chapterId), log.E(err)) + } +} + +// incrementPlayletOperateCounter 增减短剧点赞或收藏计数 +func incrementPlayletOperateCounter(ctx context.Context, playletId int64, countField string, delta int64, heatDelta int64) *srvdef.SrvErr { + // 取消操作时增加 countField > 0 条件,避免历史脏数据或并发场景把计数减成负数。 + filter := bson.M{ + playletmod.FN.Playlet.Id: playletId, + } + if delta < 0 { + filter[countField] = bson.M{"$gt": 0} + } + incDoc := bson.M{ + countField: delta, + } + if heatDelta != 0 { + incDoc[playletmod.FN.Playlet.HeatValue] = heatDelta + } + err := playletmod.UpdateOnePlaylet(ctx, filter, bson.M{ + "$inc": incDoc, + }) + if err != nil { + if delta < 0 && err.Code == srvdef.ErrNoData { + log.Warn("短剧操作计数已为0,跳过扣减", log.Any("短剧ID", playletId), log.Any("计数字段", countField)) + return nil + } + log.Error("更新短剧操作计数失败", log.Any("短剧ID", playletId), log.Any("计数字段", countField), log.Any("增量", delta), log.E(err)) + return err + } + return nil +} + +// incrementPlayletChapterOperateCounter 增减短剧剧集点赞或收藏计数 +func incrementPlayletChapterOperateCounter(ctx context.Context, chapterId int64, countField string, delta int64) *srvdef.SrvErr { + // 取消操作时增加 countField > 0 条件,避免历史脏数据或并发场景把计数减成负数。 + filter := bson.M{ + playletchaptermod.FN.PlayletChapter.Id: chapterId, + } + if delta < 0 { + filter[countField] = bson.M{"$gt": 0} + } + err := playletchaptermod.UpdateOnePlayletChapter(ctx, filter, bson.M{ + "$inc": bson.M{ + countField: delta, + }, + }) + if err != nil { + if delta < 0 && err.Code == srvdef.ErrNoData { + log.Warn("短剧剧集操作计数已为0,跳过扣减", log.Any("剧集ID", chapterId), log.Any("计数字段", countField)) + return nil + } + log.Error("更新短剧剧集操作计数失败", log.Any("剧集ID", chapterId), log.Any("计数字段", countField), log.Any("增量", delta), log.E(err)) + return err + } + return nil +} + +func rollbackPlayletChapterOperateCounter(ctx context.Context, chapterId int64, countField string, delta int64) { + rollbackDelta := -delta + if err := incrementPlayletChapterOperateCounter(ctx, chapterId, countField, rollbackDelta); err != nil { + log.Error("回滚短剧剧集操作计数失败", log.Any("剧集ID", chapterId), log.Any("计数字段", countField), log.Any("增量", rollbackDelta), log.E(err)) + } +} + +// applyLocalCounter 根据本次关系变更结果计算响应计数 +func applyLocalCounter(current int64, enabled bool, changed bool) int64 { + // 响应计数基于操作前短剧计数和本次真实变更计算,避免为返回值再查一次短剧表。 + if !changed { + return current + } + if enabled { + return current + 1 + } + if current <= 0 { + return 0 + } + return current - 1 +} + +func displayPlayletCountLike(countLike int64, virtualCountLike int64) int64 { + return countLike + virtualCountLike +} + +// syncPlayletToESAsync 异步同步短剧到ES +func syncPlayletToESAsync(playletId int64, failMsg string) { + if playletId <= 0 { + return + } + go func() { + // 点赞、收藏入口只允许操作已上架短剧 + if err := srv_search.SyncPlayletStatusToES(context.Background(), []int64{playletId}, playletmod.StatusUp); err != nil { + log.Error(failMsg, log.Any("短剧ID", playletId), log.E(err)) + } + }() +} + +// operateStatusByFlag 转换操作状态响应值 +func operateStatusByFlag(enabled bool) int64 { + if enabled { + return operateStatusYes + } + return operateStatusNo +} + +// Detail 根据ID查询app端短剧详情 +func Detail(ctx context.Context, userId int64, req *proto.PlayletInfoReq) (*proto.PlayletInfoResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") + } + playletID, err := resolvePlayletID(ctx, req.Id, req.Slug, true) + if err != nil { + return nil, err + } + req.Id = playletID + item, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: req.Id, + }), options.FindOne().SetProjection(playletHomeProjection)) + if err != nil { + log.Error("查询短剧详情失败", log.Any("短剧ID", req.Id), log.E(err)) + return nil, err + } + if err = incrementPlayletBrowse(ctx, item.Id); err != nil { + log.Error("增加短剧观看量失败", log.Any("短剧ID", item.Id), log.E(err)) + return nil, err + } + item.CountBrowse++ + item.HeatValue += playletHeatClickInc + themeNameMap, err := buildHomeThemeNameMap(ctx, playletmod.PlayletList{item}) + if err != nil { + log.Error("查询短剧详情题材名称失败", log.Any("短剧ID", item.Id), log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, playletmod.PlayletList{item}) + if err != nil { + log.Error("查询短剧详情标签名称失败", log.Any("短剧ID", item.Id), log.E(err)) + return nil, err + } + commonList := playletsToHomeCommonData(ctx, playletmod.PlayletList{item}, themeNameMap, tagNameMap) + if len(commonList) == 0 || commonList[0] == nil { + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + chapterInfo, err := findPlayletChapters(ctx, item.Id, userId, item.IsOriginal) + if err != nil { + return nil, err + } + isLike, isCollect, err := findPlayletUserOperateStatus(ctx, userId, item.Id) + if err != nil { + return nil, err + } + resp := &proto.PlayletInfoResp{ + CommonData: *commonList[0], + IsLike: isLike, + IsCollect: isCollect, + PlayletChapterInfo: chapterInfo, + SeoInfo: buildPlayletDetailSeoInfo(item, themeNameMap, tagNameMap), + Advitise: configser.GetAdvitise(), + } + if resp.Preview != "" { + resp.Preview = videoser.GetM3U8AuthParam( + item.Id, + resp.Preview, + videoser.H5M3U8RequestIP(ctx), + "", + userId, + ) + } + return resp, nil +} + +// findPlayletUserOperateStatus 查询当前用户短剧点赞和收藏状态 +func findPlayletUserOperateStatus(ctx context.Context, userId int64, playletId int64) (int64, int64, *srvdef.SrvErr) { + // 游客或无有效token时没有用户ID,直接返回未点赞、未收藏,保证详情接口仍可匿名访问。 + if userId <= 0 { + return operateStatusNo, operateStatusNo, nil + } + likeExists, err := playletlikemod.ExistsPlayletLike(ctx, bson.M{ + playletlikemod.FN.PlayletLike.UserId: userId, + playletlikemod.FN.PlayletLike.PlayletId: playletId, + }) + if err != nil { + log.Error("查询短剧用户点赞状态失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + return operateStatusNo, operateStatusNo, err + } + collectExists, err := playletcollectmod.ExistsPlayletCollect(ctx, bson.M{ + playletcollectmod.FN.PlayletCollect.UserId: userId, + playletcollectmod.FN.PlayletCollect.PlayletId: playletId, + }) + if err != nil { + log.Error("查询短剧用户收藏状态失败", log.Any("用户ID", userId), log.Any("短剧ID", playletId), log.E(err)) + return operateStatusNo, operateStatusNo, err + } + isLike := operateStatusByFlag(likeExists) + isCollect := operateStatusByFlag(collectExists) + return isLike, isCollect, nil +} + +// CollectList 分页查询当前用户的短剧收藏列表 +func CollectList(ctx context.Context, userId int64, req *proto.PlayletCollectListReq) (*proto.PlayletCollectListResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + filter := bson.M{ + playletcollectmod.FN.PlayletCollect.UserId: userId, + } + if err := applyCollectWatchStatusFilter(ctx, userId, req.WatchStatus, filter); err != nil { + return nil, err + } + totalCount, err := playletcollectmod.CountPlayletCollect(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletCollectListResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.PlayletCollectData, 0), + } + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + collectList, err := playletcollectmod.FindManyPlayletCollect(ctx, filter, options.Find(). + SetProjection(bson.D{ + {Key: playletcollectmod.FN.PlayletCollect.Id, Value: 1}, + {Key: playletcollectmod.FN.PlayletCollect.PlayletId, Value: 1}, + {Key: playletcollectmod.FN.PlayletCollect.CreatedAt, Value: 1}, + }). + SetSort(bson.D{ + {Key: playletcollectmod.FN.PlayletCollect.CreatedAt, Value: -1}, + {Key: playletcollectmod.FN.PlayletCollect.Id, Value: -1}, + }). + SetSkip(skip). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + if len(collectList) == 0 { + return resp, nil + } + playletIds := collectPlayletIdsFromCollects(collectList) + playletMap, err := findCollectPlayletMap(ctx, playletIds) + if err != nil { + return nil, err + } + progressMap, err := findCollectWatchProgressMap(ctx, userId, playletIds) + if err != nil { + return nil, err + } + playletList := playletListFromMapByCollects(collectList, playletMap) + themeNameMap, err := buildHomeThemeNameMap(ctx, playletList) + if err != nil { + log.Error("查询我的收藏题材名称失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, playletList) + if err != nil { + log.Error("查询我的收藏标签名称失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + resp.List = buildPlayletCollectData(ctx, collectList, playletMap, progressMap, themeNameMap, tagNameMap) + return resp, nil +} + +func applyCollectWatchStatusFilter(ctx context.Context, userId int64, watchStatus int64, filter bson.M) *srvdef.SrvErr { + if watchStatus == 0 { + return nil + } + if watchStatus != 1 && watchStatus != 2 { + return srvdef.New(srvdef.ErrParamError, "观看状态错误", "观看状态错误") + } + playletIds, err := findCollectWatchStatusPlayletIds(ctx, userId, watchStatus == 1) + if err != nil { + return err + } + filter[playletcollectmod.FN.PlayletCollect.PlayletId] = bson.M{"$in": playletIds} + return nil +} + +func findCollectWatchStatusPlayletIds(ctx context.Context, userId int64, isChasing bool) ([]int64, *srvdef.SrvErr) { + list, err := playletwatchprogressmod.FindManyPlayletWatchProgress(ctx, bson.M{ + playletwatchprogressmod.FN.PlayletWatchProgress.UserId: userId, + playletwatchprogressmod.FN.PlayletWatchProgress.IsChasing: isChasing, + }, options.Find().SetProjection(bson.D{ + {Key: playletwatchprogressmod.FN.PlayletWatchProgress.PlayletId, Value: 1}, + })) + if err != nil { + log.Error("查询收藏列表观看状态短剧ID失败", log.Any("用户ID", userId), log.Any("是否追剧中", isChasing), log.E(err)) + return nil, err + } + playletIds := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, item := range list { + if item == nil || item.PlayletId <= 0 { + continue + } + if _, ok := seen[item.PlayletId]; ok { + continue + } + seen[item.PlayletId] = struct{}{} + playletIds = append(playletIds, item.PlayletId) + } + return playletIds, nil +} + +// BoughtList 分页查询当前用户已购短剧列表 +func BoughtList(ctx context.Context, userId int64, req *proto.PlayletBoughtListReq) (*proto.PlayletBoughtListResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + totalCount, err := countBoughtPlaylets(ctx, userId) + if err != nil { + return nil, err + } + resp := &proto.PlayletBoughtListResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.PlayletBoughtData, 0), + } + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + rows, err := findBoughtPlayletRows(ctx, userId, skip, pageSize) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return resp, nil + } + playletIds := collectPlayletIdsFromBoughtRows(rows) + playletMap, err := findCollectPlayletMap(ctx, playletIds) + if err != nil { + return nil, err + } + progressMap, err := findCollectWatchProgressMap(ctx, userId, playletIds) + if err != nil { + return nil, err + } + playletList := playletListFromBoughtRows(rows, playletMap) + themeNameMap, err := buildHomeThemeNameMap(ctx, playletList) + if err != nil { + log.Error("查询我的已购短剧题材名称失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, playletList) + if err != nil { + log.Error("查询我的已购短剧标签名称失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + resp.List = buildPlayletBoughtData(ctx, rows, playletMap, progressMap, themeNameMap, tagNameMap) + return resp, nil +} + +// countBoughtPlaylets 统计当前用户已购短剧数量 +func countBoughtPlaylets(ctx context.Context, userId int64) (int64, *srvdef.SrvErr) { + rows := make([]playletBoughtCountRow, 0, 1) + pipeline := []bson.M{ + { + "$match": bson.M{ + playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId, + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId: bson.M{"$gt": 0}, + }, + }, + { + "$group": bson.M{ + "_id": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId, + }, + }, + { + "$count": "count", + }, + } + if err := playletchapterunlockmod.AggregatePlayletChapterUnlock(ctx, &rows, pipeline); err != nil { + return 0, err + } + if len(rows) == 0 { + return 0, nil + } + return rows[0].Count, nil +} + +// findBoughtPlayletRows 聚合查询当前用户已购短剧分页数据 +func findBoughtPlayletRows(ctx context.Context, userId int64, skip int64, limit int64) ([]playletBoughtAggRow, *srvdef.SrvErr) { + rows := make([]playletBoughtAggRow, 0) + pipeline := []bson.M{ + { + "$match": bson.M{ + playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId, + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId: bson.M{"$gt": 0}, + }, + }, + { + "$group": bson.M{ + "_id": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId, + "boughtChapterCount": bson.M{"$sum": 1}, + "costCoins": bson.M{"$sum": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.Coins}, + "boughtAt": bson.M{"$max": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.CreatedAt}, + "latestUnlockId": bson.M{"$max": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.Id}, + }, + }, + { + "$sort": bson.D{ + {Key: "boughtAt", Value: -1}, + {Key: "latestUnlockId", Value: -1}, + {Key: "_id", Value: -1}, + }, + }, + { + "$skip": skip, + }, + { + "$limit": limit, + }, + } + if err := playletchapterunlockmod.AggregatePlayletChapterUnlock(ctx, &rows, pipeline); err != nil { + return nil, err + } + return rows, nil +} + +// collectPlayletIdsFromBoughtRows 从已购短剧聚合结果中提取短剧ID集合 +func collectPlayletIdsFromBoughtRows(rows []playletBoughtAggRow) []int64 { + result := make([]int64, 0, len(rows)) + seen := make(map[int64]struct{}, len(rows)) + for _, row := range rows { + if row.PlayletId <= 0 { + continue + } + if _, ok := seen[row.PlayletId]; ok { + continue + } + seen[row.PlayletId] = struct{}{} + result = append(result, row.PlayletId) + } + return result +} + +// playletListFromBoughtRows 按已购短剧聚合顺序返回短剧集合 +func playletListFromBoughtRows(rows []playletBoughtAggRow, playletMap map[int64]*playletmod.Playlet) playletmod.PlayletList { + result := make(playletmod.PlayletList, 0, len(rows)) + for _, row := range rows { + item := playletMap[row.PlayletId] + if item == nil { + continue + } + result = append(result, item) + } + return result +} + +// ChapterCollectList 分页查询当前用户收藏的短剧剧集列表 +func ChapterCollectList(ctx context.Context, userId int64, req *proto.PlayletChapterCollectListReq) (*proto.PlayletChapterCollectListResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize) + filter := bson.M{ + playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId, + } + totalCount, err := playletchaptercollectmod.CountPlayletChapterCollect(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletChapterCollectListResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.PlayletChapterCollectData, 0), + } + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + collectList, err := playletchaptercollectmod.FindManyPlayletChapterCollect(ctx, filter, options.Find(). + SetProjection(bson.D{ + {Key: playletchaptercollectmod.FN.PlayletChapterCollect.Id, Value: 1}, + {Key: playletchaptercollectmod.FN.PlayletChapterCollect.PlayletId, Value: 1}, + {Key: playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId, Value: 1}, + {Key: playletchaptercollectmod.FN.PlayletChapterCollect.CreatedAt, Value: 1}, + }). + SetSort(bson.D{ + {Key: playletchaptercollectmod.FN.PlayletChapterCollect.CreatedAt, Value: -1}, + {Key: playletchaptercollectmod.FN.PlayletChapterCollect.Id, Value: -1}, + }). + SetSkip(skip). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + if len(collectList) == 0 { + return resp, nil + } + chapterIds := collectChapterIdsFromChapterCollects(collectList) + chapterMap, err := findChapterCollectChapterMap(ctx, chapterIds) + if err != nil { + return nil, err + } + playletIds := collectPlayletIdsFromChapterCollects(collectList, chapterMap) + playletMap, err := findChapterCollectPlayletMap(ctx, playletIds) + if err != nil { + return nil, err + } + resp.List = buildPlayletChapterCollectData(ctx, collectList, chapterMap, playletMap) + return resp, nil +} + +// HistoryList 分页查询当前用户的短剧观看历史 +func HistoryList(ctx context.Context, userId int64, req *proto.PlayletHistoryListReq) (*proto.PlayletHistoryListResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizePlayletHistoryPage(req.PageNum, req.PageSize) + filter := bson.M{ + playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId, + } + totalCount, err := playletwatchhistorymod.CountPlayletWatchHistory(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletHistoryListResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.PlayletHistoryData, 0), + } + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + historyList, err := playletwatchhistorymod.FindManyPlayletWatchHistory(ctx, filter, options.Find(). + SetProjection(bson.D{ + {Key: playletwatchhistorymod.FN.PlayletWatchHistory.Id, Value: 1}, + {Key: playletwatchhistorymod.FN.PlayletWatchHistory.PlayletId, Value: 1}, + {Key: playletwatchhistorymod.FN.PlayletWatchHistory.LastChapterId, Value: 1}, + {Key: playletwatchhistorymod.FN.PlayletWatchHistory.LastEpisode, Value: 1}, + {Key: playletwatchhistorymod.FN.PlayletWatchHistory.UpdatedAt, Value: 1}, + }). + SetSort(bson.D{ + {Key: playletwatchhistorymod.FN.PlayletWatchHistory.UpdatedAt, Value: -1}, + {Key: playletwatchhistorymod.FN.PlayletWatchHistory.Id, Value: -1}, + }). + SetSkip(skip). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + if len(historyList) == 0 { + return resp, nil + } + playletIds := collectPlayletIdsFromHistories(historyList) + playletMap, err := findHistoryPlayletMap(ctx, playletIds) + if err != nil { + return nil, err + } + progressMap, err := findCollectWatchProgressMap(ctx, userId, playletIds) + if err != nil { + return nil, err + } + playletList := playletListFromMapByHistories(historyList, playletMap) + themeNameMap, err := buildHomeThemeNameMap(ctx, playletList) + if err != nil { + log.Error("查询观看历史题材名称失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, playletList) + if err != nil { + log.Error("查询观看历史标签名称失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + resp.List = buildPlayletHistoryData(ctx, historyList, playletMap, progressMap, themeNameMap, tagNameMap) + return resp, nil +} + +// DeleteHistory 删除当前用户指定短剧观看历史,不影响观看进度和收藏列表 +func DeleteHistory(ctx context.Context, userId int64, req *proto.PlayletHistoryDeleteReq) (*proto.PlayletHistoryDeleteResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil || req.PlayletId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") + } + deleted, err := playletwatchhistorymod.DeleteManyPlayletWatchHistory(ctx, bson.M{ + playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId, + playletwatchhistorymod.FN.PlayletWatchHistory.PlayletId: req.PlayletId, + }) + if err != nil { + log.Error("删除单条短剧观看历史失败", log.Any("用户ID", userId), log.Any("短剧ID", req.PlayletId), log.E(err)) + return nil, err + } + return &proto.PlayletHistoryDeleteResp{DeletedCount: deleted}, nil +} + +// ClearHistory 清空当前用户的短剧观看历史,不影响观看进度和收藏列表。 +func ClearHistory(ctx context.Context, userId int64) (*proto.PlayletHistoryClearResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + deleted, err := playletwatchhistorymod.DeleteManyPlayletWatchHistory(ctx, bson.M{ + playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId, + }) + if err != nil { + return nil, err + } + return &proto.PlayletHistoryClearResp{DeletedCount: deleted}, nil +} + +// collectPlayletIdsFromCollects 按收藏列表顺序收集短剧ID并去重,避免批量查询重复ID。 +func collectPlayletIdsFromCollects(list playletcollectmod.PlayletCollectList) []int64 { + result := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, item := range list { + if item == nil || item.PlayletId <= 0 { + continue + } + if _, ok := seen[item.PlayletId]; ok { + continue + } + seen[item.PlayletId] = struct{}{} + result = append(result, item.PlayletId) + } + return result +} + +// collectPlayletIdsFromHistories 按观看历史顺序收集短剧ID并去重,避免批量查询重复ID。 +func collectPlayletIdsFromHistories(list playletwatchhistorymod.PlayletWatchHistoryList) []int64 { + result := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, item := range list { + if item == nil || item.PlayletId <= 0 { + continue + } + if _, ok := seen[item.PlayletId]; ok { + continue + } + seen[item.PlayletId] = struct{}{} + result = append(result, item.PlayletId) + } + return result +} + +// collectChapterIdsFromChapterCollects 按剧集收藏顺序收集剧集ID并去重。 +func collectChapterIdsFromChapterCollects(list playletchaptercollectmod.PlayletChapterCollectList) []int64 { + result := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, item := range list { + if item == nil || item.ChapterId <= 0 { + continue + } + if _, ok := seen[item.ChapterId]; ok { + continue + } + seen[item.ChapterId] = struct{}{} + result = append(result, item.ChapterId) + } + return result +} + +// collectPlayletIdsFromChapterCollects 从收藏关系和剧集数据中收集短剧ID,兼容收藏关系里playletId缺失的历史数据。 +func collectPlayletIdsFromChapterCollects( + list playletchaptercollectmod.PlayletChapterCollectList, + chapterMap map[int64]*playletchaptermod.PlayletChapter, +) []int64 { + result := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, item := range list { + if item == nil { + continue + } + playletId := item.PlayletId + if playletId <= 0 { + if chapter := chapterMap[item.ChapterId]; chapter != nil { + playletId = chapter.PlayletId + } + } + if playletId <= 0 { + continue + } + if _, ok := seen[playletId]; ok { + continue + } + seen[playletId] = struct{}{} + result = append(result, playletId) + } + return result +} + +// findCollectPlayletMap 批量查询收藏关联的短剧数据,返回map便于按收藏时间顺序组装。 +func findCollectPlayletMap(ctx context.Context, playletIds []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) { + result := make(map[int64]*playletmod.Playlet, len(playletIds)) + if len(playletIds) == 0 { + return result, nil + } + list, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": playletIds}, + }, options.Find().SetProjection(playletCollectListProjection)) + if err != nil { + log.Error("批量查询我的收藏短剧失败", log.Any("短剧ID集合", playletIds), log.E(err)) + return nil, err + } + for _, item := range list { + if item == nil { + continue + } + result[item.Id] = item + } + return result, nil +} + +// findChapterCollectPlayletMap 批量查询剧集收藏关联的短剧数据。 +func findChapterCollectPlayletMap(ctx context.Context, playletIds []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) { + result := make(map[int64]*playletmod.Playlet, len(playletIds)) + if len(playletIds) == 0 { + return result, nil + } + list, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": playletIds}, + }, options.Find().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.Title, Value: 1}, + {Key: playletmod.FN.Playlet.Cover, Value: 1}, + {Key: playletmod.FN.Playlet.CompressedCover, Value: 1}, + {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, + {Key: playletmod.FN.Playlet.UpdateStatus, Value: 1}, + })) + if err != nil { + log.Error("批量查询我的剧集收藏短剧失败", log.Any("短剧ID集合", playletIds), log.E(err)) + return nil, err + } + for _, item := range list { + if item == nil { + continue + } + result[item.Id] = item + } + return result, nil +} + +// findChapterCollectChapterMap 批量查询收藏关联的剧集数据。 +func findChapterCollectChapterMap(ctx context.Context, chapterIds []int64) (map[int64]*playletchaptermod.PlayletChapter, *srvdef.SrvErr) { + result := make(map[int64]*playletchaptermod.PlayletChapter, len(chapterIds)) + if len(chapterIds) == 0 { + return result, nil + } + list, err := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": chapterIds}, + playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp, + }, options.Find().SetProjection(bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.VideoUrl, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.PayType, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.SingleCoins, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CountBrowse, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CountCollect, Value: 1}, + })) + if err != nil { + log.Error("批量查询我的剧集收藏剧集失败", log.Any("剧集ID集合", chapterIds), log.E(err)) + return nil, err + } + for _, item := range list { + if item == nil { + continue + } + result[item.Id] = item + } + return result, nil +} + +// findHistoryPlayletMap 批量查询观看历史关联的短剧数据,返回map便于按观看时间顺序组装。 +func findHistoryPlayletMap(ctx context.Context, playletIds []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) { + result := make(map[int64]*playletmod.Playlet, len(playletIds)) + if len(playletIds) == 0 { + return result, nil + } + list, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": playletIds}, + }, options.Find().SetProjection(playletHomeProjection)) + if err != nil { + log.Error("批量查询观看历史短剧失败", log.Any("短剧ID集合", playletIds), log.E(err)) + return nil, err + } + for _, item := range list { + if item == nil { + continue + } + result[item.Id] = item + } + return result, nil +} + +// findCollectWatchProgressMap 批量查询当前用户收藏短剧的观看进度,避免收藏列表逐条查询。 +func findCollectWatchProgressMap(ctx context.Context, userId int64, playletIds []int64) (map[int64]*playletwatchprogressmod.PlayletWatchProgress, *srvdef.SrvErr) { + result := make(map[int64]*playletwatchprogressmod.PlayletWatchProgress, len(playletIds)) + if userId <= 0 || len(playletIds) == 0 { + return result, nil + } + list, err := playletwatchprogressmod.FindManyPlayletWatchProgress(ctx, bson.M{ + playletwatchprogressmod.FN.PlayletWatchProgress.UserId: userId, + playletwatchprogressmod.FN.PlayletWatchProgress.PlayletId: bson.M{"$in": playletIds}, + }, options.Find().SetProjection(bson.D{ + {Key: playletwatchprogressmod.FN.PlayletWatchProgress.PlayletId, Value: 1}, + {Key: playletwatchprogressmod.FN.PlayletWatchProgress.LastChapterId, Value: 1}, + {Key: playletwatchprogressmod.FN.PlayletWatchProgress.LastEpisode, Value: 1}, + {Key: playletwatchprogressmod.FN.PlayletWatchProgress.WatchedEpisodes, Value: 1}, + {Key: playletwatchprogressmod.FN.PlayletWatchProgress.WatchedCount, Value: 1}, + })) + if err != nil { + log.Error("批量查询我的收藏观看进度失败", log.Any("用户ID", userId), log.Any("短剧ID集合", playletIds), log.E(err)) + return nil, err + } + for _, item := range list { + if item == nil { + continue + } + result[item.PlayletId] = item + } + return result, nil +} + +// playletListFromMapByCollects 按收藏列表顺序构造短剧列表,用于批量查询题材和标签名称。 +func playletListFromMapByCollects(collectList playletcollectmod.PlayletCollectList, playletMap map[int64]*playletmod.Playlet) playletmod.PlayletList { + result := make(playletmod.PlayletList, 0, len(collectList)) + for _, collect := range collectList { + if collect == nil { + continue + } + if item := playletMap[collect.PlayletId]; item != nil { + result = append(result, item) + } + } + return result +} + +// playletListFromMapByHistories 按观看历史顺序构造短剧列表,用于批量查询题材和标签名称。 +func playletListFromMapByHistories(historyList playletwatchhistorymod.PlayletWatchHistoryList, playletMap map[int64]*playletmod.Playlet) playletmod.PlayletList { + result := make(playletmod.PlayletList, 0, len(historyList)) + for _, history := range historyList { + if history == nil { + continue + } + if item := playletMap[history.PlayletId]; item != nil { + result = append(result, item) + } + } + return result +} + +// buildPlayletCollectData 组装我的收藏响应,保持playlet_collect的倒序分页顺序。 +func buildPlayletCollectData( + ctx context.Context, + collectList playletcollectmod.PlayletCollectList, + playletMap map[int64]*playletmod.Playlet, + progressMap map[int64]*playletwatchprogressmod.PlayletWatchProgress, + themeNameMap map[int64]string, + tagNameMap map[int64]string, +) []proto.PlayletCollectData { + result := make([]proto.PlayletCollectData, 0, len(collectList)) + for _, collect := range collectList { + if collect == nil { + continue + } + item := playletMap[collect.PlayletId] + if item == nil { + continue + } + progress := progressMap[item.Id] + watchedCount := collectWatchedCount(progress, item.TotalEpisode) + result = append(result, proto.PlayletCollectData{ + Id: item.Id, + Title: item.Title, + PlayName: item.PlayName, + Slug: item.Slug, + Cover: withImageDomain(ctx, item.DisplayCover()), + ThemeIds: item.ThemeIds, + ThemeNames: namesByIds(item.ThemeIds, themeNameMap), + ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)), + TagIds: item.TagIds, + TagNames: namesByIds(item.TagIds, tagNameMap), + TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)), + TotalEpisode: item.TotalEpisode, + UpdateStatus: item.UpdateStatus, + CountBrowse: item.CountBrowse, + HeatValue: item.HeatValue, + CountCollect: item.CountCollect, + IsCollect: operateStatusYes, + CollectedAt: collect.CreatedAt, + WatchedCount: watchedCount, + UnwatchedCount: collectUnwatchedCount(item.TotalEpisode, watchedCount), + LastChapterId: collectLastChapterId(progress), + LastEpisode: collectLastEpisode(progress), + SeoInfo: item.SeoInfo, + }) + } + return result +} + +// buildPlayletBoughtData 组装我的已购短剧响应,保持最近购买时间倒序 +func buildPlayletBoughtData( + ctx context.Context, + rows []playletBoughtAggRow, + playletMap map[int64]*playletmod.Playlet, + progressMap map[int64]*playletwatchprogressmod.PlayletWatchProgress, + themeNameMap map[int64]string, + tagNameMap map[int64]string, +) []proto.PlayletBoughtData { + result := make([]proto.PlayletBoughtData, 0, len(rows)) + for _, row := range rows { + item := playletMap[row.PlayletId] + if item == nil { + continue + } + if len(item.Description) > 0 { + item.SeoInfo.Description = item.Description + } + progress := progressMap[item.Id] + watchedCount := collectWatchedCount(progress, item.TotalEpisode) + result = append(result, proto.PlayletBoughtData{ + CommonData: proto.CommonData{ + Id: item.Id, + Status: int32(item.Status), + IsFeatured: item.IsFeatured, + IsOriginal: item.IsOriginal || item.MediaCenterId == 0, + ShowOriginal: item.ShowOriginal, + IsRecommend: item.IsRecommend, + IsNew: isOnlineThisWeek(item.OnlineAt), + OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)), + ShowOnBanner: item.ShowOnBanner, + Banner: withImageDomain(ctx, item.Banner), + OnlineAt: item.OnlineAt, + MediaCreatedAt: item.MediaCreatedAt, + MediaUpdatedAt: item.MediaUpdatedAt, + ThemeIds: item.ThemeIds, + ThemeNames: namesByIds(item.ThemeIds, themeNameMap), + ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)), + CategoryIds: item.CategoryIds, + Title: item.Title, + PlayName: item.PlayName, + Description: item.Description, + Slug: item.Slug, + Desc: item.Desc, + Anchor: item.Anchor, + Cover: withImageDomain(ctx, item.DisplayCover()), + HorizontalCover: withImageDomain(ctx, item.DisplayHorizontalCover()), + Preview: item.Preview, + TagIds: item.TagIds, + TagNames: namesByIds(item.TagIds, tagNameMap), + TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)), + TotalEpisode: item.TotalEpisode, + UpdateStatus: item.UpdateStatus, + CountBrowse: item.CountBrowse, + HeatValue: item.HeatValue, + CountCollect: item.CountCollect, + CountLike: displayPlayletCountLike(item.CountLike, item.VirtualCountLike), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + SeoInfo: item.SeoInfo, + }, + BoughtChapterCount: row.BoughtChapterCount, + CostCoins: row.CostCoins, + BoughtAt: row.BoughtAt, + LastChapterId: collectLastChapterId(progress), + LastEpisode: collectLastEpisode(progress), + WatchedCount: watchedCount, + UnwatchedCount: collectUnwatchedCount(item.TotalEpisode, watchedCount), + IsFinished: isPlayletFinished(item.TotalEpisode, watchedCount), + }) + } + return result +} + +// buildPlayletChapterCollectData 组装我的剧集收藏响应,保持playlet_chapter_collect的倒序分页顺序。 +func buildPlayletChapterCollectData( + ctx context.Context, + collectList playletchaptercollectmod.PlayletChapterCollectList, + chapterMap map[int64]*playletchaptermod.PlayletChapter, + playletMap map[int64]*playletmod.Playlet, +) []proto.PlayletChapterCollectData { + result := make([]proto.PlayletChapterCollectData, 0, len(collectList)) + for _, collect := range collectList { + if collect == nil { + continue + } + chapter := chapterMap[collect.ChapterId] + if chapter == nil { + continue + } + playletId := collect.PlayletId + if playletId <= 0 { + playletId = chapter.PlayletId + } + playlet := playletMap[playletId] + if playlet == nil { + continue + } + result = append(result, proto.PlayletChapterCollectData{ + PlayletId: playlet.Id, + PlayletTitle: playlet.Title, + PlayletCover: withImageDomain(ctx, playlet.DisplayCover()), + TotalEpisode: playlet.TotalEpisode, + UpdateStatus: playlet.UpdateStatus, + ChapterId: chapter.Id, + CurrentEpisode: chapter.CurrentEpisode, + ChapterTitle: chapter.Title, + VideoUrl: playletChapterPlayVideoUrl(chapter), + MediaTime: chapter.MediaTime, + PayType: int32(chapter.PayType), + SingleCoins: chapter.SingleCoins, + CountBrowse: chapter.CountBrowse, + CountCollect: chapter.CountCollect, + IsCollect: operateStatusYes, + CollectedAt: collect.CreatedAt, + }) + } + return result +} + +// buildPlayletHistoryData 组装观看历史响应,保持playlet_watch_history的倒序分页顺序。 +func buildPlayletHistoryData( + ctx context.Context, + historyList playletwatchhistorymod.PlayletWatchHistoryList, + playletMap map[int64]*playletmod.Playlet, + progressMap map[int64]*playletwatchprogressmod.PlayletWatchProgress, + themeNameMap map[int64]string, + tagNameMap map[int64]string, +) []proto.PlayletHistoryData { + result := make([]proto.PlayletHistoryData, 0, len(historyList)) + for _, history := range historyList { + if history == nil { + continue + } + item := playletMap[history.PlayletId] + if item == nil { + continue + } + progress := progressMap[item.Id] + watchedCount := collectWatchedCount(progress, item.TotalEpisode) + result = append(result, proto.PlayletHistoryData{ + CommonData: proto.CommonData{ + Id: item.Id, + Status: int32(item.Status), + IsFeatured: item.IsFeatured, + IsOriginal: item.IsOriginal || item.MediaCenterId == 0, + ShowOriginal: item.ShowOriginal, + IsRecommend: item.IsRecommend, + IsNew: isOnlineThisWeek(item.OnlineAt), + OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)), + ShowOnBanner: item.ShowOnBanner, + Banner: withImageDomain(ctx, item.Banner), + OnlineAt: item.OnlineAt, + MediaCreatedAt: item.MediaCreatedAt, + MediaUpdatedAt: item.MediaUpdatedAt, + ThemeIds: item.ThemeIds, + ThemeNames: namesByIds(item.ThemeIds, themeNameMap), + ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)), + CategoryIds: item.CategoryIds, + Title: item.Title, + PlayName: item.PlayName, + Description: item.Description, + Slug: item.Slug, + Desc: item.Desc, + Anchor: item.Anchor, + Cover: withImageDomain(ctx, item.DisplayCover()), + TagIds: item.TagIds, + TagNames: namesByIds(item.TagIds, tagNameMap), + TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)), + TotalEpisode: item.TotalEpisode, + UpdateStatus: item.UpdateStatus, + CountBrowse: item.CountBrowse, + CountCollect: item.CountCollect, + CountLike: displayPlayletCountLike(item.CountLike, item.VirtualCountLike), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + SeoInfo: item.SeoInfo, + }, + LastChapterId: history.LastChapterId, + LastEpisode: history.LastEpisode, + WatchedCount: watchedCount, + UnwatchedCount: collectUnwatchedCount(item.TotalEpisode, watchedCount), + IsFinished: isPlayletFinished(item.TotalEpisode, watchedCount), + HistoryAt: formatPlayletHistoryTime(history.UpdatedAt), + }) + } + return result +} + +func formatPlayletHistoryTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.In(utils.LocBJ).Format(pubdef.TimeISO) +} + +// collectWatchedCount 读取已看集数,兼容仅保存watchedEpisodes但未写watchedCount的历史数据。 +func collectWatchedCount(progress *playletwatchprogressmod.PlayletWatchProgress, totalEpisode int64) int64 { + if progress == nil { + return 0 + } + watchedCount := progress.WatchedCount + if watchedCount <= 0 && len(progress.WatchedEpisodes) > 0 { + watchedCount = int64(len(progress.WatchedEpisodes)) + } + if watchedCount < 0 { + return 0 + } + if totalEpisode > 0 && watchedCount > totalEpisode { + return totalEpisode + } + return watchedCount +} + +// collectUnwatchedCount 计算未看集数,避免历史脏数据导致负数。 +func collectUnwatchedCount(totalEpisode int64, watchedCount int64) int64 { + if totalEpisode <= watchedCount { + return 0 + } + return totalEpisode - watchedCount +} + +// isPlayletFinished 判断短剧是否已看完。 +func isPlayletFinished(totalEpisode int64, watchedCount int64) bool { + return totalEpisode > 0 && watchedCount >= totalEpisode +} + +// collectLastChapterId 获取最近观看剧集ID,无有效进度时返回0。 +func collectLastChapterId(progress *playletwatchprogressmod.PlayletWatchProgress) int64 { + if progress == nil || progress.LastChapterId < 0 { + return 0 + } + return progress.LastChapterId +} + +// collectLastEpisode 获取最近观看集数,无有效进度时返回0。 +func collectLastEpisode(progress *playletwatchprogressmod.PlayletWatchProgress) int64 { + if progress == nil || progress.LastEpisode < 0 { + return 0 + } + return progress.LastEpisode +} + +// findPlayletChapterUserOperateStatus 查询当前用户短剧剧集点赞和收藏状态 +func findPlayletChapterUserOperateStatus(ctx context.Context, userId int64, chapterId int64) (int64, int64, *srvdef.SrvErr) { + // 游客或无有效token时没有用户ID,直接返回未点赞、未收藏,保证剧集详情接口可匿名访问。 + if userId <= 0 { + return operateStatusNo, operateStatusNo, nil + } + likeList, err := playletchapterlikemod.FindManyPlayletChapterLike(ctx, bson.M{ + playletchapterlikemod.FN.PlayletChapterLike.UserId: userId, + playletchapterlikemod.FN.PlayletChapterLike.ChapterId: chapterId, + }, options.Find(). + SetLimit(1). + SetProjection(bson.D{{Key: playletchapterlikemod.FN.PlayletChapterLike.Id, Value: 1}})) + if err != nil { + log.Error("查询短剧剧集用户点赞状态失败", log.Any("用户ID", userId), log.Any("剧集ID", chapterId), log.E(err)) + return operateStatusNo, operateStatusNo, err + } + collectList, err := playletchaptercollectmod.FindManyPlayletChapterCollect(ctx, bson.M{ + playletchaptercollectmod.FN.PlayletChapterCollect.UserId: userId, + playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: chapterId, + }, options.Find(). + SetLimit(1). + SetProjection(bson.D{{Key: playletchaptercollectmod.FN.PlayletChapterCollect.Id, Value: 1}})) + if err != nil { + log.Error("查询短剧剧集用户收藏状态失败", log.Any("用户ID", userId), log.Any("剧集ID", chapterId), log.E(err)) + return operateStatusNo, operateStatusNo, err + } + isLike := operateStatusByFlag(len(likeList) > 0) + isCollect := operateStatusByFlag(len(collectList) > 0) + return isLike, isCollect, nil +} + +// incrementPlayletBrowse 原子增加短剧观看量和热度值 +func incrementPlayletBrowse(ctx context.Context, id int64) *srvdef.SrvErr { + // 不更新updatedAt,避免详情访问影响今日更新排序。 + return playletmod.UpdateOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: id, + }), bson.M{ + "$inc": bson.M{ + playletmod.FN.Playlet.CountBrowse: 1, + playletmod.FN.Playlet.HeatValue: playletHeatClickInc, + }, + }) +} + +func incrementPlayletHeatValue(ctx context.Context, id int64, delta int64) *srvdef.SrvErr { + if id <= 0 || delta == 0 { + return nil + } + return playletmod.UpdateOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: id, + }, bson.M{ + "$inc": bson.M{ + playletmod.FN.Playlet.HeatValue: delta, + }, + }) +} + +// checkPlayletForwardHeatLimit 校验同一用户或IP对同一短剧的转发加热频控 +func checkPlayletForwardHeatLimit(userId int64, ip string, playletId int64) (bool, error) { + operator := strings.TrimSpace(ip) + if userId > 0 { + operator = "user-" + strconv.FormatInt(userId, 10) + } + if operator == "" { + operator = "unknown" + } + ok, err := redis.Setnx_NewOK( + redisconst.AppPlayletForwardHeatKey(operator, playletId), + 1, + redisconst.AppPlayletForwardHeatExpire, + ) + if err != nil { + return false, err + } + return !ok, nil +} + +// findPlayletForwardHeatTarget 查询转发加热目标短剧,用于频控命中时返回当前热度 +func findPlayletForwardHeatTarget(ctx context.Context, id int64) (*playletmod.Playlet, *srvdef.SrvErr) { + playlet, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: id, + }), options.FindOne().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.HeatValue, Value: 1}, + })) + if err != nil { + log.Error("查询短剧转发热度目标失败", log.Any("短剧ID", id), log.E(err)) + return nil, err + } + return playlet, nil +} + +// incrementPlayletHeatValueAndReturn 原子增加短剧热度值并返回更新后的热度 +func incrementPlayletHeatValueAndReturn(ctx context.Context, id int64, delta int64) (*playletmod.Playlet, *srvdef.SrvErr) { + if id <= 0 || delta == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") + } + return playletmod.FindOneAndUpdatePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: id, + }), bson.M{ + "$inc": bson.M{ + playletmod.FN.Playlet.HeatValue: delta, + }, + }, options.FindOneAndUpdate().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.HeatValue, Value: 1}, + })) +} + +// incrementPlayletChapterBrowse 原子增加短剧剧集观看量 +func incrementPlayletChapterBrowse(ctx context.Context, id int64) *srvdef.SrvErr { + // 只递增剧集countBrowse,不更新updatedAt,避免播放行为影响后台维护时间和排序逻辑。 + return playletchaptermod.UpdateOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: id, + }, bson.M{ + "$inc": bson.M{ + playletchaptermod.FN.PlayletChapter.CountBrowse: 1, + }, + }) +} + +// findPlayletChapters 查询短剧全部章节,并批量填充当前用户播放权限。 +// 原创短剧只返回已有水印地址的剧集:首次未处理好的不露出,换水印期间仍可播旧地址。 +func findPlayletChapters(ctx context.Context, playletId int64, userId int64, isOriginal bool) ([]proto.PlayletChapterData, *srvdef.SrvErr) { + filter := bson.M{ + playletchaptermod.FN.PlayletChapter.PlayletId: playletId, + playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp, + } + if isOriginal { + filter[playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl] = bson.M{"$gt": ""} + } + // 详情页播放区需要展示完整选集列表,这里按剧集序号一次性查询全部章节,避免前端再发一次章节请求 + list, err := playletchaptermod.FindManyPlayletChapter(ctx, filter, options.Find(). + SetProjection(playletChapterInfoProjection). + SetSort(bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + })) + if err != nil { + log.Error("查询短剧章节列表失败", log.Any("短剧ID", playletId), log.E(err)) + return nil, err + } + access, err := buildPlayletChapterAccessContext(ctx, userId, playletId, list) + if err != nil { + return nil, err + } + result := make([]proto.PlayletChapterData, 0, len(list)) + for _, chapter := range list { + if chapter == nil { + continue + } + result = append(result, playletChapterToData(chapter, access)) + } + return result, nil +} + +// buildPlayletChapterAccessContext 批量查询详情章节列表所需的用户权限数据。 +func buildPlayletChapterAccessContext(ctx context.Context, userId int64, playletId int64, chapters playletchaptermod.PlayletChapterList) (*playletChapterAccessContext, *srvdef.SrvErr) { + access := &playletChapterAccessContext{ + unlockedChapterIds: make(map[int64]bool), + } + if userId <= 0 { + return access, nil + } + user, err := findPlayletChapterAccessUser(ctx, userId) + if err != nil { + return nil, err + } + access.balance = user.Recharge + access.vipValid = isUserVipValid(user) + + coinChapterIds := collectCoinPayChapterIds(chapters) + if len(coinChapterIds) == 0 { + return access, nil + } + unlocks, err := playletchapterunlockmod.FindManyPlayletChapterUnlock(ctx, bson.M{ + playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId, + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId: playletId, + playletchapterunlockmod.FN.PlayletChapterUnlock.ChapterId: bson.M{"$in": coinChapterIds}, + }, options.Find().SetProjection(bson.D{ + {Key: playletchapterunlockmod.FN.PlayletChapterUnlock.ChapterId, Value: 1}, + })) + if err != nil { + return nil, err + } + for _, item := range unlocks { + if item == nil || item.ChapterId <= 0 { + continue + } + access.unlockedChapterIds[item.ChapterId] = true + } + return access, nil +} + +// collectCoinPayChapterIds 收集金币付费剧集ID,减少解锁记录查询范围。 +func collectCoinPayChapterIds(chapters playletchaptermod.PlayletChapterList) []int64 { + ids := make([]int64, 0) + for _, chapter := range chapters { + if chapter == nil || chapter.Id <= 0 || chapter.PayType != playletchaptermod.PayTypeCoin { + continue + } + ids = append(ids, chapter.Id) + } + return ids +} + +// findGuessLikeCurrentPlaylet 查询猜你喜欢当前短剧 +func findGuessLikeCurrentPlaylet(ctx context.Context, playletId int64) (*playletmod.Playlet, *srvdef.SrvErr) { + item, err := playletmod.FindOnePlaylet(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: playletId, + }), options.FindOne().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.ThemeIds, Value: 1}, + })) + if err != nil { + log.Error("查询猜你喜欢当前短剧失败", log.Any("短剧ID", playletId), log.E(err)) + return nil, err + } + if item == nil { + return nil, srvdef.New(srvdef.ErrNoData, "短剧不存在或已下架", "短剧不存在或已下架") + } + return item, nil +} + +// playletGuessLikeFilter 构建猜你喜欢过滤条件 +func playletGuessLikeFilter(item *playletmod.Playlet) bson.M { + filter := homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$ne": item.Id}, + }) + if item != nil && len(item.ThemeIds) > 0 { + filter[playletmod.FN.Playlet.ThemeIds] = bson.M{"$in": item.ThemeIds} + } + return filter +} + +// playletGuessLikeSort 构建猜你喜欢排序 +func playletGuessLikeSort() bson.D { + return bson.D{ + {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, + {Key: playletmod.FN.Playlet.CountCollect, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } +} + +// playletChapterToData 转换短剧章节响应数据 +func playletChapterToData(item *playletchaptermod.PlayletChapter, access *playletChapterAccessContext) proto.PlayletChapterData { + if item == nil { + return proto.PlayletChapterData{} + } + videoURL := playletChapterPlayVideoUrl(item) + data := proto.PlayletChapterData{ + Id: item.Id, + PlayletId: item.PlayletId, + 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, + PayType: int32(item.PayType), + SingleCoins: item.SingleCoins, + CostCoins: item.SingleCoins, + } + applyPlayletChapterDataWatchPermission(item, access, &data) + if !data.CanWatch { + videoURL = "" + } + data.VideoUrl = videoURL + return data +} + +// applyPlayletChapterDataWatchPermission 填充详情页章节列表的播放权限状态。 +func applyPlayletChapterDataWatchPermission(item *playletchaptermod.PlayletChapter, access *playletChapterAccessContext, data *proto.PlayletChapterData) { + if item == nil || data == nil { + return + } + if access != nil { + data.Balance = access.balance + data.VipValid = access.vipValid + } + switch item.PayType { + case playletchaptermod.PayTypeFree: + data.CanWatch = true + case playletchaptermod.PayTypeCoin: + unlocked := access != nil && access.unlockedChapterIds[item.Id] + data.IsUnlocked = unlocked + data.CanWatch = unlocked + data.NeedUnlock = !unlocked + case playletchaptermod.PayTypeVip: + vipValid := access != nil && access.vipValid + data.CanWatch = vipValid + data.NeedVip = !vipValid + default: + data.NeedUnlock = true + } +} + +// playletChapterToInfoResp 转换短剧剧集详情响应数据 +func playletChapterToInfoResp(item *playletchaptermod.PlayletChapter) *proto.PlayletChapterInfoResp { + // 剧集详情只返回播放页需要的当前剧集字段;播放地址优先水印地址,为空回退原片。 + if item == nil { + return &proto.PlayletChapterInfoResp{} + } + return &proto.PlayletChapterInfoResp{ + Id: item.Id, + PlayletId: item.PlayletId, + CurrentEpisode: item.CurrentEpisode, + Title: item.Title, + Md5: item.Md5, + Hash: item.Hash, + Height: item.Height, + Width: item.Width, + Ratio: item.Ratio, + VideoUrl: playletChapterPlayVideoUrl(item), + MediaSize: item.MediaSize, + MediaTime: item.MediaTime, + CountComment: item.CountComment, + CountBrowse: item.CountBrowse, + CountCollect: item.CountCollect, + CountLike: item.CountLike, + IsLike: operateStatusNo, + IsCollect: operateStatusNo, + PayType: int32(item.PayType), + SingleCoins: item.SingleCoins, + } +} + +// ensureOriginalChapterWatermarkReady 原创剧集必须已有水印地址才允许播放/解锁。 +func ensureOriginalChapterWatermarkReady(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) *srvdef.SrvErr { + if playlet == nil || !playlet.IsOriginal || chapter == nil { + return nil + } + if strings.TrimSpace(chapter.WatermarkVideoUrl) != "" { + return nil + } + return srvdef.New(srvdef.ErrNoData, "剧集处理中,请稍后观看", "剧集处理中,请稍后观看") +} + +// playletChapterPlayVideoUrl 优先返回水印地址,为空时回退原片地址。 +func playletChapterPlayVideoUrl(item *playletchaptermod.PlayletChapter) string { + if item == nil { + return "" + } + if u := strings.TrimSpace(item.WatermarkVideoUrl); u != "" { + return u + } + return item.VideoUrl +} + +// buildPlayletDetailSeoInfo 组装短剧详情页SEO信息 +func buildPlayletDetailSeoInfo(item *playletmod.Playlet, themeNameMap map[int64]string, tagNameMap map[int64]string) *proto.SeoInfo { + seo := configser.GetSiteSeoInfo() + if item == nil { + return &seo + } + // 优先使用后台配置的 SeoInfo,缺失字段再回退剧名/简介自动生成。 + if title := strings.TrimSpace(item.SeoInfo.Title); title != "" { + seo.Title = title + } else if title := strings.TrimSpace(item.Title); title != "" { + seo.Title = title + } + if desc := strings.TrimSpace(item.Description); desc != "" { + seo.Description = desc + } else if desc := strings.TrimSpace(item.SeoInfo.Description); desc != "" { + seo.Description = desc + } else if desc := truncatePlayletSEODescription(strings.TrimSpace(item.Desc), 120); desc != "" { + seo.Description = desc + } + if len(item.SeoInfo.Keyworld) > 0 { + seo.Keywords = append([]string{}, item.SeoInfo.Keyworld...) + } else { + seo.Keywords = buildPlayletSEOKeywords(item, themeNameMap, tagNameMap, seo.Keywords) + } + seo.CanonicalURL = buildPlayletDetailCanonicalURL(seo.CanonicalURL, item.Slug, item.Id) + return &seo +} + +// buildPlayletChapterDetailSeoInfo 组装短剧剧集详情页SEO信息 +func buildPlayletChapterDetailSeoInfo(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) *proto.SeoInfo { + seo := configser.GetSiteSeoInfo() + if playlet == nil && chapter == nil { + return &seo + } + // 优先使用短剧后台配置的 SeoInfo,再回退到剧名/简介自动生成。 + if playlet != nil { + if title := strings.TrimSpace(playlet.SeoInfo.Title); title != "" { + seo.Title = title + } + if desc := strings.TrimSpace(playlet.Description); desc != "" { + seo.Description = desc + } else if desc := strings.TrimSpace(playlet.SeoInfo.Description); desc != "" { + seo.Description = desc + } + if len(playlet.SeoInfo.Keyworld) > 0 { + seo.Keywords = append([]string{}, playlet.SeoInfo.Keyworld...) + } + } + if strings.TrimSpace(seo.Title) == "" { + if title := buildPlayletChapterSEOTitle(playlet, chapter); title != "" { + seo.Title = title + } + } else if chapter != nil { + // 已有短剧 SEO 标题时,补充剧集名便于播放页区分。 + if episodeTitle := strings.TrimSpace(chapter.Title); episodeTitle != "" { + seo.Title = seo.Title + " " + episodeTitle + } else if chapter.CurrentEpisode > 0 { + seo.Title = seo.Title + " 第" + strconv.FormatInt(chapter.CurrentEpisode, 10) + "集" + } + } + if strings.TrimSpace(seo.Description) == "" && playlet != nil { + if desc := truncatePlayletSEODescription(strings.TrimSpace(playlet.Desc), 120); desc != "" { + seo.Description = desc + } + } + if len(seo.Keywords) == 0 { + if playlet != nil { + seo.Keywords = buildPlayletSEOKeywords(playlet, nil, nil, seo.Keywords) + } else { + seo.Keywords = buildPlayletChapterSEOKeywords(chapter, seo.Keywords) + } + } + if chapter != nil { + slug := "" + if playlet != nil { + slug = playlet.Slug + } + seo.CanonicalURL = buildPlayletChapterDetailCanonicalURL(seo.CanonicalURL, slug, chapter.PlayletId, chapter.Id) + } + return &seo +} + +// buildPlayletListPageSeoInfo 组装短剧总列表页 SEO(无题材/分类筛选)。 +func buildPlayletListPageSeoInfo() *proto.SeoInfo { + return buildPageSeoInfoFromValue(pubdef.SeoValue{}, "", "", "/list") +} + +// buildPlayletThemePageSeoInfo 组装题材短剧列表页SEO信息(读题材缓存,避免每次查库) +func buildPlayletThemePageSeoInfo(themeId int64) *proto.SeoInfo { + if themeId <= 0 { + return buildPlayletListPageSeoInfo() + } + theme := playletcache.GetEnabledTheme(themeId) + if theme == nil { + return buildPlayletListPageSeoInfo() + } + return buildPageSeoInfoFromValue(theme.SeoInfo, theme.Name, "", buildPlayletThemeListCanonicalURL(theme.Slug, theme.Id)) +} + +// buildPlayletTagPageSeoInfo 组装标签短剧列表页SEO信息(读标签缓存,避免每次查库) +func buildPlayletTagPageSeoInfo(tagId int64) *proto.SeoInfo { + if tagId <= 0 { + return buildPlayletListPageSeoInfo() + } + tag := playletcache.GetEnabledTag(tagId) + if tag == nil { + return buildPageSeoInfoFromValue(pubdef.SeoValue{}, "短剧标签", "", buildPlayletTagListCanonicalURL("", tagId)) + } + return buildPageSeoInfoFromValue(tag.SeoInfo, tag.Name, "", buildPlayletTagListCanonicalURL(tag.Slug, tag.Id)) +} + +// buildPageSeoInfoFromValue 将后台 SeoValue 转为页面 SeoInfo,缺失字段回退站点默认。 +func buildPageSeoInfoFromValue(custom pubdef.SeoValue, fallbackTitle, fallbackDesc, canonicalURL string) *proto.SeoInfo { + seo := configser.GetSiteSeoInfo() + if title := strings.TrimSpace(custom.Title); title != "" { + seo.Title = title + } else if title := strings.TrimSpace(fallbackTitle); title != "" { + seo.Title = title + } + if desc := strings.TrimSpace(custom.Description); desc != "" { + seo.Description = desc + } else if desc := strings.TrimSpace(fallbackDesc); desc != "" { + seo.Description = desc + } + if len(custom.Keyworld) > 0 { + seo.Keywords = append([]string{}, custom.Keyworld...) + } + if canonicalURL != "" { + seo.CanonicalURL = buildPlayletCanonicalURL(seo.CanonicalURL, canonicalURL) + } + return &seo +} + +// buildPlayletThemeListCanonicalURL 专题详情页相对路径(对齐 jha-1045-hgdjnet-web /topic-detail/:slug) +func buildPlayletThemeListCanonicalURL(slug string, themeId int64) string { + return h5path.TopicDetail(slug, themeId) +} + +// buildPlayletTagListCanonicalURL 标签列表页相对路径(对齐 jha-1045-hgdjnet-web /tags/:slug) +func buildPlayletTagListCanonicalURL(slug string, tagId int64) string { + return h5path.Tag(slug, tagId) +} + +func playletSiteEntityPath(prefix, slug string, id int64) string { + return h5path.Entity(prefix, slug, id) +} + +// buildPlayletChapterSEOTitle 生成剧集详情页标题,优先展示“短剧名 + 剧集名”。 +func buildPlayletChapterSEOTitle(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) string { + playletTitle := "" + if playlet != nil { + playletTitle = strings.TrimSpace(playlet.Title) + } + chapterTitle := "" + if chapter != nil { + chapterTitle = strings.TrimSpace(chapter.Title) + if chapterTitle == "" && chapter.CurrentEpisode > 0 { + chapterTitle = "第" + strconv.FormatInt(chapter.CurrentEpisode, 10) + "集" + } + } + if playletTitle == "" { + return chapterTitle + } + if chapterTitle == "" { + return playletTitle + } + return playletTitle + " " + chapterTitle +} + +// buildPlayletSEOKeywords 合并短剧标题、题材、标签和全站关键词,并去重。 +func buildPlayletSEOKeywords(item *playletmod.Playlet, themeNameMap map[int64]string, tagNameMap map[int64]string, siteKeywords []string) []string { + capacity := len(siteKeywords) + 1 + if item != nil { + capacity += len(item.ThemeIds) + len(item.TagIds) + } + keywords := make([]string, 0, capacity) + seen := make(map[string]struct{}, cap(keywords)) + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + keywords = append(keywords, value) + } + if item != nil { + add(item.Title) + for _, name := range namesByIds(item.ThemeIds, themeNameMap) { + add(name) + } + for _, name := range namesByIds(item.TagIds, tagNameMap) { + add(name) + } + } + for _, keyword := range siteKeywords { + add(keyword) + } + return keywords +} + +// buildPlayletChapterSEOKeywords 合并剧集标题和全站关键词,并去重。 +func buildPlayletChapterSEOKeywords(chapter *playletchaptermod.PlayletChapter, siteKeywords []string) []string { + keywords := make([]string, 0, len(siteKeywords)+1) + seen := make(map[string]struct{}, cap(keywords)) + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + keywords = append(keywords, value) + } + if chapter != nil { + add(chapter.Title) + } + for _, keyword := range siteKeywords { + add(keyword) + } + return keywords +} + +// truncatePlayletSEODescription 限制meta description长度,避免详情长文本直接写进SEO描述。 +func truncatePlayletSEODescription(desc string, maxRunes int) string { + if maxRunes <= 0 || desc == "" { + return "" + } + runes := []rune(desc) + if len(runes) <= maxRunes { + return desc + } + return string(runes[:maxRunes]) +} + +// buildPlayletDetailCanonicalURL 拼接短剧详情页标准URL。 +func buildPlayletDetailCanonicalURL(baseURL, slug string, id int64) string { + return buildPlayletCanonicalURL(baseURL, playletSiteEntityPath(playletH5DetailPath, slug, id)) +} + +// buildPlayletChapterDetailCanonicalURL 拼接短剧剧集详情页标准URL。 +func buildPlayletChapterDetailCanonicalURL(baseURL, slug string, playletId int64, chapterId int64) string { + pagePath := h5path.PlayletChapter(slug, playletId, chapterId) + if pagePath == "" { + pagePath = h5path.Playlet(slug, playletId) + } + return buildPlayletCanonicalURL(baseURL, pagePath) +} + +// buildPlayletCanonicalURL 使用全站Web地址拼接页面路径;未配置域名时返回相对路径。 +func buildPlayletCanonicalURL(baseURL string, pagePath string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return pagePath + } + return baseURL + pagePath +} + +// emptyPlayletSearchResp 构建短剧ES空分页响应 +func emptyPlayletSearchResp(ctx context.Context, keyword string, pageNum int64, pageSize int64) *proto.PlayletSearchResp { + // ES索引未创建或暂无数据时返回空列表,保持接口响应结构稳定。 + return &proto.PlayletSearchResp{ + TotalCount: 0, + PageSize: int(pageSize), + PageNum: int(pageNum), + Keyword: keyword, + List: make([]proto.CommonData, 0), + EveryoneWatching: guessLikeCommonData(ctx), + } +} + +// buildHomePlayletResponse 查询并组装app首页短剧数据 +func buildHomePlayletResponse(ctx context.Context) (*proto.HomePlayletResponse, *srvdef.SrvErr) { + // 定义返回 + resp := &proto.HomePlayletResponse{ + HomeBannerData: make([]*proto.CommonData, 0), + Modules: make([]*proto.HomeModuleData, 0), + Announcements: make([]*proto.AnnouncementItem, 0), + } + // Home首页数据响应 + bannerList, err := findHomeBanners(ctx) + if err != nil { + log.Error("查询首页banner数据失败", log.E(err)) + return nil, err + } + configList, err := findHomeConfigs(ctx) + if err != nil { + log.Error("查询首页配置数据失败", log.E(err)) + return nil, err + } + configPlayletMap, configThemeMap, configPlaylets, err := findHomeConfigPlaylets(ctx, configList) + if err != nil { + log.Error("查询首页配置短剧数据失败", log.E(err)) + return nil, err + } + allPlaylets := mergeHomePlayletLists(bannerList, configPlaylets) + themeNameMap, err := buildHomeThemeNameMap(ctx, allPlaylets) + if err != nil { + log.Error("查询首页配置题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, allPlaylets) + if err != nil { + log.Error("查询首页配置标签名称失败", log.E(err)) + return nil, err + } + resp.HomeBannerData = playletsToHomeCommonData(ctx, bannerList, themeNameMap, tagNameMap) + for _, item := range homeConfigsToHomeData(ctx, configList, configPlayletMap, configThemeMap, themeNameMap, tagNameMap) { + resp.Modules = append(resp.Modules, homeConfigToModule(item)) + } + seo := configser.GetSiteSeoInfo() + resp.SeoInfo = &seo + return resp, nil +} + +func attachHomeAnnouncements(resp *proto.HomePlayletResponse) { + if resp == nil { + return + } + resp.Announcements = configser.GetAnnouncements() +} + +func attachHomeAdvitise(resp *proto.HomePlayletResponse) { + if resp == nil { + return + } + resp.Advitise = configser.GetAdvitise() +} + +func guessLikeCommonData(ctx context.Context) []*proto.CommonData { + list := playletcache.ListGuessLike() + themeNameMap, _ := buildHomeThemeNameMap(ctx, list) + tagNameMap, _ := buildHomeTagNameMap(ctx, list) + return playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap) +} + +// findHomeBanners 查询展示在首页 banner 的上架短剧 +func findHomeBanners(ctx context.Context) (playletmod.PlayletList, *srvdef.SrvErr) { + return findHomePlaylets(ctx, homeUpPlayletFilter(bson.M{ + playletmod.FN.Playlet.ShowOnBanner: true, + }), bson.D{ + {Key: playletmod.FN.Playlet.TopRank, Value: -1}, + {Key: playletmod.FN.Playlet.CreatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }, homeBannerLimit) +} + +// findHomeConfigs 查询启用的首页配置 +func findHomeConfigs(ctx context.Context) (homeconfigmod.HomeConfigList, *srvdef.SrvErr) { + return homeconfigmod.FindManyHomeConfig(ctx, bson.M{ + homeconfigmod.FN.HomeConfig.Status: true, + homeconfigmod.FN.HomeConfig.Type: bson.M{"$in": []int32{homeconfigmod.TypePlaylet, homeconfigmod.TypeTheme}}, + }, options.Find(). + SetProjection(homeConfigAppProjection). + SetSort(bson.D{ + {Key: homeconfigmod.FN.HomeConfig.Rank, Value: -1}, + {Key: homeconfigmod.FN.HomeConfig.Id, Value: -1}, + })) +} + +// findHomeConfigPlaylets 按首页配置查询短剧集合 +func findHomeConfigPlaylets(ctx context.Context, configs homeconfigmod.HomeConfigList) (map[int64]playletmod.PlayletList, map[int64][]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) { + configPlayletMap := make(map[int64]playletmod.PlayletList, len(configs)) + configThemeMap := make(map[int64][]homeConfigThemePlaylets, len(configs)) + allPlaylets := make(playletmod.PlayletList, 0) + for _, config := range configs { + if config == nil { + continue + } + switch config.Type { + case homeconfigmod.TypePlaylet: + list, err := findHomeConfigPlayletListForConfig(ctx, config, homeConfigDisplayLimit(config)) + if err != nil { + return nil, nil, nil, err + } + configPlayletMap[config.Id] = list + allPlaylets = append(allPlaylets, list...) + case homeconfigmod.TypeTheme: + groups, list, err := findHomeConfigThemePlayletsForConfig(ctx, config, 0, homeConfigDisplayLimit(config)) + if err != nil { + return nil, nil, nil, err + } + configThemeMap[config.Id] = groups + allPlaylets = append(allPlaylets, list...) + } + } + return configPlayletMap, configThemeMap, allPlaylets, nil +} + +func homeConfigDisplayLimit(config *homeconfigmod.HomeConfig) int64 { + if config != nil && config.Limit > 0 { + if config.Limit > maxPageSize { + return maxPageSize + } + return config.Limit + } + if config != nil && config.Type == homeconfigmod.TypeTheme { + return homeConfigThemeLimit + } + return homeConfigPlayletLimit +} + +func findHomeConfigPlayletList(ctx context.Context, sortType int32, categoryId, limit int64) (playletmod.PlayletList, *srvdef.SrvErr) { + filter, sort := homeConfigPlayletQuery(sortType, categoryId) + list, err := findHomePlayletsByHomeConfigSort(ctx, filter, sortType, categoryId, sort, 0, limit) + if err != nil { + return nil, err + } + if sortType != homeconfigmod.SortTypeRecommend || int64(len(list)) >= limit { + return list, nil + } + filter, sort = homeConfigPlayletQuery(homeconfigmod.SortTypeOnlineAt, 0) + if len(list) > 0 { + filter[playletmod.FN.Playlet.Id] = bson.M{"$nin": playletIdsFromList(list)} + } + moreList, err := findHomePlaylets(ctx, filter, sort, limit-int64(len(list))) + if err != nil { + return nil, err + } + return append(list, moreList...), nil +} + +func findHomePlayletsByHomeConfigSort(ctx context.Context, filter bson.M, sortType int32, categoryId int64, sort bson.D, skip, limit int64) (playletmod.PlayletList, *srvdef.SrvErr) { + if sortType == homeconfigmod.SortTypeCategory && categoryId > 0 { + return playletmod.FindManyPlayletByCategoryRank(ctx, filter, categoryId, skip, limit, playletmod.CategoryRankSort(), playletHomeProjection) + } + return findHomePlaylets(ctx, filter, sort, limit, options.Find().SetSkip(skip)) +} + +func playletIdsFromList(list playletmod.PlayletList) []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 +} + +func findHomeConfigThemePlaylets(ctx context.Context, limit int64) ([]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) { + themes := playletcache.ListEnabledThemesByPlayletCount(0) + result := make([]homeConfigThemePlaylets, 0) + allPlaylets := make(playletmod.PlayletList, 0) + for _, theme := range themes { + if theme == nil { + continue + } + filter := topicDetailPlayletFilter(theme.Id) + count, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, nil, err + } + if count == 0 { + continue + } + list, err := findHomePlaylets(ctx, filter, playletTopicDetailSort(topicDetailSortEdit), homeConfigPlayletLimit) + if err != nil { + return nil, nil, err + } + if len(list) == 0 { + continue + } + result = append(result, homeConfigThemePlaylets{ + Theme: theme, + Playlets: list, + TotalCount: count, + }) + allPlaylets = append(allPlaylets, list...) + if int64(len(result)) >= limit { + break + } + } + return result, allPlaylets, nil +} + +// findHomeConfigThemePlayletsPage 分页查询有短剧数据的首页配置题材 +func findHomeConfigThemePlayletsPage(ctx context.Context, skip, limit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) { + themes := playletcache.ListEnabledThemesByPlayletCount(0) + result := make([]homeConfigThemePlaylets, 0) + allPlaylets := make(playletmod.PlayletList, 0) + totalCount := int64(0) + for _, theme := range themes { + if theme == nil { + continue + } + filter := topicDetailPlayletFilter(theme.Id) + count, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, 0, nil, err + } + if count == 0 { + continue + } + if totalCount >= skip && int64(len(result)) < limit { + list, err := findHomePlaylets(ctx, filter, playletTopicDetailSort(topicDetailSortEdit), homeConfigPlayletLimit) + if err != nil { + return nil, 0, nil, err + } + result = append(result, homeConfigThemePlaylets{ + Theme: theme, + Playlets: list, + TotalCount: count, + }) + allPlaylets = append(allPlaylets, list...) + } + totalCount++ + } + return result, totalCount, allPlaylets, nil +} + +// fillPlayletTopicDetailMore 填充专题详情更多专题 +func fillPlayletTopicDetailMore(ctx context.Context, resp *proto.PlayletTopicDetailResp, currentThemeID int64, seedPlaylets playletmod.PlayletList) (*proto.PlayletTopicDetailResp, *srvdef.SrvErr) { + if resp == nil { + return resp, nil + } + groups, morePlaylets, err := findPlayletTopicMoreGroups(ctx, currentThemeID, topicDetailMoreLimit) + if err != nil { + return nil, err + } + allPlaylets := mergeHomePlayletLists(seedPlaylets, morePlaylets) + themeNameMap, err := buildHomeThemeNameMap(ctx, allPlaylets) + if err != nil { + log.Error("查询专题详情更多专题题材名称失败", log.E(err)) + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, allPlaylets) + if err != nil { + log.Error("查询专题详情更多专题标签名称失败", log.E(err)) + return nil, err + } + resp.MoreTopics = playletTopicsToPageData(ctx, groups, themeNameMap, tagNameMap) + return resp, nil +} + +// findPlayletTopicMoreGroups 查询更多专题 +func findPlayletTopicMoreGroups(ctx context.Context, currentThemeID int64, limit int64) ([]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) { + themes := playletcache.ListEnabledThemesByPlayletCount(0) + selected := make([]*playletcache.ThemeItem, 0, limit) + for _, theme := range themes { + if theme == nil || theme.Id == currentThemeID { + continue + } + selected = append(selected, theme) + if int64(len(selected)) >= limit { + break + } + } + groups, _, playlets, err := buildPlayletTopicGroups(ctx, selected, int64(len(selected)), topicListSortHot, topicListPlayletLimit) + if err != nil { + return nil, nil, err + } + return groups, playlets, nil +} + +// buildPlayletTopicGroups 查询专题卡片需要的短剧数据 +func buildPlayletTopicGroups(ctx context.Context, themes []*playletcache.ThemeItem, totalCount int64, sortType, playletLimit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) { + result := make([]homeConfigThemePlaylets, 0, len(themes)) + allPlaylets := make(playletmod.PlayletList, 0) + for _, theme := range themes { + if theme == nil { + continue + } + filter, sort := playletTopicPagePlayletQuery(sortType, theme.Id) + list, err := findHomePlaylets(ctx, filter, sort, playletLimit) + if err != nil { + return nil, 0, nil, err + } + if len(list) == 0 { + continue + } + result = append(result, homeConfigThemePlaylets{ + Theme: theme, + Playlets: list, + }) + allPlaylets = append(allPlaylets, list...) + } + return result, totalCount, allPlaylets, nil +} + +// playletTopicPagePlayletQuery 构建专题卡片短剧查询条件 +func playletTopicPagePlayletQuery(sortType int64, themeID int64) (bson.M, bson.D) { + filter := homeUpPlayletFilter(nil) + filter[playletmod.FN.Playlet.ThemeIds] = themeID + if sortType == topicListSortHot { + return filter, bson.D{ + {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + } + return filter, bson.D{ + {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } +} + +// topicDetailPlayletFilter 构建专题详情和首页题材共用的短剧过滤条件 +func topicDetailPlayletFilter(themeID int64) bson.M { + return homeUpPlayletFilter(bson.M{playletmod.FN.Playlet.ThemeIds: themeID}) +} + +func homeConfigCategoryID(sortType int32, configId int64) int64 { + if sortType == homeconfigmod.SortTypeCategory && configId > 0 { + return configId + } + return 0 +} + +func homeConfigPlayletQuery(sortType int32, categoryId int64) (bson.M, bson.D) { + filter := homeUpPlayletFilter(nil) + switch sortType { + case homeconfigmod.SortTypeRecommend: + filter[playletmod.FN.Playlet.IsRecommend] = true + return filter, playletTodayRecommendSort() + case homeconfigmod.SortTypeHot: + return filter, playletHitSeriesSort() + case homeconfigmod.SortTypeLatest: + return filter, playletRecentlyUpdatedSort() + case homeconfigmod.SortTypeOnlineAt: + return filter, playletTodayUpdateSort() + case homeconfigmod.SortTypeCategory: + if categoryId > 0 { + filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": []int64{categoryId}} + } + return filter, playletTodayUpdateSort() + default: + return filter, playletTodayUpdateSort() + } +} + +// homeUpPlayletFilter 构建首页短剧上架过滤条件 +func homeUpPlayletFilter(extra bson.M) bson.M { + filter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp} + for key, value := range extra { + filter[key] = value + } + return filter +} + +// homePlayletCacheKey 获取app首页短剧缓存key +func homePlayletCacheKey() string { + return redisconst.AppPlayletHomeCacheDataKey(homePlayletCacheVersion() + ":" + homePlayletCacheSchema) +} + +// homePlayletCacheVersion 获取app首页短剧缓存版本 +func homePlayletCacheVersion() string { + if redis.Client() == nil { + return "0" + } + value, err := redis.Get(redisconst.AppPlayletHomeCacheVersionKey()) + if err != nil { + log.Warn("读取app首页短剧缓存版本失败", log.E(err)) + return "0" + } + if value == nil || strings.TrimSpace(*value) == "" { + return "0" + } + return strings.TrimSpace(*value) +} + +// getHomePlayletCache 读取app首页短剧缓存 +func getHomePlayletCache(cacheKey string) *proto.HomePlayletResponse { + if redis.Client() == nil || cacheKey == "" { + return nil + } + value, err := redis.Get(cacheKey) + if err != nil { + log.Warn("读取app首页短剧缓存失败", 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("解析app首页短剧缓存失败", log.E(err)) + return nil + } + return &resp +} + +// setHomePlayletCache 写入app首页短剧缓存 +func setHomePlayletCache(cacheKey string, resp *proto.HomePlayletResponse) { + if redis.Client() == nil || cacheKey == "" || resp == nil { + return + } + data, err := json.Marshal(resp) + if err != nil { + log.Warn("序列化app首页短剧缓存失败", log.E(err)) + return + } + if err = redis.Set(cacheKey, data, redisconst.AppPlayletHomeCacheExpire); err != nil { + log.Warn("写入app首页短剧缓存失败", log.E(err)) + } +} + +// findHomePlaylets 查询首页分区或列表短剧数据 +func findHomePlaylets(ctx context.Context, filter bson.M, sort bson.D, limit int64, extraOpts ...*options.FindOptions) (playletmod.PlayletList, *srvdef.SrvErr) { + opt := options.Find(). + SetProjection(playletHomeProjection). + SetSort(sort). + SetLimit(limit) + for _, extra := range extraOpts { + if extra == nil { + continue + } + if extra.Skip != nil { + opt.SetSkip(*extra.Skip) + } + } + return playletmod.FindManyPlaylet(ctx, filter, opt) +} + +// mergeHomePlayletLists 合并首页各分区短剧,便于后续批量查询题材和标签名称 +func mergeHomePlayletLists(lists ...playletmod.PlayletList) playletmod.PlayletList { + total := 0 + for _, list := range lists { + total += len(list) + } + result := make(playletmod.PlayletList, 0, total) + for _, list := range lists { + result = append(result, list...) + } + return result +} + +// homeConfigsToHomeData 转换首页配置响应数据 +func homeConfigsToHomeData(ctx context.Context, list homeconfigmod.HomeConfigList, configPlayletMap map[int64]playletmod.PlayletList, configThemeMap map[int64][]homeConfigThemePlaylets, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.HomeConfigData { + result := make([]*proto.HomeConfigData, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + if item.SortType == homeconfigmod.SortTypeCategory { + if item.Type == homeconfigmod.TypePlaylet && len(configPlayletMap[item.Id]) == 0 { + continue + } + } + data := &proto.HomeConfigData{ + Id: item.Id, + Name: item.Name, + Slug: item.Slug, + Desc: item.Desc, + Keyword: homeconfigmod.NormalizeKeywords(item.Keyword), + Label: item.Label, + Style: item.Style, + CardBackgroundImage: withImageDomain(ctx, item.CardBackgroundImage), + TopicIds: item.TopicIds, + CategoryId: item.CategoryId, + RecommendType: item.RecommendType, + Type: item.Type, + Rank: item.Rank, + Limit: homeConfigDisplayLimit(item), + SortType: item.SortType, + Status: item.Status, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + List: playletsToHomeCommonData(ctx, configPlayletMap[item.Id], themeNameMap, tagNameMap), + ThemeList: make([]*proto.HomeConfigThemeData, 0), + SeoInfo: item.SeoInfo, + } + switch item.Type { + case homeconfigmod.TypeTheme: + data.List = make([]*proto.CommonData, 0) + data.ThemeList = homeConfigThemesToHomeData(ctx, configThemeMap[item.Id], themeNameMap, tagNameMap) + } + result = append(result, data) + } + return result +} + +// homeConfigThemesToHomeData 转换首页配置题材响应数据 +func homeConfigThemesToHomeData(ctx context.Context, groups []homeConfigThemePlaylets, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.HomeConfigThemeData { + result := make([]*proto.HomeConfigThemeData, 0, len(groups)) + for _, group := range groups { + if group.Theme == nil || len(group.Playlets) == 0 { + continue + } + list := playletsToHomeCommonData(ctx, group.Playlets, themeNameMap, tagNameMap) + result = append(result, &proto.HomeConfigThemeData{ + Id: group.Theme.Id, + Name: group.Theme.Name, + Slug: group.Theme.Slug, + Icon: withImageDomain(ctx, group.Theme.Icon), + Rank: group.Theme.Rank, + PlayletCount: group.TotalCount, + List: list, + SeoInfo: buildPageSeoInfoFromValue(group.Theme.SeoInfo, group.Theme.Name, "", buildPlayletThemeListCanonicalURL(group.Theme.Slug, group.Theme.Id)), + }) + } + return result +} + +// playletTopicsToPageData 转换专题剧单分页响应 +func playletTopicsToPageData(ctx context.Context, groups []homeConfigThemePlaylets, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.PlayletTopicPageItem { + result := make([]*proto.PlayletTopicPageItem, 0, len(groups)) + for _, group := range groups { + if group.Theme == nil || len(group.Playlets) == 0 { + continue + } + list := playletsToHomeCommonData(ctx, group.Playlets, themeNameMap, tagNameMap) + result = append(result, &proto.PlayletTopicPageItem{ + Id: group.Theme.Id, + Name: group.Theme.Name, + Slug: group.Theme.Slug, + Desc: strings.TrimSpace(group.Theme.SeoInfo.Description), + Icon: withImageDomain(ctx, group.Theme.Icon), + Rank: group.Theme.Rank, + PlayletCount: int64(len(list)), + Covers: playletTopicCovers(list), + List: list, + SeoInfo: buildPageSeoInfoFromValue(group.Theme.SeoInfo, group.Theme.Name, "", buildPlayletThemeListCanonicalURL(group.Theme.Slug, group.Theme.Id)), + }) + } + return result +} + +// playletTopicCovers 提取专题卡片封面 +func playletTopicCovers(list []*proto.CommonData) []string { + result := make([]string, 0, len(list)) + for _, item := range list { + if item == nil || strings.TrimSpace(item.Cover) == "" { + continue + } + result = append(result, item.Cover) + } + return result +} + +// buildHomeThemeNameMap 从缓存批量取首页短剧题材名称 +func buildHomeThemeNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { + themeIds := collectHomeIds(list, func(item *playletmod.Playlet) []int64 { + return item.ThemeIds + }) + return playletcache.ThemeNameMap(themeIds), nil +} + +// buildHomeTagNameMap 从缓存批量取首页短剧标签名称 +func buildHomeTagNameMap(ctx context.Context, list playletmod.PlayletList) (map[int64]string, *srvdef.SrvErr) { + tagIds := collectHomeIds(list, func(item *playletmod.Playlet) []int64 { + return item.TagIds + }) + return playletcache.TagNameMap(tagIds), nil +} + +// collectHomeIds 汇总并去重首页短剧的题材或标签ID +func collectHomeIds(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 +} + +// playletsToHomeCommonData 转换首页通用短剧响应数据 +func playletsToHomeCommonData(ctx context.Context, list playletmod.PlayletList, themeNameMap map[int64]string, tagNameMap map[int64]string) []*proto.CommonData { + result := make([]*proto.CommonData, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + if len(item.Description) > 0 { + item.SeoInfo.Description = item.Description + } + result = append(result, &proto.CommonData{ + Id: item.Id, + Status: int32(item.Status), + IsFeatured: item.IsFeatured, + IsOriginal: item.IsOriginal || item.MediaCenterId == 0, + ShowOriginal: item.ShowOriginal, + IsRecommend: item.IsRecommend, + IsNew: isOnlineThisWeek(item.OnlineAt), + OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)), + ShowOnBanner: item.ShowOnBanner, + Banner: withImageDomain(ctx, item.Banner), + MobileBanner: withImageDomain(ctx, item.MobileBanner), + OnlineAt: item.OnlineAt, + MediaCreatedAt: item.MediaCreatedAt, + MediaUpdatedAt: item.MediaUpdatedAt, + ThemeIds: item.ThemeIds, + ThemeNames: namesByIds(item.ThemeIds, themeNameMap), + ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)), + CategoryIds: item.CategoryIds, + Title: item.Title, + PlayName: item.PlayName, + Description: item.Description, + Slug: item.Slug, + Desc: item.Desc, + Anchor: item.Anchor, + Cover: withImageDomain(ctx, item.DisplayCover()), + HorizontalCover: withImageDomain(ctx, item.DisplayHorizontalCover()), + Preview: item.Preview, + TagIds: item.TagIds, + TagNames: namesByIds(item.TagIds, tagNameMap), + TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)), + TotalEpisode: item.TotalEpisode, + UpdateStatus: item.UpdateStatus, + CountBrowse: item.CountBrowse, + HeatValue: item.HeatValue, + CountCollect: item.CountCollect, + CountLike: displayPlayletCountLike(item.CountLike, item.VirtualCountLike), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + SeoInfo: item.SeoInfo, + }) + } + return result +} + +// slugsByIds 按ID顺序返回 slug +func slugsByIds(ids []int64, slugMap map[int64]string) []string { + result := make([]string, 0, len(ids)) + for _, id := range ids { + if slug := slugMap[id]; slug != "" { + result = append(result, slug) + } + } + return result +} + +// namesByIds 按ID顺序返回名称 +func namesByIds(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 +} + +// isOnlineThisWeek 判断短剧是否为本周内上架,用于app端展示“上新”标识。 +func isOnlineThisWeek(onlineAt time.Time) bool { + if onlineAt.IsZero() { + return false + } + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + weekStart := time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location()) + return !onlineAt.Before(weekStart) && !onlineAt.After(now) +} + +// commonDataValues 将短剧指针列表转换为响应值列表 +func commonDataValues(list []*proto.CommonData) []proto.CommonData { + result := make([]proto.CommonData, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + result = append(result, *item) + } + return result +} + +// playletAllListFilter 构建全部短剧分页过滤条件 +func playletAllListFilter(req *proto.PlayletAllListReq) (bson.M, *srvdef.SrvErr) { + filter := homeUpPlayletFilter(nil) + if req.ThemeId > 0 { + filter[playletmod.FN.Playlet.ThemeIds] = bson.M{"$in": []int64{req.ThemeId}} + } + switch req.UpdateStatus { + case 0: + case playletmod.UpdateStatusSerial, playletmod.UpdateStatusDone: + filter[playletmod.FN.Playlet.UpdateStatus] = req.UpdateStatus + default: + return nil, srvdef.New(srvdef.ErrParamError, "更新状态无效", "更新状态无效") + } + return filter, nil +} + +// playletAllListSort 构建全部短剧分页排序 +func playletAllListSort(sortType int64) bson.D { + switch sortType { + case allListSortHot: + return bson.D{ + {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + case allListSortCollect: + return bson.D{ + {Key: playletmod.FN.Playlet.CountCollect, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + case allListSortLatest: + fallthrough + default: + return bson.D{ + {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + } +} + +// playletTopicDetailSort 构建专题详情短剧排序 +func playletTopicDetailSort(sortType int64) bson.D { + switch sortType { + case topicDetailSortLatest: + return bson.D{ + {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + case topicDetailSortHot: + return bson.D{ + {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + case topicDetailSortEdit: + fallthrough + default: + return bson.D{ + {Key: playletmod.FN.Playlet.TopRank, Value: -1}, + {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, + {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + } +} + +// playletSearchQuery 构建短剧ES搜索查询条件 +func playletSearchQuery(keyword string, from int64, size int64) esmanager.M { + return esmanager.M{ + "from": from, + "size": size, + "query": esmanager.M{ + "bool": esmanager.M{ + "must": esmanager.A{ + { + "bool": esmanager.M{ + "should": esmanager.A{ + { + "multi_match": esmanager.M{ + "query": keyword, + "fields": playletSearchFields(), + "type": "phrase", + "boost": 3, + }, + }, + { + // 与剧名入库的 ik_max_word 一致。查询默认的 ik_smart 会把长标题收成索引里没有的整词。 + // 1~2 个词仍需全部命中;更长的标题至少命中 75%,避免括号和数字切出的多余词把结果清掉。 + "multi_match": esmanager.M{ + "query": keyword, + "fields": playletSearchFields(), + "type": "best_fields", + "analyzer": esmanager.AnalyzerIkMaxWord, + "minimum_should_match": "2<75%", + }, + }, + }, + "minimum_should_match": 1, + }, + }, + }, + "filter": esmanager.A{ + { + "term": esmanager.M{ + esmod.FN.ESPlaylet.Status: int32(playletmod.StatusUp), + }, + }, + }, + }, + }, + "sort": esmanager.A{ + { + "_score": esmanager.M{"order": "desc"}, + }, + { + esmod.FN.ESPlaylet.OnlineAt: esmanager.M{"order": "desc"}, + }, + { + esmod.FN.ESPlaylet.Id: esmanager.M{"order": "desc"}, + }, + }, + } +} + +// playletSearchFields 返回短剧ES搜索字段和权重 +func playletSearchFields() []string { + // 剧名权重最高,题材名和标签名次之,简介兜底命中。 + return []string{ + esmod.FN.ESPlaylet.Title + "^3", + esmod.FN.ESPlaylet.PlayName + "^3", + esmod.FN.ESPlaylet.ThemeNames + "^2", + esmod.FN.ESPlaylet.TagNames + "^2", + esmod.FN.ESPlaylet.Desc, + } +} + +// loadESPlayletLabels 从Mongo批量读取标识,兼容尚未重建的ES文档 +func loadESPlayletLabels(ctx context.Context, hits []*esmod.ESPlayletSource) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) { + labels := make(map[int64]*playletmod.Playlet, len(hits)) + ids := make([]int64, 0, len(hits)) + for _, hit := range hits { + if hit != nil && hit.Source != nil { + ids = append(ids, hit.Source.Id) + } + } + if len(ids) == 0 { + return labels, nil + } + 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.MediaCenterId, Value: 1}, + {Key: playletmod.FN.Playlet.IsOriginal, Value: 1}, + {Key: playletmod.FN.Playlet.ShowOriginal, Value: 1}, + })) + if err != nil { + return nil, err + } + for _, item := range list { + if item != nil { + labels[item.Id] = item + } + } + return labels, nil +} + +// esPlayletToCommonData 转换ES短剧文档为app通用短剧响应 +func esPlayletToCommonData(ctx context.Context, item *esmod.ESPlaylet, label *playletmod.Playlet) proto.CommonData { + isOriginal, showOriginal := false, false + if label != nil { + isOriginal = label.IsOriginal || label.MediaCenterId == 0 + showOriginal = label.ShowOriginal + } + return proto.CommonData{ + Id: item.Id, + Status: int32(item.Status), + IsFeatured: item.IsFeatured, + IsOriginal: isOriginal, + ShowOriginal: showOriginal, + IsRecommend: item.IsRecommend, + IsNew: isOnlineThisWeek(item.OnlineAt), + OperateType: int32(playletmod.NormalizeOperateType(item.OperateType)), + ShowOnBanner: item.ShowOnBanner, + Banner: withImageDomain(ctx, item.Banner), + OnlineAt: item.OnlineAt, + ThemeIds: item.ThemeIds, + ThemeNames: item.ThemeNames, + ThemeSlugs: slugsByIds(item.ThemeIds, playletcache.ThemeSlugMap(item.ThemeIds)), + CategoryIds: item.CategoryIds, + Title: item.Title, + PlayName: item.PlayName, + Description: item.Description, + Slug: item.Slug, + Desc: item.Desc, + Anchor: item.Anchor, + Cover: withImageDomain(ctx, item.Cover), + TagIds: item.TagIds, + TagNames: item.TagNames, + TagSlugs: slugsByIds(item.TagIds, playletcache.TagSlugMap(item.TagIds)), + TotalEpisode: item.TotalEpisode, + UpdateStatus: item.UpdateStatus, + CountBrowse: item.CountBrowse, + HeatValue: item.HeatValue, + CountCollect: item.CountCollect, + CountLike: item.CountLike, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + SeoInfo: item.SeoInfo, + } +} + +// playletRankSort 构建app短剧排行榜排序 +func playletRankSort(sortOrder int64) bson.D { + switch sortOrder { + case rankHot: + return bson.D{ + {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + case rankNew: + return bson.D{ + {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + case rankCollect: + return bson.D{ + {Key: playletmod.FN.Playlet.CountCollect, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + default: + return bson.D{ + {Key: playletmod.FN.Playlet.HeatValue, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + } +} + +// normalizePlayletHotRankType 规范化热榜类型 +func normalizePlayletHotRankType(rankType int64) int64 { + if rankType == 0 { + return rankHot + } + switch rankType { + case rankHot, rankNew, rankCollect: + return rankType + default: + return 0 + } +} + +// playletHotRankSort 构建热榜排序 +func playletHotRankSort(rankType int64) bson.D { + return playletRankSort(rankType) +} + +// playletTodayRecommendSort 构建今日推荐短剧排序 +func playletTodayRecommendSort() bson.D { + return bson.D{ + {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, + {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } +} + +// playletHitSeriesSort 构建热门好剧短剧排序 +func playletHitSeriesSort() bson.D { + return bson.D{ + {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } +} + +// playletRecentlyUpdatedSort 按短剧记录更新时间排序。 +func playletRecentlyUpdatedSort() bson.D { + return bson.D{ + {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } +} + +// playletTodayUpdateSort 构建今日更新短剧排序 +func playletTodayUpdateSort() bson.D { + return bson.D{ + {Key: playletmod.FN.Playlet.OnlineAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } +} + +// normalizePlayletPage 规范化短剧分页参数 +func normalizePlayletPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = defaultPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return pageNum, pageSize +} + +// normalizePlayletGuessLikePage 规范化猜你喜欢分页参数 +func normalizePlayletGuessLikePage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = guessLikePageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return pageNum, pageSize +} + +// normalizePlayletHotRankPage 规范化热榜分页参数 +func normalizePlayletHotRankPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = hotRankPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return pageNum, pageSize +} + +// normalizePlayletTopicDetailPage 规范化专题详情分页参数 +func normalizePlayletTopicDetailPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = topicDetailPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return pageNum, pageSize +} + +// normalizePlayletHistoryPage 规范化观看历史分页参数 +func normalizePlayletHistoryPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = recommendPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return pageNum, pageSize +} + +// withImageDomain 给图片相对路径拼接图片域名,海外 IP 使用 imgAppUsCdn。 +func withImageDomain(ctx context.Context, path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + imageDomain := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + if imageDomain == "" { + return path + } + return netutil.MergeURL(imageDomain, path) +} diff --git a/app/service/playletser/slug.go b/app/service/playletser/slug.go new file mode 100644 index 0000000..fb35210 --- /dev/null +++ b/app/service/playletser/slug.go @@ -0,0 +1,77 @@ +package playletser + +import ( + "context" + "strings" + "videoweb/app/service/playletcache" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/pub/models/playletthememod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// resolvePlayletID 解析短剧ID:slug 非空时先按 slug 查 ID,否则使用传入 ID。 +func resolvePlayletID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) { + slug = strings.TrimSpace(slug) + if slug != "" { + item, err := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Slug: slug, + }, options.FindOne().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + })) + if err != nil { + return 0, err + } + return item.Id, nil + } + if required && id <= 0 { + return 0, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空") + } + return id, nil +} + +// resolveThemeID 解析题材ID:slug 非空时先按 slug 查 ID,否则使用传入 ID(0 表示不按题材筛选)。 +func resolveThemeID(ctx context.Context, id int64, slug string) (int64, *srvdef.SrvErr) { + slug = strings.TrimSpace(slug) + if slug == "" { + return id, nil + } + if item := playletcache.GetThemeBySlug(slug); item != nil { + return item.Id, nil + } + theme, err := playletthememod.FindOnePlayletTheme(ctx, bson.M{ + playletthememod.FN.PlayletTheme.Slug: slug, + }, options.FindOne().SetProjection(bson.D{ + {Key: playletthememod.FN.PlayletTheme.Id, Value: 1}, + })) + if err != nil { + return 0, err + } + return theme.Id, nil +} + +// resolveTagID 解析标签ID:slug 非空时先按 slug 查 ID,否则使用传入 ID。 +func resolveTagID(ctx context.Context, id int64, slug string) (int64, *srvdef.SrvErr) { + slug = strings.TrimSpace(slug) + if slug != "" { + if item := playletcache.GetTagBySlug(slug); item != nil { + return item.Id, nil + } + tag, err := playlettagmod.FindOnePlayletTag(ctx, bson.M{ + playlettagmod.FN.PlayletTag.Slug: slug, + }, options.FindOne().SetProjection(bson.D{ + {Key: playlettagmod.FN.PlayletTag.Id, Value: 1}, + })) + if err != nil { + return 0, err + } + return tag.Id, nil + } + if id <= 0 { + return 0, srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "标签ID不能为空") + } + return id, nil +} diff --git a/app/service/playletser/topic_group.go b/app/service/playletser/topic_group.go new file mode 100644 index 0000000..97cc00e --- /dev/null +++ b/app/service/playletser/topic_group.go @@ -0,0 +1,131 @@ +package playletser + +import ( + "context" + "strings" + "videoweb/app/api/proto" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playletthememod" + "videoweb/pub/models/playlettopicgroupmod" + + "go.mongodb.org/mongo-driver/bson" +) + +func enabledTopicGroups(ctx context.Context) (playlettopicgroupmod.PlayletTopicGroupList, *srvdef.SrvErr) { + return playlettopicgroupmod.FindMany(ctx, bson.M{"status": true}) +} + +type topicPlayletCount struct { + Id int64 `bson:"_id"` + Count int64 `bson:"count"` +} + +// topicGroupPage 分页查询区块下有已上架短剧的专题 +func topicGroupPage(ctx context.Context, group *playlettopicgroupmod.PlayletTopicGroup, skip, limit int64) (playletthememod.PlayletThemeList, map[int64]int64, int64, *srvdef.SrvErr) { + counts := make(map[int64]int64) + if len(group.TopicIds) == 0 { + return playletthememod.PlayletThemeList{}, counts, 0, nil + } + themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{ + playletthememod.FN.PlayletTheme.Id: bson.M{"$in": group.TopicIds}, + playletthememod.FN.PlayletTheme.Status: true, + }) + if err != nil { + return nil, nil, 0, err + } + byID := make(map[int64]*playletthememod.PlayletTheme, len(themes)) + for _, theme := range themes { + if theme != nil { + byID[theme.Id] = theme + } + } + rows := make([]topicPlayletCount, 0) + err = playletmod.AggregatePlaylet(ctx, &rows, []bson.M{ + {"$match": bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp, playletmod.FN.Playlet.ThemeIds: bson.M{"$in": group.TopicIds}}}, + {"$unwind": "$" + playletmod.FN.Playlet.ThemeIds}, + {"$match": bson.M{playletmod.FN.Playlet.ThemeIds: bson.M{"$in": group.TopicIds}}}, + {"$group": bson.M{"_id": "$" + playletmod.FN.Playlet.ThemeIds, "count": bson.M{"$sum": 1}}}, + }) + if err != nil { + return nil, nil, 0, err + } + for _, row := range rows { + counts[row.Id] = row.Count + } + available := make(playletthememod.PlayletThemeList, 0, len(themes)) + seen := make(map[int64]bool, len(group.TopicIds)) + for _, id := range group.TopicIds { + if seen[id] { + continue + } + seen[id] = true + if theme := byID[id]; theme != nil && counts[id] > 0 { + available = append(available, theme) + } + } + total := int64(len(available)) + if skip >= total { + return playletthememod.PlayletThemeList{}, counts, total, nil + } + end := skip + limit + if end > total { + end = total + } + return available[skip:end], counts, total, nil +} + +// topicGroupPageData 组装区块当前页专题及单部短剧预览 +func topicGroupPageData(ctx context.Context, group *playlettopicgroupmod.PlayletTopicGroup, skip, limit int64) (*proto.PlayletTopicGroupPageItem, *srvdef.SrvErr) { + themes, counts, totalCount, err := topicGroupPage(ctx, group, skip, limit) + if err != nil { + return nil, err + } + page := &proto.PlayletTopicGroupPageItem{ + Id: group.Id, Name: group.Name, Desc: group.Desc, Rank: group.Rank, + TotalCount: totalCount, List: make([]*proto.PlayletTopicPageItem, 0, len(themes)), + } + if len(themes) == 0 { + return page, nil + } + previewByTheme := make(map[int64]playletmod.PlayletList, len(themes)) + previews := make(playletmod.PlayletList, 0, len(themes)) + for _, theme := range themes { + filter, sort := playletTopicPagePlayletQuery(0, theme.Id) + list, err := findHomePlaylets(ctx, filter, sort, 1) + if err != nil { + return nil, err + } + previewByTheme[theme.Id] = list + previews = append(previews, list...) + } + themeNameMap, err := buildHomeThemeNameMap(ctx, previews) + if err != nil { + return nil, err + } + tagNameMap, err := buildHomeTagNameMap(ctx, previews) + if err != nil { + return nil, err + } + for _, theme := range themes { + preview := playletsToHomeCommonData(ctx, previewByTheme[theme.Id], themeNameMap, tagNameMap) + covers := make([]string, 0, len(preview)) + if len(preview) > 0 { + cover := preview[0].HorizontalCover + if cover == "" { + cover = preview[0].Cover + } + if cover != "" { + covers = append(covers, cover) + } + } + page.List = append(page.List, &proto.PlayletTopicPageItem{ + Id: theme.Id, Name: theme.Name, Slug: theme.Slug, + Desc: strings.TrimSpace(theme.SeoInfo.Description), + Icon: withImageDomain(ctx, theme.Icon), Rank: theme.Rank, + PlayletCount: counts[theme.Id], Covers: covers, List: preview, + SeoInfo: buildPageSeoInfoFromValue(theme.SeoInfo, theme.Name, "", buildPlayletThemeListCanonicalURL(theme.Slug, theme.Id)), + }) + } + return page, nil +} diff --git a/app/service/playlettagser/tag.go b/app/service/playlettagser/tag.go new file mode 100644 index 0000000..5b21466 --- /dev/null +++ b/app/service/playlettagser/tag.go @@ -0,0 +1,89 @@ +package playlettagser + +import ( + "context" + "strings" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/playletcache" + "videoweb/pub/common/srvdef" + "videoweb/pub/pubdef" + "videoweb/pub/seo/h5path" +) + +// List 查询 app 端短剧标签列表(读标签缓存,含 SEO)。 +func List(ctx context.Context) (*proto.PlayletTagListResponse, *srvdef.SrvErr) { + list := playletcache.ListEnabledTags() + items := make([]*proto.PlayletTagItem, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, &proto.PlayletTagItem{ + Id: item.Id, + Name: item.Name, + Slug: item.Slug, + Rank: item.Rank, + Status: item.Status, + SeoInfo: buildTagSeoInfo(item), + }) + } + return &proto.PlayletTagListResponse{List: items}, nil +} + +// buildTagSeoInfo 将标签后台 SEO 转为页面 SeoInfo,缺失字段回退标签名/站点默认。 +func buildTagSeoInfo(item *playletcache.TagItem) *proto.SeoInfo { + seo := configser.GetSiteSeoInfo() + if item == nil { + return &seo + } + custom := normalizeTagSeoValue(item.SeoInfo) + if title := strings.TrimSpace(custom.Title); title != "" { + seo.Title = title + } else if name := strings.TrimSpace(item.Name); name != "" { + seo.Title = name + } + if desc := strings.TrimSpace(custom.Description); desc != "" { + seo.Description = desc + } + if len(custom.Keyworld) > 0 { + seo.Keywords = append([]string{}, custom.Keyworld...) + } + pagePath := h5path.Tag(item.Slug, item.Id) + seo.CanonicalURL = joinCanonicalURL(seo.CanonicalURL, pagePath) + return &seo +} + +func normalizeTagSeoValue(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 joinCanonicalURL(baseURL, pagePath string) string { + pagePath = strings.TrimSpace(pagePath) + if pagePath == "" { + return strings.TrimSpace(baseURL) + } + if !strings.HasPrefix(pagePath, "/") { + pagePath = "/" + pagePath + } + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return pagePath + } + return strings.TrimRight(baseURL, "/") + pagePath +} diff --git a/app/service/playletthemeser/theme.go b/app/service/playletthemeser/theme.go new file mode 100644 index 0000000..fe65c5c --- /dev/null +++ b/app/service/playletthemeser/theme.go @@ -0,0 +1,109 @@ +package playletthemeser + +import ( + "context" + "strings" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/playletcache" + "videoweb/pub/common/netutil" + "videoweb/pub/common/srvdef" + "videoweb/pub/pubdef" + "videoweb/pub/seo/h5path" +) + +// List 查询 app 端短剧题材列表(读题材缓存,含 SEO) +func List(ctx context.Context) (*proto.PlayletThemeListResponse, *srvdef.SrvErr) { + list := playletcache.ListEnabledThemes() + imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + items := make([]*proto.PlayletThemeItem, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, &proto.PlayletThemeItem{ + Id: item.Id, + Name: item.Name, + Slug: item.Slug, + Icon: mergeThemeIcon(imgCdn, item.Icon), + Rank: item.Rank, + Status: item.Status, + IsRecommend: item.IsRecommend, + PlayletCount: item.PlayletCount, + SeoInfo: buildThemeSeoInfo(item), + }) + } + return &proto.PlayletThemeListResponse{List: items}, nil +} + +// buildThemeSeoInfo 将题材后台 SEO 转为页面 SeoInfo,缺失字段回退题材名/站点默认。 +func buildThemeSeoInfo(item *playletcache.ThemeItem) *proto.SeoInfo { + seo := configser.GetSiteSeoInfo() + if item == nil { + return &seo + } + custom := normalizeThemeSeoValue(item.SeoInfo) + if title := strings.TrimSpace(custom.Title); title != "" { + seo.Title = title + } else if name := strings.TrimSpace(item.Name); name != "" { + seo.Title = name + } + if desc := strings.TrimSpace(custom.Description); desc != "" { + seo.Description = desc + } + if len(custom.Keyworld) > 0 { + seo.Keywords = append([]string{}, custom.Keyworld...) + } + pagePath := h5path.ListTheme(item.Slug, item.Id) + seo.CanonicalURL = joinCanonicalURL(seo.CanonicalURL, pagePath) + return &seo +} + +func normalizeThemeSeoValue(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 joinCanonicalURL(baseURL, pagePath string) string { + pagePath = strings.TrimSpace(pagePath) + if pagePath == "" { + return strings.TrimSpace(baseURL) + } + if !strings.HasPrefix(pagePath, "/") { + pagePath = "/" + pagePath + } + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return pagePath + } + return strings.TrimRight(baseURL, "/") + pagePath +} + +// mergeThemeIcon 给题材图标拼接图片域名 +func mergeThemeIcon(imgCdn string, icon string) string { + icon = strings.TrimSpace(icon) + if icon == "" { + return "" + } + if strings.HasPrefix(icon, "http://") || strings.HasPrefix(icon, "https://") { + return icon + } + if imgCdn == "" { + return icon + } + return netutil.MergeURL(imgCdn, icon) +} diff --git a/app/service/rechargepackageser/package.go b/app/service/rechargepackageser/package.go new file mode 100644 index 0000000..1dc9d23 --- /dev/null +++ b/app/service/rechargepackageser/package.go @@ -0,0 +1,108 @@ +package rechargepackageser + +import ( + "context" + "videoweb/app/api/proto" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/rechargepackagemod" + "videoweb/pub/payCenter" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetRechargePackageList 查询购买套餐页所需的会员畅享和金币充值列表,并为各套餐附上可用支付方式。 +// 内部统一调用一次新支付中心通道查询,避免 N+1 请求。 +func GetRechargePackageList(ctx context.Context) (*proto.RechargePackageListResp, *srvdef.SrvErr) { + filter := bson.M{rechargepackagemod.FN.RechargePackage.Status: rechargepackagemod.StatusEnabled} + opts := options.Find().SetSort(bson.D{ + {Key: rechargepackagemod.FN.RechargePackage.PackageType, Value: 1}, + {Key: rechargepackagemod.FN.RechargePackage.Sort, Value: 1}, + {Key: rechargepackagemod.FN.RechargePackage.Id, Value: -1}, + }) + list, err := rechargepackagemod.FindManyRechargePackage(ctx, filter, opts) + if err != nil { + return nil, err + } + if len(list) == 0 { + return &proto.RechargePackageListResp{ + VipList: make([]*proto.RechargePackageItem, 0), + CoinList: make([]*proto.RechargePackageItem, 0), + }, nil + } + + channelMap := buildPayMethodMap(ctx, list) + + resp := &proto.RechargePackageListResp{ + VipList: make([]*proto.RechargePackageItem, 0), + CoinList: make([]*proto.RechargePackageItem, 0), + } + for _, item := range list { + data := rechargePackageToAppItem(item, channelMap[item.Price]) + switch item.PackageType { + case rechargepackagemod.PackageTypeVip: + resp.VipList = append(resp.VipList, data) + case rechargepackagemod.PackageTypeCoin: + resp.CoinList = append(resp.CoinList, data) + } + } + return resp, nil +} + +// buildPayMethodMap 按金额批量查询可用支付方式;查询失败时返回空映射,不影响套餐列表展示。 +func buildPayMethodMap(ctx context.Context, list rechargepackagemod.RechargePackageList) map[int64][]proto.PayMethodInfo { + seen := map[int64]struct{}{} + moneys := make([]int64, 0) + for _, item := range list { + if item == nil || item.Price <= 0 { + continue + } + if _, ok := seen[item.Price]; ok { + continue + } + seen[item.Price] = struct{}{} + moneys = append(moneys, item.Price) + } + if len(moneys) == 0 { + return map[int64][]proto.PayMethodInfo{} + } + + channels, serr := payCenter.GetPayChannelByMoney(ctx, moneys) + if serr != nil { + log.Error("rechargepackageser:查询支付通道失败", log.E(serr)) + return map[int64][]proto.PayMethodInfo{} + } + channelMap := make(map[int64][]proto.PayMethodInfo, len(moneys)) + for _, money := range moneys { + payTypes := channels.GetPayType(money) + methods := make([]proto.PayMethodInfo, 0, len(payTypes)) + for _, payType := range payTypes { + methods = append(methods, proto.PayMethodInfo{Type: payType.Type, TypeName: payType.TypeName}) + } + channelMap[money] = methods + } + return channelMap +} + +// rechargePackageToAppItem 将购买套餐表数据转换为 App 展示字段 +func rechargePackageToAppItem(item *rechargepackagemod.RechargePackage, payMethods []proto.PayMethodInfo) *proto.RechargePackageItem { + if payMethods == nil { + payMethods = []proto.PayMethodInfo{} + } + return &proto.RechargePackageItem{ + Id: item.Id, + PackageType: item.PackageType, + Name: item.Name, + SubTitle: item.SubTitle, + Price: item.Price, + OriginalPrice: item.OriginalPrice, + DurationDays: item.DurationDays, + DurationHours: item.DurationDays * 24, + BaseCoin: item.BaseCoin, + GiftCoin: item.GiftCoin, + TotalCoin: item.BaseCoin + item.GiftCoin, + Sort: item.Sort, + PayMethods: payMethods, + } +} diff --git a/app/service/rechargeser/pay_center.go b/app/service/rechargeser/pay_center.go new file mode 100644 index 0000000..fe3fa50 --- /dev/null +++ b/app/service/rechargeser/pay_center.go @@ -0,0 +1,54 @@ +package rechargeser + +import ( + "context" + "strconv" + "strings" + + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/payCenter" + "videoweb/pub/pubdef" +) + +// PayDeviceID 支付中心要求 deviceId 非空。本次请求带了设备号就用本次的,否则用用户资料里保存的设备号。 +func PayDeviceID(requestDeviceID, savedDeviceID, backupDeviceID string) string { + for _, value := range []string{requestDeviceID, savedDeviceID, backupDeviceID} { + if deviceID := strings.TrimSpace(value); deviceID != "" { + return deviceID + } + } + return "" +} + +// SubmitPayOrder 向新支付中心下单。会员和金币订单共用同一协议,本地订单仍由调用方入库。 +func SubmitPayOrder(ctx context.Context, userId int64, userName, tradeNo string, amount int64, payMode, sysType, devType, deviceID, ip string) (*pubdef.SubmitVipOrderInfo, *srvdef.SrvErr) { + deviceType := devType + if deviceType == "" { + deviceType = sysType + } + response, err := (&payCenter.Recharge{ + Ctx: ctx, + TradeNo: tradeNo, + Money: payCenter.FenToYuan(amount), + Type: payMode, + Info: payCenter.PayInfo{ + PlayerId: strconv.FormatInt(userId, 10), + PlayerIp: ip, + DeviceType: deviceType, + DeviceId: strings.TrimSpace(deviceID), + Name: userName, + }, + }).ToPayNew() + if err != nil { + log.Error("新支付中心下单失败", log.Any("tradeNo", tradeNo), log.Any("userId", userId), log.E(err)) + return nil, srvdef.New(srvdef.Err, "充值失败", err.Error()) + } + return &pubdef.SubmitVipOrderInfo{ + TradeNo: tradeNo, + Oid: response.OID, + PayUrl: response.PayUrl, + PayUrlMode: response.Mode, + PayRate: response.Rebate, + }, nil +} diff --git a/app/service/rechargeser/recharge.go b/app/service/rechargeser/recharge.go new file mode 100644 index 0000000..50d9146 --- /dev/null +++ b/app/service/rechargeser/recharge.go @@ -0,0 +1,193 @@ +package rechargeser + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/useragent" + "videoweb/pub/dataReport" + "videoweb/pub/models/eventmod" + "videoweb/pub/models/rechargepackagemod" + "videoweb/pub/models/usermod" + "videoweb/pub/models/vipordermod" + "videoweb/pub/mongos" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// SubmitOrderResp 创建第三方支付订单的响应 +type SubmitOrderResp struct { + TradeNo string `json:"tradeNo"` // 本地订单号(可用于查询) + PayUrl string `json:"payUrl"` // 支付链接 + PayUrlMode string `json:"payUrlMode"` // 链接打开方式 + IsOpenNewBrowser bool `json:"isOpenNewBrowser"` // 是否需要外部浏览器打开 +} + +// CreateOrder 创建第三方支付 VIP 订单。 +// 防重复:15 秒内同一用户+同一套餐的 pending 订单直接返回原支付链接。 +func CreateOrder(ctx context.Context, + userId int64, userName string, + packageId int64, payMode string, + sysType string, devType string, deviceID string, ip string, + districtCode string, sourcePageKey string, sourcePageName string, +) (*SubmitOrderResp, *srvdef.SrvErr) { + + pkg, serr := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{ + rechargepackagemod.FN.RechargePackage.Id: packageId, + rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeVip, + rechargepackagemod.FN.RechargePackage.Status: rechargepackagemod.StatusEnabled, + }) + if serr != nil || pkg == nil { + return nil, srvdef.New(srvdef.ErrNoData, "会员套餐不存在或已下架", "") + } + + now := time.Now() + dupFilter := bson.M{ + vipordermod.FN.VipOrder.UserId: userId, + vipordermod.FN.VipOrder.CardId: packageId, + vipordermod.FN.VipOrder.PayMode: payMode, + vipordermod.FN.VipOrder.Status: pubdef.VipOrderPending, + vipordermod.FN.VipOrder.CreatedAt: bson.M{"$gte": now.Add(-15 * time.Second)}, + } + existing, _ := vipordermod.FindOneVipOrder(ctx, dupFilter) + if existing != nil && existing.PayUrl != "" { + return &SubmitOrderResp{ + TradeNo: existing.TradeNo, + PayUrl: existing.PayUrl, + PayUrlMode: existing.PayUrlMode, + IsOpenNewBrowser: true, + }, nil + } + + tradeNo := pubdef.GetTrandNoWithUUID() + amount := pkg.Price + + payInfo, serr := SubmitPayOrder(ctx, userId, userName, tradeNo, amount, payMode, sysType, devType, deviceID, ip) + if serr != nil { + return nil, serr + } + + order := &vipordermod.VipOrder{ + Id: mongos.Lid.VipOrder.Get(), + OrderType: vipordermod.OrderTypeVip, + UserId: userId, + CardId: packageId, // 兼容历史字段,存购买套餐ID + CardName: pkg.Name, + Amount: amount, + PayMode: payMode, + TradeNo: tradeNo, + Oid: payInfo.Oid, + Status: pubdef.VipOrderPending, + PayUrl: payInfo.PayUrl, + PayUrlMode: payInfo.PayUrlMode, + PayChannel: payInfo.PayChannel, + PayRate: payInfo.PayRate, + IPAddr: ip, + SysType: sysType, + DistrictCode: strings.TrimSpace(districtCode), + Client: vipordermod.NewOrderClient(useragent.FromContext(ctx), sourcePageKey, sourcePageName), + CreatedAt: now, + UpdatedAt: now, + } + if serr = vipordermod.InsertOneVipOrder(ctx, order); serr != nil { + log.Error("rechargeser:CreateOrder InsertOneVipOrder failed", + log.Any("tradeNo", tradeNo), log.E(serr)) + return nil, serr + } + + go reportOrderCreated(ctx, order, pkg, userId, ip) + + return &SubmitOrderResp{ + TradeNo: tradeNo, + PayUrl: payInfo.PayUrl, + PayUrlMode: payInfo.PayUrlMode, + IsOpenNewBrowser: true, + }, nil +} + +// GetOrderHistory 查询用户的 VIP 订单历史 +func GetOrderHistory(ctx context.Context, userId int64, page, pageSize int64) (vipordermod.VipOrderList, int64, *srvdef.SrvErr) { + filter := bson.M{ + vipordermod.FN.VipOrder.UserId: userId, + vipordermod.FN.VipOrder.OrderType: vipordermod.OrderTypeVip, + } + total, serr := vipordermod.CountVipOrder(ctx, filter) + if serr != nil { + return nil, 0, serr + } + + skip := (page - 1) * pageSize + opts := options.Find(). + SetSort(bson.M{vipordermod.FN.VipOrder.CreatedAt: -1}). + SetSkip(skip). + SetLimit(pageSize) + list, serr := vipordermod.FindManyVipOrder(ctx, filter, opts) + return list, total, serr +} + +// GetUserInfo 获取用户信息(供 app 层展示当前 VIP 状态) +func GetUserInfo(ctx context.Context, userId int64) (*usermod.User, *srvdef.SrvErr) { + return usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) +} + +func reportOrderCreated(ctx context.Context, order *vipordermod.VipOrder, pkg *rechargepackagemod.RechargePackage, userId int64, ip string) { + user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) + districtCode := "" + if user != nil { + districtCode = user.DistrictCode + } + + sourceKey, sourceName := "deposit", "金币充值" + if order.Client != nil { + if value := strings.TrimSpace(order.Client.SourcePageKey); value != "" { + sourceKey = value + } + if value := strings.TrimSpace(order.Client.SourcePageName); value != "" { + sourceName = value + } + } + ua := order.Client.UA(order.SysType) + eventId := newEventId(pubdef.TrackerAppId(), user.Id, order.TradeNo, order.CreatedAt.Unix()) + commonField := dataReport.NewCommonField(eventId, dataReport.EventTypeOrderCreated, districtCode, ua, strconv.FormatInt(user.Id, 10), ip) + + commonField.Payload = dataReport.JsonRawMessage(&dataReport.OrderCreatedEvent{ + OrderID: order.TradeNo, + OrderType: "vip_subscription", + ProductID: strconv.FormatInt(pkg.Id, 10), + ProductName: pkg.Name, + Amount: dataReport.JsonNumber(order.Amount), + Currency: "CNY", + SourcePageKey: sourceKey, + SourcePageName: sourceName, + CreateTime: dataReport.JsonNumber(order.CreatedAt.Unix()), + }) + + eventData, _ := json.Marshal(commonField) + if err := eventmod.InsertOneEvent(ctx, &eventmod.Event{ + EventId: commonField.EventID, + EventType: commonField.Event, + Data: string(eventData), + Status: eventmod.Default, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }); err != nil { + log.Error("reportOrderCreated InsertOneEvent failed", + log.Any("tradeNo", order.TradeNo), log.E(err)) + } +} + +func newEventId(appId string, uid int64, orderId interface{}, createTime int64) string { + input := fmt.Sprintf("%v%v%v%v", appId, uid, orderId, createTime) + h := md5.New() + h.Write([]byte(input)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/app/service/reportser/report.go b/app/service/reportser/report.go new file mode 100644 index 0000000..eadfa56 --- /dev/null +++ b/app/service/reportser/report.go @@ -0,0 +1,559 @@ +package reportser + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + "videoweb/app/service/playletcache" + "videoweb/pub/common" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/log" + "videoweb/pub/common/useragent" + "videoweb/pub/dataReport" + "videoweb/pub/models/eventmod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/usermod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + videoContentTypeVideo = "video" +) + +// ReportUserRegister 用户注册事件 +func ReportUserRegister(user *usermod.User, ua useragent.UA, ip, registerType string) { + if user == nil { + return + } + if registerType == "" { + registerType = "deviceid" + } + ua = fillUserUA(user, ua) + createAt := user.CreatedAt + if !user.RegisterTime.IsZero() { + createAt = user.RegisterTime + } + eventId := newEventId(pubdef.TrackerAppId(), user.Id, "", createAt.Unix()) + event := dataReport.NewCommonField(eventId, dataReport.EventTypeUserRegister, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip) + event.Payload = dataReport.JsonRawMessage(&dataReport.UserRegisterEvent{ + Type: registerType, + CreateTime: dataReport.JsonNumber(createAt.Unix()), + }) + insertEvent(event) +} + +// ReportUserLogin 用户登录事件 +func ReportUserLogin(user *usermod.User, ua useragent.UA, ip string, loginType pubdef.LoginType) { + if user == nil { + return + } + ua = fillUserUA(user, ua) + createTime := time.Now().Unix() + eventId := newEventId(pubdef.TrackerAppId(), user.Id, "", createTime) + event := dataReport.NewCommonField(eventId, dataReport.EventTypeUserLogin, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip) + event.Payload = dataReport.JsonRawMessage(&dataReport.UserLoginEvent{ + Type: loginTypeEventValue(loginType), + }) + insertEvent(event) +} + +// ReportPlayletLike 短剧点赞 +func ReportPlayletLike(ctx context.Context, userId, playletId, flag int64) { + reportDramaEvent(ctx, userId, playletId, 0, dataReport.EventTypeDramaLike, flag, "") +} + +// ReportVideoLike 视频点赞 +func ReportVideoLike(ctx context.Context, userId, videoId, flag int64) { + reportVideoEvent(ctx, userId, videoId, dataReport.EventTypeVideoLike, flag) +} + +// ReportPlayletCollect 短剧收藏 +func ReportPlayletCollect(ctx context.Context, userId, playletId, flag int64) { + reportDramaEvent(ctx, userId, playletId, 0, dataReport.EventTypeDramaCollect, flag, "") +} + +// ReportVideoCollect 视频收藏 +func ReportVideoCollect(ctx context.Context, userId, videoId, flag int64) { + reportVideoEvent(ctx, userId, videoId, dataReport.EventTypeVideoCollect, flag) +} + +// ReportVideoComment 视频评论 +func ReportVideoComment(ctx context.Context, user *usermod.User, videoId int64, content string) { + if user == nil || videoId <= 0 { + return + } + pub := buildVideoPub(ctx, videoId) + if pub.VideoID == "" { + return + } + ua, ip := uaAndIPFromCtx(ctx) + ua = fillUserUA(user, ua) + event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), dataReport.EventTypeVideoComment, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip) + event.Payload = dataReport.JsonRawMessage(&dataReport.VideoCommentEvent{ + VideoEventPub: pub, + CommentContent: content, + }) + insertEvent(event) +} + +// ReportPlayletChapterLike 短剧剧集点赞 +func ReportPlayletChapterLike(ctx context.Context, userId, playletId, chapterId, flag int64) { + reportDramaEvent(ctx, userId, playletId, chapterId, dataReport.EventTypeDramaLike, flag, "") +} + +// ReportPlayletChapterCollect 短剧剧集收藏 +func ReportPlayletChapterCollect(ctx context.Context, userId, playletId, chapterId, flag int64) { + reportDramaEvent(ctx, userId, playletId, chapterId, dataReport.EventTypeDramaCollect, flag, "") +} + +// ReportPlayletComment 短剧/剧集评论 +func ReportPlayletComment(ctx context.Context, user *usermod.User, playletId, chapterId int64, content string) { + if user == nil || playletId <= 0 { + return + } + pub := buildDramaPub(ctx, playletId, chapterId) + if pub.DramaID == "" { + return + } + ua, ip := uaAndIPFromCtx(ctx) + ua = fillUserUA(user, ua) + event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), dataReport.EventTypeDramaComment, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip) + event.Payload = dataReport.JsonRawMessage(&dataReport.DramaCommentEvent{ + DramaEventPub: pub, + CommentContent: content, + }) + insertEvent(event) +} + +// ReportPlayletUnlock 短剧剧集金币解锁:同时上报金币消耗和购买 +func ReportPlayletUnlock(ctx context.Context, user *usermod.User, playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter, amount, balanceBefore, balanceAfter int64, orderNo string) { + if user == nil || playlet == nil || chapter == nil || amount <= 0 { + return + } + if full, err := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: user.Id}); err == nil && full != nil { + user = full + } + ua, ip := uaAndIPFromCtx(ctx) + ua = fillUserUA(user, ua) + productId := strconv.FormatInt(chapter.Id, 10) + productName := playletUnlockProductName(playlet, chapter) + reportCoinConsume(user, ua, ip, productId, productName, amount, balanceBefore, balanceAfter, KeyPlayletUnlock, orderNo) + reportDramaPurchase(ctx, user, ua, ip, playlet, chapter, amount, orderNo) +} + +// ReportPlayletStatusChange 短剧上下架,复用 video_status_change +func ReportPlayletStatusChange(playlet *playletmod.Playlet, flag int64) { + if playlet == nil || playlet.Id == 0 || flag == 0 { + return + } + typeID, typeName := resolvePlayletType(playlet) + event := dataReport.NewCommonField("", dataReport.EventTypeVideoStatusChange, "", useragent.UA{}, "", "") + event.Payload = dataReport.JsonRawMessage(&dataReport.VideoStatusChangeEvent{ + MediaId: playletMediaId(playlet), + VideoID: strconv.FormatInt(playlet.Id, 10), + VideoTitle: playlet.Title, + VideoTypeID: typeID, + VideoTypeName: typeName, + Flag: dataReport.JsonNumber(flag), + }) + insertEvent(event) +} + +// PlayletStatusFlag 短剧上下架映射上报 flag:1上架 2下架 +func PlayletStatusFlag(status playletmod.Status) int64 { + switch status { + case playletmod.StatusUp: + return 1 + case playletmod.StatusDown: + return 2 + default: + return 0 + } +} + +func reportDramaEvent(ctx context.Context, userId, playletId, chapterId int64, eventType string, flag int64, content string) { + if userId <= 0 || playletId <= 0 { + return + } + user, err := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) + if err != nil || user == nil { + return + } + pub := buildDramaPub(ctx, playletId, chapterId) + if pub.DramaID == "" { + return + } + ua, ip := uaAndIPFromCtx(ctx) + ua = fillUserUA(user, ua) + event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), eventType, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip) + switch eventType { + case dataReport.EventTypeDramaLike: + event.Payload = dataReport.JsonRawMessage(&dataReport.DramaLikeEvent{ + DramaEventPub: pub, + Flag: dataReport.JsonNumber(flag), + }) + case dataReport.EventTypeDramaCollect: + event.Payload = dataReport.JsonRawMessage(&dataReport.DramaCollectEvent{ + DramaEventPub: pub, + Flag: dataReport.JsonNumber(flag), + }) + case dataReport.EventTypeDramaComment: + event.Payload = dataReport.JsonRawMessage(&dataReport.DramaCommentEvent{ + DramaEventPub: pub, + CommentContent: content, + }) + default: + return + } + insertEvent(event) +} + +func reportVideoEvent(ctx context.Context, userId, videoId int64, eventType string, flag int64) { + if userId <= 0 || videoId <= 0 { + return + } + user, err := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) + if err != nil || user == nil { + return + } + pub := buildVideoPub(ctx, videoId) + if pub.VideoID == "" { + return + } + ua, ip := uaAndIPFromCtx(ctx) + ua = fillUserUA(user, ua) + event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), eventType, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip) + switch eventType { + case dataReport.EventTypeVideoLike: + event.Payload = dataReport.JsonRawMessage(&dataReport.VideoLikeEvent{ + VideoEventPub: pub, + Flag: dataReport.JsonNumber(flag), + }) + case dataReport.EventTypeVideoCollect: + event.Payload = dataReport.JsonRawMessage(&dataReport.VideoCollectEvent{ + VideoEventPub: pub, + Flag: dataReport.JsonNumber(flag), + }) + default: + return + } + insertEvent(event) +} + +func reportCoinConsume(user *usermod.User, ua useragent.UA, ip, productId, productName string, amount, balanceBefore, balanceAfter int64, reason Key, orderId string) { + if user == nil || amount == 0 { + return + } + if productId == "" { + if productName == "" { + productName = "金币消耗" + } + productId = md5String(productName) + } + createTime := time.Now().Unix() + eventId := newEventId(pubdef.TrackerAppId(), user.Id, orderId, createTime) + event := dataReport.NewCommonField(eventId, dataReport.EventTypeCoinConsume, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip) + event.Payload = dataReport.JsonRawMessage(&dataReport.CoinConsumeEvent{ + ProductID: productId, + ProductName: productName, + CoinConsumeAmount: dataReport.JsonNumber(amount), + CoinBalanceBefore: dataReport.JsonNumber(balanceBefore), + CoinBalanceAfter: dataReport.JsonNumber(balanceAfter), + ConsumeReasonKey: reason.Key(), + ConsumeReasonName: reason.Name(), + CreateTime: dataReport.JsonNumber(createTime), + OrderId: orderId, + }) + insertEvent(event) +} + +func reportDramaPurchase(ctx context.Context, user *usermod.User, ua useragent.UA, ip string, playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter, amount int64, orderId string) { + pub := dataReport.DramaEventPub{} + if playlet != nil && chapter != nil && chapter.Id > 0 { + pub = buildDramaPub(ctx, playlet.Id, chapter.Id) + } + if pub.DramaID == "" { + pub = playletToDramaPub(playlet, chapter) + } + if pub.DramaID == "" { + return + } + event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), dataReport.EventTypeDramaPurchase, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip) + event.Payload = dataReport.JsonRawMessage(&dataReport.DramaPurchaseEvent{ + DramaEventPub: pub, + CoinQuantity: dataReport.JsonNumber(amount), + OrderID: orderId, + UnlockType: dataReport.DramaUnlockTypeCoin, + }) + insertEvent(event) +} + +func buildDramaPub(ctx context.Context, playletId, chapterId int64) dataReport.DramaEventPub { + 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.Title, Value: 1}, + {Key: playletmod.FN.Playlet.ThemeIds, Value: 1}, + {Key: playletmod.FN.Playlet.TagIds, Value: 1}, + {Key: playletmod.FN.Playlet.TotalEpisode, Value: 1}, + })) + if err != nil || playlet == nil { + return dataReport.DramaEventPub{} + } + chapter := findDramaReportChapter(ctx, playletId, chapterId) + return playletToDramaPub(playlet, chapter) +} + +// findDramaReportChapter 取上报用剧集。指定了剧集就用该集,短剧维度事件用第一集,保证 drama_id、video_id 都来自剧集。 +func findDramaReportChapter(ctx context.Context, playletId, chapterId int64) *playletchaptermod.PlayletChapter { + projection := options.FindOne().SetProjection(bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.MediaContentId, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1}, + }) + if chapterId > 0 { + chapter, _ := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: chapterId}, projection) + return chapter + } + chapter, _ := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.PlayletId: playletId}, projection.SetSort(bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + })) + return chapter +} + +func buildVideoPub(ctx context.Context, videoId int64) dataReport.VideoEventPub { + video, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: videoId}, options.FindOne().SetProjection(bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Id, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Title, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.MediaType, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.FileId, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.TaskId, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.SyncId, Value: 1}, + })) + if err != nil || video == nil { + return dataReport.VideoEventPub{} + } + return videoToVideoPub(video) +} + +func videoToVideoPub(video *videoinformationmod.VideoInformation) dataReport.VideoEventPub { + if video == nil { + return dataReport.VideoEventPub{} + } + mediaId := video.Video.FileId + if mediaId == "" { + mediaId = video.Video.TaskId + } + if mediaId == "" { + mediaId = video.SyncId + } + return dataReport.VideoEventPub{ + MediaId: mediaId, + VideoID: strconv.FormatInt(video.Id, 10), + VideoTitle: video.Title, + VideoTypeID: strconv.FormatInt(int64(video.MediaType), 10), + VideoTypeName: videoMediaTypeName(video.MediaType), + VideoContentType: videoContentTypeVideo, + } +} + +func playletToDramaPub(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) dataReport.DramaEventPub { + if playlet == nil { + return dataReport.DramaEventPub{} + } + typeID, typeName := resolvePlayletType(playlet) + tagKey, tagName := resolvePlayletTags(playlet.TagIds) + episode := int64(1) + duration := int64(0) + dramaID := "" + videoID := "" + if chapter != nil && chapter.Id > 0 { + if chapter.CurrentEpisode > 0 { + episode = chapter.CurrentEpisode + } + duration = chapter.MediaTime + 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, + DramaDuration: dataReport.JsonNumber(duration), + } +} + +func videoMediaTypeName(mediaType videoinformationmod.MediaType) string { + switch mediaType { + case videoinformationmod.MediaType_Video: + return "视频" + case videoinformationmod.MediaType_Collection: + return "合集" + case videoinformationmod.MediaType_Advitise: + return "广告" + case videoinformationmod.MediaType_VideoV2: + return "视频" + default: + return "视频" + } +} + +func playletUnlockProductName(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) string { + title := "" + if playlet != nil { + title = playlet.Title + } + if chapter == nil { + return title + } + if chapter.Title != "" { + if title == "" { + return chapter.Title + } + return fmt.Sprintf("%s %s", title, chapter.Title) + } + if chapter.CurrentEpisode > 0 { + if title == "" { + return fmt.Sprintf("第%d集", chapter.CurrentEpisode) + } + return fmt.Sprintf("%s 第%d集", title, chapter.CurrentEpisode) + } + return title +} + +func playletMediaId(playlet *playletmod.Playlet) string { + if playlet == nil || playlet.MediaCenterId == 0 { + return "" + } + return strconv.FormatUint(uint64(playlet.MediaCenterId), 10) +} + +func resolvePlayletType(playlet *playletmod.Playlet) (string, string) { + if playlet == nil || len(playlet.ThemeIds) == 0 { + return "default", "默认分类" + } + id := playlet.ThemeIds[0] + if id <= 0 { + return "default", "默认分类" + } + name := strings.TrimSpace(playletcache.ThemeNameMap([]int64{id})[id]) + if name == "" { + name = "默认分类" + } + return strconv.FormatInt(id, 10), name +} + +func resolvePlayletTags(ids []int64) (string, string) { + keys := make([]string, 0, len(ids)) + for _, id := range ids { + if id > 0 { + keys = append(keys, strconv.FormatInt(id, 10)) + } + } + if len(keys) == 0 { + return "default", "默认标签" + } + nameMap := playletcache.TagNameMap(ids) + names := make([]string, 0, len(keys)) + for _, id := range ids { + if id <= 0 { + continue + } + name := strings.TrimSpace(nameMap[id]) + if name == "" { + name = strconv.FormatInt(id, 10) + } + names = append(names, name) + } + return strings.Join(keys, ","), strings.Join(names, ",") +} + +func uaAndIPFromCtx(ctx context.Context) (useragent.UA, string) { + gc, ok := ctx.(*gin.Context) + if !ok { + if v := ctx.Value(consts.CtxUA); v != nil { + if ua, ok := v.(useragent.UA); ok { + return ua, "" + } + } + return useragent.UA{}, "" + } + ua, _ := common.GetUA(gc) + return ua, ipaddr.ResolveClientIP(gc) +} + +func fillUserUA(user *usermod.User, ua useragent.UA) useragent.UA { + if user == nil { + return ua + } + if ua.DevID == "" { + ua.DevID = user.DevID + } + if ua.DevType == "" { + ua.DevType = user.Device + } + if ua.SysType == "" { + ua.SysType = user.SysType + } + return ua +} + +func loginTypeEventValue(loginType pubdef.LoginType) string { + switch loginType { + case pubdef.LoginTypeAccount: + return "username" + case pubdef.LoginTypeEmail: + return "email" + default: + return "deviceid" + } +} + +func newEventId(appId string, uid int64, orderId interface{}, createTime int64) string { + input := fmt.Sprintf("%v%v%v%v", appId, uid, orderId, createTime) + return md5String(input) +} + +func md5String(input string) string { + h := md5.New() + h.Write([]byte(input)) + return hex.EncodeToString(h.Sum(nil)) +} + +func insertEvent(event dataReport.CommonField) { + 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("reportser insertEvent failed", log.Any("eventType", event.Event), log.Any("eventId", event.EventID), log.E(err)) + } +} diff --git a/app/service/reportser/type.go b/app/service/reportser/type.go new file mode 100644 index 0000000..a7ffb02 --- /dev/null +++ b/app/service/reportser/type.go @@ -0,0 +1,21 @@ +package reportser + +type Key string + +const ( + KeyPlayletUnlock Key = "playlet_unlock" // 短剧剧集解锁 + KeyCoinConsume Key = "coin_consume" // 金币消耗 +) + +var keyMap = map[Key]string{ + KeyPlayletUnlock: "短剧解锁", + KeyCoinConsume: "金币消耗", +} + +func (p Key) Name() string { + return keyMap[p] +} + +func (p Key) Key() string { + return string(p) +} diff --git a/app/service/searchser/cache.go b/app/service/searchser/cache.go new file mode 100644 index 0000000..06303b9 --- /dev/null +++ b/app/service/searchser/cache.go @@ -0,0 +1 @@ +package searchser diff --git a/app/service/searchser/search.go b/app/service/searchser/search.go new file mode 100644 index 0000000..f104313 --- /dev/null +++ b/app/service/searchser/search.go @@ -0,0 +1,61 @@ +package searchser + +import ( + "context" + "time" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/translationser" + "videoweb/pub/pubdef" + + "github.com/patrickmn/go-cache" +) + +var ( + Cache *cache.Cache +) + +func init() { + Cache = cache.New(5*time.Minute, 5*time.Minute) +} + +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), + PageNum: page, + PageSize: pubdef.PageSize, + } + resp.InTitle = title + resp.InType = mediaType + resp.Sort = getSearchSort() + resp.SearchRecommend = getSearchRecommend(title) + resp.Config = configser.GetConfigInfo() + resp.Translations = translationser.GetTranslationData() + resp.BottonTranslations = translationser.GetTranslationBottomList() + + resp.TotalSize = pubdef.GetTotalPage(resp.TotalSize, pubdef.PageSize) + return resp +} + +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/translationser/catch.go b/app/service/translationser/catch.go new file mode 100644 index 0000000..6024799 --- /dev/null +++ b/app/service/translationser/catch.go @@ -0,0 +1,173 @@ +package translationser + +import ( + "context" + "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/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 +) + +type CacheInfo struct { + Lock sync.RWMutex // 读写锁 + TranslationData proto.AppTranslationInfo // 导航信息缓存 + BottomTranslationData proto.AppTranslationInfo // 底部导航信息缓存 + TranslationMap map[int64]proto.TranslationInfo +} + +func init() { + skdCron = cron.New(cron.WithSeconds()) // 秒级定时任务 + CacheMgr.TranslationData = proto.AppTranslationInfo{} + CacheMgr.BottomTranslationData = proto.AppTranslationInfo{} + CacheMgr.TranslationMap = make(map[int64]proto.TranslationInfo) +} + +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(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() + + 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, + SeoInfo: categoryInfo.SeoInfo, + } + if categoryTemp.CategoryType == pubdef.TranslationTypeUrl { + categoryTemp.UrlValue = categoryInfo.UrlValue + } else if categoryInfo.CategoryType == pubdef.TranslationTypeSub { + for _, translationInfo := range translationList { + if categoryInfo.Id == translationInfo.TransCategoryId { + translationTmp := proto.TranslationInfo{ + ID: translationInfo.Id, + Name: translationInfo.Name, + } + if translationInfo.IsFriendAds { + translationTmp.IsFriendAds = true + // 友链广告 获取的时候添加 + // TODO:顶部暂不支持 。 + continue + } else { + translationTmp.UrlValue = translationInfo.Link + } + if translationTmp.UrlValue == "" { + continue + } + tempTranslationMap[translationInfo.Id] = translationTmp + categoryTemp.TranslationList = append(categoryTemp.TranslationList, translationTmp) + } + } + } else if categoryInfo.CategoryType == pubdef.TranslationTypeBottom { + for _, translationInfo := range translationList { + if categoryInfo.Id == translationInfo.TransCategoryId { + translationTmp := proto.TranslationInfo{ + ID: translationInfo.Id, + Name: translationInfo.Name, + } + if translationInfo.IsFriendAds { + translationTmp.IsFriendAds = true + // 友链广告 获取的时候添加 + } else { + 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("", log.Any("categoryInfo.Name", translation.CategoryList)) + CacheMgr.Lock.Lock() + defer CacheMgr.Lock.Unlock() + CacheMgr.TranslationData = translation + CacheMgr.BottomTranslationData = bottomTranslation + CacheMgr.TranslationMap = tempTranslationMap + +} +func GetTranslationData() proto.AppTranslationInfo { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + + log.Info("GetTranslationData", log.Any("TranslationData", CacheMgr.TranslationData)) + return CacheMgr.TranslationData +} +func GetTranslationBottomList() proto.AppTranslationInfo { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + return CacheMgr.BottomTranslationData +} + +func GetTranslationInfo(id int64) *proto.TranslationInfo { + CacheMgr.Lock.RLock() + defer CacheMgr.Lock.RUnlock() + if v, ok := CacheMgr.TranslationMap[id]; ok { + return &v + } + return nil +} diff --git a/app/service/translationser/translation.go b/app/service/translationser/translation.go new file mode 100644 index 0000000..6d21bde --- /dev/null +++ b/app/service/translationser/translation.go @@ -0,0 +1 @@ +package translationser diff --git a/app/service/uploadser/upload.go b/app/service/uploadser/upload.go new file mode 100644 index 0000000..2338a53 --- /dev/null +++ b/app/service/uploadser/upload.go @@ -0,0 +1,127 @@ +package uploadser + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "strconv" + "videoweb/app/service/configser" + + "videoweb/pub/common/log" + "videoweb/pub/pubdef" + "videoweb/pub/storage/proxy" +) + +const ( + // 图片上传路径 + imagePath = "/media/web/autopublish/image" +) + +// 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"` +} + +// ImageParam 图片上传入参 +type ImageParam struct { + AppId string + Watermark int64 + FileHeader *multipart.FileHeader +} + +// targetDomain 获取转发目标域名 +func targetDomain() string { + return proxy.DomainAddressRepair("", proxy.BucketVideoAddressV2) +} + +// 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.Watermark == 1 { + watermarkImage := configser.GetConfigInfo().WatermarkImage + if err := mw.WriteField("watermarkImage", watermarkImage); 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/usercenterser/user_center.go b/app/service/usercenterser/user_center.go new file mode 100644 index 0000000..5313adf --- /dev/null +++ b/app/service/usercenterser/user_center.go @@ -0,0 +1,61 @@ +package usercenterser + +import ( + "context" + "videoweb/app/api/proto" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletchapterunlockmod" + "videoweb/pub/models/playletcollectmod" + "videoweb/pub/models/playletwatchhistorymod" + "videoweb/pub/models/playletwatchprogressmod" + + "go.mongodb.org/mongo-driver/bson" +) + +// Data 查询app个人中心数据 +func Data(ctx context.Context, userId int64) (*proto.UserCenterDataResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + + watchCount, err := playletwatchhistorymod.CountPlayletWatchHistory(ctx, bson.M{ + playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId, + }) + if err != nil { + log.Error("统计个人中心观看数量失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + + collectCount, err := playletcollectmod.CountPlayletCollect(ctx, bson.M{ + playletcollectmod.FN.PlayletCollect.UserId: userId, + }) + if err != nil { + log.Error("统计个人中心收藏数量失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + + chasingCount, err := playletwatchprogressmod.CountPlayletWatchProgress(ctx, bson.M{ + playletwatchprogressmod.FN.PlayletWatchProgress.UserId: userId, + playletwatchprogressmod.FN.PlayletWatchProgress.IsChasing: true, + }) + if err != nil { + log.Error("统计个人中心追剧中数量失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + + boughtChapterCount, err := playletchapterunlockmod.CountPlayletChapterUnlock(ctx, bson.M{ + playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId, + }) + if err != nil { + log.Error("统计个人中心已购剧集数量失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + + return &proto.UserCenterDataResp{ + WatchCount: watchCount, + CollectCount: collectCount, + ChasingCount: chasingCount, + BoughtChapterCount: boughtChapterCount, + }, nil +} diff --git a/app/service/videoser/h265.go b/app/service/videoser/h265.go new file mode 100644 index 0000000..89f119d --- /dev/null +++ b/app/service/videoser/h265.go @@ -0,0 +1,60 @@ +package videoser + +import ( + "strings" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/pub/models/videoinformationmod" +) + +func toVideoInfoData(videoInfo *videoinformationmod.VideoInformation, imgCdn string) proto.VideoInfoData { + data := proto.VideoInfoToData(videoInfo, imgCdn) + normalizeVideoInfoH265Paths(&data) + configser.ApplyVideoH265Policy(&data) + return data +} + +func applyVideoUrlAuth(resp *proto.VideoInfoData, videoId int64, ip, ua string, userId int64) { + normalizeVideoInfoH265Paths(resp) + configser.ApplyVideoH265Policy(resp) + + if len(resp.Video.Url) > 0 { + resp.Video.Url = GetM3U8AuthParam(videoId, resp.Video.Url, ip, ua, userId) + } + if len(resp.Video.H265Url) > 0 { + resp.Video.H265Url = GetM3U8AuthParam(videoId, resp.Video.H265Url, ip, ua, userId) + } + for i, node := range resp.Nodes { + for j, video := range node.Videos { + if len(video.Url) > 0 { + video.Url = GetM3U8AuthParam(videoId, video.Url, ip, ua, userId) + } + if len(video.H265Url) > 0 { + video.H265Url = GetM3U8AuthParam(videoId, video.H265Url, ip, ua, userId) + } + node.Videos[j] = video + } + resp.Nodes[i] = node + } +} + +func normalizeVideoInfoH265Paths(data *proto.VideoInfoData) { + if data == nil { + return + } + data.Video.H265Url = normalizeLaosijiHEVCPath(data.Video.H265Url) + for i := range data.Nodes { + for j := range data.Nodes[i].Videos { + data.Nodes[i].Videos[j].H265Url = normalizeLaosijiHEVCPath(data.Nodes[i].Videos[j].H265Url) + } + } +} + +func normalizeLaosijiHEVCPath(path string) string { + path = strings.TrimSpace(path) + path = strings.TrimPrefix(path, "laosiji/m3m/") + if path != "" && !strings.HasPrefix(path, "laosiji/") { + return "laosiji/" + path + } + return path +} diff --git a/app/service/videoser/h5_m3u8_ticket.go b/app/service/videoser/h5_m3u8_ticket.go new file mode 100644 index 0000000..0cf9cc1 --- /dev/null +++ b/app/service/videoser/h5_m3u8_ticket.go @@ -0,0 +1,255 @@ +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/enum/redisconst" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/log" + "videoweb/pub/models/usermod" + "videoweb/pub/redis" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + h5M3U8TicketTTLSeconds = int64(600) + h5M3U8TicketKeyVersion = "v1" + h5M3U8TicketBindIP = true + h5M3U8RoutePrefix = "/h5/m3u8/" + hardcodedH5M3U8TicketKey = "3d058714838683a97c04d7b6ab609fc196f29e55df36197341d87325dc63154a" + H5M3U8FallbackPath = "/v3/av/w0/w6/db/7j/029e2f2df53d4741ad39b471e2ad1806.m3u8" +) + +// H5M3U8Ticket 记录业务接口已确认的正片/预览权限,供 m3u8 接口可信计数。 +type H5M3U8Ticket struct { + MediaID int64 `json:"mediaId"` + Path string `json:"path"` + ExpireAt int64 `json:"expireAt"` + IPHash string `json:"ipHash,omitempty"` + UserId int64 `json:"userId,omitempty"` // 签发时登录用户,供会员日限识别 + VipExpireAt int64 `json:"vipExpireAt,omitempty"` // 签发时会员过期时间(unix),播放侧据此判断会员日限,避免查库 + Playable bool `json:"playable"` + Preview bool `json:"preview"` + UseNewKey bool `json:"useNewKey,omitempty"` + KeyVersion string `json:"keyVersion,omitempty"` +} + +func h5M3U8TicketKey() string { + return hardcodedH5M3U8TicketKey +} + +// BuildH5M3U8LegacyURL 返回不带路由前缀的相对票据地址,兼容 H5 自行拼接 /h5/m3u8/。 +func BuildH5M3U8LegacyURL(mediaID int64, path, ip string, playable, preview bool, userId int64) string { + return buildH5M3U8LegacyURL(mediaID, path, ip, playable, preview, false, userId) +} + +// BuildH5M3U8PlayletURL 为短剧播放地址生成票据,并在票据中记录原片密钥类型。 +func BuildH5M3U8PlayletURL(mediaID int64, path, ip string, useNewKey bool, userId int64) string { + return buildH5M3U8LegacyURL(mediaID, path, ip, true, false, useNewKey, userId) +} + +func buildH5M3U8LegacyURL(mediaID int64, path, ip string, playable, preview, useNewKey bool, userId 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, + UserId: userId, + VipExpireAt: lookupUserVipExpireUnix(userId), + Playable: playable, + Preview: preview, + UseNewKey: useNewKey, + KeyVersion: h5M3U8TicketKeyVersion, + } + 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, ok := decodeAndValidateH5M3U8Ticket(mediaID, expireAt, ip, ticket) + if !ok || data == nil { + return nil, false + } + normalizedPath, pathOK := normalizeH5M3U8Path(path) + if !pathOK { + return nil, false + } + if payloadPath, valid := normalizeH5M3U8Path(data.Path); !valid || payloadPath != normalizedPath { + return nil, false + } + return data, true +} + +// CheckH5M3U8TicketForEnkey 校验 /enkey 请求,不校验 m3u8 路径。 +func CheckH5M3U8TicketForEnkey(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, bool) { + return decodeAndValidateH5M3U8Ticket(mediaID, expireAt, ip, ticket) +} + +func decodeAndValidateH5M3U8Ticket(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, bool) { + key := h5M3U8TicketKey() + if key == "" || mediaID <= 0 || expireAt <= 0 || strings.TrimSpace(ticket) == "" { + return nil, false + } + data, err := decodeH5M3U8Ticket(ticket, key) + if err != nil { + return nil, false + } + if data.MediaID != mediaID || data.ExpireAt != expireAt { + return nil, false + } + if data.KeyVersion != "" && data.KeyVersion != h5M3U8TicketKeyVersion { + return nil, false + } + if time.Now().Unix() > data.ExpireAt { + return nil, false + } + if h5M3U8TicketBindIP && data.IPHash != h5M3U8Hash(ip) { + return nil, false + } + return data, true +} + +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[:]) +} + +// lookupUserVipExpireUnix 取用户会员过期 unix 时间;优先 Redis 短缓存,未登录返回 0。 +// 仅在签发票据时调用,避免 /h5/m3u8 热路径查库。 +func lookupUserVipExpireUnix(userId int64) int64 { + if userId <= 0 { + return 0 + } + cacheKey := redisconst.M3U8UserVipExpireKey(userId) + if cached, err := redis.Get(cacheKey); err == nil && cached != nil { + if n, parseErr := strconv.ParseInt(*cached, 10, 64); parseErr == nil { + return n + } + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + user, serr := usermod.FindOneUser(ctx, bson.M{ + usermod.FN.User.Id: userId, + }, options.FindOne().SetProjection(bson.D{ + {Key: usermod.FN.User.Id, Value: 1}, + {Key: usermod.FN.User.CurrentVipExpire, Value: 1}, + })) + vipExpireAt := int64(0) + if serr == nil && user != nil && !user.CurrentVipExpire.IsZero() { + vipExpireAt = user.CurrentVipExpire.Unix() + } + _ = redis.Set(cacheKey, strconv.FormatInt(vipExpireAt, 10), redisconst.M3U8UserVipExpireCache) + return vipExpireAt +} diff --git a/app/service/videoser/m3u8.go b/app/service/videoser/m3u8.go new file mode 100644 index 0000000..8f9ec26 --- /dev/null +++ b/app/service/videoser/m3u8.go @@ -0,0 +1,349 @@ +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/redis" + + "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) { + handleTicketEnkey(ctx, false) +} + +// NewEnkey 返回短剧未完成水印时使用的新 M3U8 密钥。 +func NewEnkey(ctx *gin.Context) { + handleTicketEnkey(ctx, true) +} + +func handleTicketEnkey(ctx *gin.Context, useNewKey bool) { + 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 != useNewKey { + log.Warn("Enkey: ticket invalid", log.Any("ip", ip), log.Any("videoId", videoId)) + err = errors.New("ticket check failed") + return + } + + var buffer *bytes.Buffer + if useNewKey { + buffer, err = m3u8.NewEnkey(c) + } else { + 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) + isOverseas = configser.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 := CheckH5M3U8Ticket( + mediaID, expireAt, origM3u8Name, ip, ticket, + ) + + if !ticketOK { + m3u8Name = H5M3U8FallbackPath + log.Warn("【GetM3U8Ex】H5播放票据无效", + log.Any("mediaId", mediaID), + log.Any("expireAt", expireAt), + log.Any("ip", ip), + log.Any("hasTicket", ticket != "")) + } + + countTowardDailyLimit := ticketOK && ticketInfo.Playable && !ticketInfo.Preview + if countTowardDailyLimit && appg.Conf.RunMod == string(consts.RunModeProd) { + userId := resolveM3U8RateLimitUserId(c, ticketInfo) + vipValid := isM3U8TicketVipValid(ticketInfo) + limit := redisconst.M3U8DayRateLimitMax + var cnt int64 + var countErr error + if vipValid { + limit = redisconst.M3U8DayRateLimitVipMax + cnt, countErr = m3u8DailyMediaCountByUser(userId, mediaID) + } else { + cnt, countErr = m3u8DailyMediaCountByIP(ip, mediaID) + } + if countErr != nil { + log.Error("【GetM3U8Ex】统计正片获取次数失败", + log.Any("ip", ip), + log.Any("userId", userId), + log.Any("vipValid", vipValid), + log.E(countErr)) + } else if cnt > limit { + m3u8Name = m3u8RateLimitFallback + status = "rate_limited" + log.Warn("GetM3U8Ex daily rate limit, use fallback m3u8", + log.Any("ip", ip), + log.Any("userId", userId), + log.Any("vipValid", vipValid), + log.Any("limit", limit), + 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 := "" + if ticketOK { + enkeyPath := "/enkey" + if ticketInfo.UseNewKey { + enkeyPath = "/newenkey" + } + enkeyUri = fmt.Sprintf("%s?id=%d&t=%d&ticket=%s", enkeyPath, mediaID, expireAt, url.QueryEscape(ticket)) + } + + var buff *bytes.Buffer + if ticketOK && ticketInfo.UseNewKey { + _, 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 resolveM3U8RateLimitUserId(c *gin.Context, ticketInfo *H5M3U8Ticket) int64 { + if ticketInfo != nil && ticketInfo.UserId > 0 { + return ticketInfo.UserId + } + if c == nil { + return 0 + } + return common.GetUserId(c) +} + +// isM3U8TicketVipValid 用票据内签发时写入的会员过期时间判断,播放热路径不查库。 +func isM3U8TicketVipValid(ticketInfo *H5M3U8Ticket) bool { + return ticketInfo != nil && ticketInfo.UserId > 0 && ticketInfo.VipExpireAt > time.Now().Unix() +} + +func m3u8DailyMediaCountByIP(ip string, mediaID int64) (int64, error) { + return m3u8DailyPlayableCount( + redisconst.M3U8DayRateLimitIpKey(ip), + redisconst.M3U8DailyMediaIpKey(ip, mediaID), + ) +} + +func m3u8DailyMediaCountByUser(userId int64, mediaID int64) (int64, error) { + return m3u8DailyPlayableCount( + redisconst.M3U8DayRateLimitUserKey(userId), + redisconst.M3U8DailyMediaUserKey(userId, mediaID), + ) +} + +func m3u8DailyPlayableCount(countKey, firstMediaKey string) (int64, error) { + expire := redisconst.M3U8DayRateLimitExpire() + first, err := redis.Setnx_NewOK(firstMediaKey, 1, expire) + if err != nil { + return 0, err + } + 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, userId int64) string { + _ = ua + if signed := BuildH5M3U8LegacyURL(videoId, urlPath, ip, true, false, userId); signed != "" { + return signed + } + return urlPath +} + +// GetPlayletM3U8AuthParam 为短剧剧集地址增加票据,并指定是否使用原片新密钥。 +func GetPlayletM3U8AuthParam(chapterID int64, urlPath, ip string, useNewKey bool, userId int64) string { + if signed := BuildH5M3U8PlayletURL(chapterID, urlPath, ip, useNewKey, userId); signed != "" { + return signed + } + return urlPath +} + +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 serr := m3u8requestlogmod.InsertOneM3U8RequestLog(c, data); serr != 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/slug.go b/app/service/videoser/slug.go new file mode 100644 index 0000000..643e674 --- /dev/null +++ b/app/service/videoser/slug.go @@ -0,0 +1,45 @@ +package videoser + +import ( + "context" + "strings" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/models/videotopicmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func resolveVideoID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) { + slug = strings.TrimSpace(slug) + if slug != "" { + item, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Slug: slug, + }, options.FindOne().SetProjection(bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Id, Value: 1}, + })) + if err != nil { + return 0, err + } + return item.Id, nil + } + if required && id <= 0 { + return 0, srvdef.New(srvdef.ErrParamError, "视频ID不能为空", "视频ID不能为空") + } + return id, nil +} + +func resolveVideoTopicID(ctx context.Context, id int64, slug string) (int64, *srvdef.SrvErr) { + slug = strings.TrimSpace(slug) + if slug == "" { + return id, nil + } + item, err := videotopicmod.FindOneVideoTopicModel(ctx, bson.M{ + videotopicmod.FN.VideoTopicModel.Slug: slug, + }) + if err != nil { + return 0, err + } + return item.Id, nil +} diff --git a/app/service/videoser/video.go b/app/service/videoser/video.go new file mode 100644 index 0000000..3643cef --- /dev/null +++ b/app/service/videoser/video.go @@ -0,0 +1,1399 @@ +package videoser + +import ( + "context" + "regexp" + "strings" + "videoweb/app/api/proto" + "videoweb/app/service/configser" + "videoweb/app/service/reportser" + "videoweb/pub/common/log" + "videoweb/pub/common/netutil" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/richtextcontentmod" + "videoweb/pub/models/videocollectmod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/models/videolikemod" + "videoweb/pub/models/videotagmod" + "videoweb/pub/models/videotopicmod" + "videoweb/pub/models/videowatchhistorymod" + "videoweb/pub/pubdef" + "videoweb/pub/seo/h5path" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + videoListDefaultPageSize int64 = 10 + videoMaxPageSize int64 = 100 + videoAllTypeAll int64 = 0 + videoAllTypeCollection int64 = 1 + videoAllTypeAnime int64 = 2 + videoAllSortLatest int64 = 1 + videoAllSortHot int64 = 2 + videoAllSortCollect int64 = 3 + videoOperateStatusYes int64 = 1 + videoOperateStatusNo int64 = 2 + // videoH5DetailPath 视频播放页路径,供SEO canonical使用 + videoH5DetailPath = "/series-play" +) + +var ( + // videoListProjection 只读取列表需要展示的字段,降低MongoDB IO和网络传输 + videoListProjection = 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.AddedTime, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.CreatorId, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.CreatorName, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.CoverImg, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Keyworld, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.VideoTime, Value: 1}, + } + + videoAllListProjection = 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.MediaType, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.CreatorId, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.CreatorName, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.CoverImg, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Keyworld, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.TopicIds, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.VideoTime, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.VideoCount, Value: 1}, + } + + // videoListSort 按上架时间和ID倒序,保证普通分页排序稳定 + videoListSort = bson.D{ + {Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + } + + videoListHint = bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Status, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + } + + // videoInfoProjection 只读取视频详情接口需要展示和签名的字段 + videoInfoProjection = 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.Keyworld, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Desc, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.TopicIds, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.Likes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.Collects, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.Comments, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.TaskId, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.FileId, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.Url, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.H265Url, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.Cover, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.Height, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.Width, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.VideoTime, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.PreMp4FileNameFs, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.UnencryptedPreMp4, Value: 1}, + } + + videoOperateProjection = bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Id, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.Likes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.Collects, Value: 1}, + } + + videoUserListProjection = 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.Keyworld, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.Likes, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.Collects, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Real.Comments, Value: 1}, + {Key: videoinformationmod.FN.VideoInformation.Video.VideoTime, Value: 1}, + } +) + +type CategoryDataStruct struct { + VideoInfoData []proto.VideoInfoData + CategoryData proto.VideoCategoryInfo + VideoCount int64 +} + +type PublishDataStruct struct { + VideoInfoData []proto.VideoInfoData + CreaterData proto.VideoCreaterInfo + VideoCount int64 +} + +// VideoAllList 分页查询app端视频列表 +func VideoAllList(ctx context.Context, req *proto.VideoAllListReq) (*proto.VideoAllListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizeVideoPage(req.PageNum, req.PageSize, videoListDefaultPageSize) + filter, err := videoAllListFilter(ctx, req) + if err != nil { + return nil, err + } + totalCount, err := videoinformationmod.CountVideoInformations(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.VideoAllListResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.VideoAllListItemResp, 0), + Advitise: configser.GetAdvitise(), + } + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + list, err := videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find(). + SetProjection(videoAllListProjection). + SetSort(videoAllListSort(req.SortType)). + SetSkip(skip). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + topicMap, err := buildVideoTopicMap(ctx, list) + if err != nil { + return nil, err + } + imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + for _, item := range list { + resp.List = append(resp.List, videoToAllListResp(item, imgCdn, topicMap)) + } + return resp, nil +} + +// VideoList 按上架时间倒序普通分页查询已上架视频 +func VideoList(ctx context.Context, req *proto.VideoReq) (*proto.VideoResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizeVideoPage(req.PageNum, req.PageSize, videoListDefaultPageSize) + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK, + } + totalCount, err := videoinformationmod.CountVideoInformations(ctx, filter, options.Count().SetHint(videoListHint)) + if err != nil { + return nil, err + } + if totalCount == 0 { + return emptyVideoPage(pageNum, pageSize), nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + resp := emptyVideoPage(pageNum, pageSize) + resp.TotalCount = totalCount + return resp, nil + } + list, err := videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find(). + SetProjection(videoListProjection). + SetSort(videoListSort). + SetHint(videoListHint). + SetSkip(skip). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + items := make([]*proto.VideoItemResp, 0, len(list)) + imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + for _, item := range list { + items = append(items, videoToListResp(item, imgCdn)) + } + return &proto.VideoResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: items, + }, nil +} + +// SetLike 设置当前用户视频点赞状态 +func SetLike(ctx context.Context, userId int64, req *proto.VideoLikeSetReq) (*proto.VideoLikeSetResp, *srvdef.SrvErr) { + // 使用指针布尔值校验是否传参,保证 false 能表示取消点赞 + if req == nil || req.Id <= 0 || req.IsLike == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") + } + video, changed, err := setVideoLike(ctx, userId, req.Id, *req.IsLike) + if err != nil { + return nil, err + } + countLike := applyVideoLocalCounter(video.Real.Likes, *req.IsLike, changed) + resp := &proto.VideoLikeSetResp{ + Id: req.Id, + IsLike: videoOperateStatusByFlag(*req.IsLike), + CountLike: countLike, + } + if changed { + reportser.ReportVideoLike(ctx, userId, req.Id, resp.IsLike) + } + return resp, nil +} + +// SetCollect 设置当前用户视频收藏状态 +func SetCollect(ctx context.Context, userId int64, req *proto.VideoCollectSetReq) (*proto.VideoCollectSetResp, *srvdef.SrvErr) { + // 使用指针布尔值校验是否传参,保证 false 能表示取消收藏 + if req == nil || req.Id <= 0 || req.IsCollect == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "参数错误") + } + video, changed, err := setVideoCollect(ctx, userId, req.Id, *req.IsCollect) + if err != nil { + return nil, err + } + countCollect := applyVideoLocalCounter(video.Real.Collects, *req.IsCollect, changed) + resp := &proto.VideoCollectSetResp{ + Id: req.Id, + IsCollect: videoOperateStatusByFlag(*req.IsCollect), + CountCollect: countCollect, + } + if changed { + reportser.ReportVideoCollect(ctx, userId, req.Id, resp.IsCollect) + } + return resp, nil +} + +// VideoInfo 查询视频详情 +func VideoInfo(ctx context.Context, req *proto.VideoInfoReq, userId int64, ip, ua string) (*proto.VideoInfoResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + videoID, err := resolveVideoID(ctx, req.Id, req.Slug, true) + if err != nil { + return nil, err + } + req.Id = videoID + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Id: req.Id, + videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK, + } + item, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, filter, options.FindOne(). + SetProjection(videoInfoProjection)) + if err != nil { + return nil, err + } + go IncWatchTimes(context.Background(), item.Id) + imgCdn := configser.GetAppImageCdn(ip) + resp := videoToInfoResp(item, imgCdn, ip, ua, true, userId) + topics, topicErr := queryVideoTopicsByIds(ctx, item.TopicIds) + if topicErr != nil { + return nil, topicErr + } + resp.Topics = topics + isLike, isCollect, statusErr := findVideoUserOperateStatus(ctx, userId, item.Id) + if statusErr != nil { + return nil, statusErr + } + resp.IsLike = isLike + resp.IsCollect = isCollect + resp.Advitise = configser.GetAdvitise() + resp.SeoInfo = buildVideoDetailSeoInfo(item) + recordVideoWatchHistory(ctx, userId, item.Id) + return resp, nil +} + +// HistoryList 分页查询当前用户的视频观看历史 +func HistoryList(ctx context.Context, userId int64, req *proto.VideoHistoryListReq) (*proto.VideoHistoryListResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizeVideoPage(req.PageNum, req.PageSize, videoListDefaultPageSize) + filter := bson.M{ + videowatchhistorymod.FN.VideoWatchHistory.UserId: userId, + } + totalCount, err := videowatchhistorymod.CountVideoWatchHistory(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.VideoHistoryListResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.VideoHistoryData, 0), + } + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + historyList, err := videowatchhistorymod.FindManyVideoWatchHistory(ctx, filter, options.Find(). + SetProjection(bson.D{ + {Key: videowatchhistorymod.FN.VideoWatchHistory.Id, Value: 1}, + {Key: videowatchhistorymod.FN.VideoWatchHistory.VideoId, Value: 1}, + {Key: videowatchhistorymod.FN.VideoWatchHistory.UpdatedAt, Value: 1}, + }). + SetSort(bson.D{ + {Key: videowatchhistorymod.FN.VideoWatchHistory.UpdatedAt, Value: -1}, + {Key: videowatchhistorymod.FN.VideoWatchHistory.Id, Value: -1}, + }). + SetSkip(skip). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + videoIds := collectVideoIdsFromHistories(historyList) + videoMap, err := findUserVideoMap(ctx, videoIds) + if err != nil { + return nil, err + } + collectMap, err := findUserCollectedVideoMap(ctx, userId, videoIds) + if err != nil { + return nil, err + } + resp.List = buildVideoHistoryData(ctx, historyList, videoMap, collectMap) + return resp, nil +} + +// DeleteHistory 删除当前用户指定视频观看历史 +func DeleteHistory(ctx context.Context, userId int64, req *proto.VideoHistoryDeleteReq) (*proto.VideoHistoryDeleteResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil || req.VideoId <= 0 { + return nil, srvdef.New(srvdef.ErrParamError, "视频ID不能为空", "视频ID不能为空") + } + deleted, err := videowatchhistorymod.DeleteManyVideoWatchHistory(ctx, bson.M{ + videowatchhistorymod.FN.VideoWatchHistory.UserId: userId, + videowatchhistorymod.FN.VideoWatchHistory.VideoId: req.VideoId, + }) + if err != nil { + log.Error("删除单条视频观看历史失败", log.Any("用户ID", userId), log.Any("视频ID", req.VideoId), log.E(err)) + return nil, err + } + return &proto.VideoHistoryDeleteResp{DeletedCount: deleted}, nil +} + +// ClearHistory 清空当前用户的视频观看历史 +func ClearHistory(ctx context.Context, userId int64) (*proto.VideoHistoryClearResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + deleted, err := videowatchhistorymod.DeleteManyVideoWatchHistory(ctx, bson.M{ + videowatchhistorymod.FN.VideoWatchHistory.UserId: userId, + }) + if err != nil { + log.Error("清空视频观看历史失败", log.Any("用户ID", userId), log.E(err)) + return nil, err + } + return &proto.VideoHistoryClearResp{DeletedCount: deleted}, nil +} + +// CollectList 分页查询当前用户的视频收藏列表 +func CollectList(ctx context.Context, userId int64, req *proto.VideoCollectListReq) (*proto.VideoCollectListResp, *srvdef.SrvErr) { + if userId <= 0 { + return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizeVideoPage(req.PageNum, req.PageSize, videoListDefaultPageSize) + filter := bson.M{ + videocollectmod.FN.VideoCollect.UserId: userId, + } + totalCount, err := videocollectmod.CountVideoCollect(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.VideoCollectListResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]proto.VideoCollectData, 0), + } + if totalCount == 0 { + return resp, nil + } + skip := (pageNum - 1) * pageSize + if skip >= totalCount { + return resp, nil + } + collectList, err := videocollectmod.FindManyVideoCollect(ctx, filter, options.Find(). + SetProjection(bson.D{ + {Key: videocollectmod.FN.VideoCollect.Id, Value: 1}, + {Key: videocollectmod.FN.VideoCollect.VideoId, Value: 1}, + {Key: videocollectmod.FN.VideoCollect.CreatedAt, Value: 1}, + }). + SetSort(bson.D{ + {Key: videocollectmod.FN.VideoCollect.CreatedAt, Value: -1}, + {Key: videocollectmod.FN.VideoCollect.Id, Value: -1}, + }). + SetSkip(skip). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + videoIds := collectVideoIdsFromCollects(collectList) + videoMap, err := findUserVideoMap(ctx, videoIds) + if err != nil { + return nil, err + } + resp.List = buildVideoCollectData(ctx, collectList, videoMap) + return resp, nil +} + +// setVideoLike 设置视频点赞关系 +func setVideoLike(ctx context.Context, userId int64, videoId int64, enabled bool) (*videoinformationmod.VideoInformation, bool, *srvdef.SrvErr) { + // 操作接口必须登录,先查询视频确保目标存在且已上架,再按点赞表真实变更决定是否更新点赞数 + if userId <= 0 { + return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + video, err := findVideoOperateTarget(ctx, userId, videoId) + if err != nil { + return nil, false, err + } + filter := bson.M{ + videolikemod.FN.VideoLike.UserId: userId, + videolikemod.FN.VideoLike.VideoId: videoId, + } + changed := false + if enabled { + inserted, err := videolikemod.InsertOneVideoLike(ctx, &videolikemod.VideoLike{ + UserId: userId, + VideoId: videoId, + }) + if err != nil { + log.Error("新增视频点赞关系失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + return nil, false, err + } + changed = inserted + } else { + deleted, err := videolikemod.DeleteOneVideoLike(ctx, filter) + if err != nil { + log.Error("删除视频点赞关系失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + return nil, false, err + } + changed = deleted > 0 + } + if changed { + delta := int64(1) + if !enabled { + delta = -1 + } + if err := incrementVideoOperateCounter(ctx, videoId, videoinformationmod.FN.VideoInformation.Real.Likes, delta); err != nil { + rollbackVideoLike(ctx, userId, videoId, enabled) + return nil, false, err + } + } + return video, changed, nil +} + +// setVideoCollect 设置视频收藏关系 +func setVideoCollect(ctx context.Context, userId int64, videoId int64, enabled bool) (*videoinformationmod.VideoInformation, bool, *srvdef.SrvErr) { + // 操作接口必须登录,先查询视频确保目标存在且已上架,再按收藏表真实变更决定是否更新收藏数 + if userId <= 0 { + return nil, false, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录") + } + video, err := findVideoOperateTarget(ctx, userId, videoId) + if err != nil { + return nil, false, err + } + filter := bson.M{ + videocollectmod.FN.VideoCollect.UserId: userId, + videocollectmod.FN.VideoCollect.VideoId: videoId, + } + changed := false + if enabled { + inserted, err := videocollectmod.InsertOneVideoCollect(ctx, &videocollectmod.VideoCollect{ + UserId: userId, + VideoId: videoId, + }) + if err != nil { + log.Error("新增视频收藏关系失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + return nil, false, err + } + changed = inserted + } else { + deleted, err := videocollectmod.DeleteOneVideoCollect(ctx, filter) + if err != nil { + log.Error("删除视频收藏关系失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + return nil, false, err + } + changed = deleted > 0 + } + if changed { + delta := int64(1) + if !enabled { + delta = -1 + } + if err := incrementVideoOperateCounter(ctx, videoId, videoinformationmod.FN.VideoInformation.Real.Collects, delta); err != nil { + rollbackVideoCollect(ctx, userId, videoId, enabled) + return nil, false, err + } + } + return video, changed, nil +} + +// findVideoOperateTarget 查询视频操作目标 +func findVideoOperateTarget(ctx context.Context, userId int64, videoId int64) (*videoinformationmod.VideoInformation, *srvdef.SrvErr) { + video, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: videoId, + videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK, + }, options.FindOne().SetProjection(videoOperateProjection)) + if err != nil { + log.Error("查询视频操作目标失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + return nil, err + } + return video, nil +} + +// incrementVideoOperateCounter 增减视频点赞或收藏计数 +func incrementVideoOperateCounter(ctx context.Context, videoId int64, countField string, delta int64) *srvdef.SrvErr { + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Id: videoId, + videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK, + } + if delta < 0 { + filter[countField] = bson.M{"$gt": 0} + } + err := videoinformationmod.UpdateOneVideoInformation(ctx, filter, bson.M{ + "$inc": bson.M{countField: delta}, + }) + if err != nil { + if delta < 0 && err.Code == srvdef.ErrNoData { + log.Warn("视频操作计数已为0,跳过扣减", log.Any("视频ID", videoId), log.Any("计数字段", countField)) + return nil + } + log.Error("更新视频操作计数失败", log.Any("视频ID", videoId), log.Any("计数字段", countField), log.Any("增量", delta), log.E(err)) + return err + } + return nil +} + +// rollbackVideoLike 回滚视频点赞关系 +func rollbackVideoLike(ctx context.Context, userId int64, videoId int64, enabled bool) { + filter := bson.M{ + videolikemod.FN.VideoLike.UserId: userId, + videolikemod.FN.VideoLike.VideoId: videoId, + } + if enabled { + if _, err := videolikemod.DeleteOneVideoLike(ctx, filter); err != nil { + log.Error("回滚视频点赞关系失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + } + return + } + if _, err := videolikemod.InsertOneVideoLike(ctx, &videolikemod.VideoLike{ + UserId: userId, + VideoId: videoId, + }); err != nil { + log.Error("恢复视频点赞关系失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + } +} + +// rollbackVideoCollect 回滚视频收藏关系 +func rollbackVideoCollect(ctx context.Context, userId int64, videoId int64, enabled bool) { + filter := bson.M{ + videocollectmod.FN.VideoCollect.UserId: userId, + videocollectmod.FN.VideoCollect.VideoId: videoId, + } + if enabled { + if _, err := videocollectmod.DeleteOneVideoCollect(ctx, filter); err != nil { + log.Error("回滚视频收藏关系失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + } + return + } + if _, err := videocollectmod.InsertOneVideoCollect(ctx, &videocollectmod.VideoCollect{ + UserId: userId, + VideoId: videoId, + }); err != nil { + log.Error("恢复视频收藏关系失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + } +} + +// findVideoUserOperateStatus 查询当前用户视频点赞和收藏状态 +func findVideoUserOperateStatus(ctx context.Context, userId int64, videoId int64) (int64, int64, *srvdef.SrvErr) { + if userId <= 0 { + return videoOperateStatusNo, videoOperateStatusNo, nil + } + likeExists, err := videolikemod.ExistsVideoLike(ctx, bson.M{ + videolikemod.FN.VideoLike.UserId: userId, + videolikemod.FN.VideoLike.VideoId: videoId, + }) + if err != nil { + log.Error("查询视频用户点赞状态失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + return 0, 0, err + } + collectExists, err := videocollectmod.ExistsVideoCollect(ctx, bson.M{ + videocollectmod.FN.VideoCollect.UserId: userId, + videocollectmod.FN.VideoCollect.VideoId: videoId, + }) + if err != nil { + log.Error("查询视频用户收藏状态失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + return 0, 0, err + } + return videoOperateStatusByFlag(likeExists), videoOperateStatusByFlag(collectExists), nil +} + +// applyVideoLocalCounter 根据本次关系变更结果计算响应计数 +func applyVideoLocalCounter(current int64, enabled bool, changed bool) int64 { + if !changed { + return current + } + if enabled { + return current + 1 + } + if current <= 0 { + return 0 + } + return current - 1 +} + +// videoOperateStatusByFlag 转换视频操作状态响应值 +func videoOperateStatusByFlag(enabled bool) int64 { + if enabled { + return videoOperateStatusYes + } + return videoOperateStatusNo +} + +// normalizeVideoPage 规范化视频普通分页参数,限制单次查询量保护接口和数据库 +func normalizeVideoPage(pageNum, pageSize, defaultPageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = defaultPageSize + } + if pageSize > videoMaxPageSize { + pageSize = videoMaxPageSize + } + return pageNum, pageSize +} + +func emptyVideoPage(pageNum, pageSize int64) *proto.VideoResp { + return &proto.VideoResp{ + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.VideoItemResp, 0), + } +} + +// videoToListResp 将视频模型转换为列表项 +func videoToListResp(item *videoinformationmod.VideoInformation, imgCdn string) *proto.VideoItemResp { + return &proto.VideoItemResp{ + Id: item.Id, + Slug: item.Slug, + Title: item.Title, + AddedTime: item.AddedTime, + CreatorId: item.CreatorId, + CreatorName: item.CreatorName, + CoverImg: netutil.MergeURL(imgCdn, item.CoverImg), + Keyworld: item.Keyworld, + Tags: item.Tags, + WatchTimes: item.Real.WatchTimes + item.Fake.WatchTimes, + VideoTime: item.Video.VideoTime, + } +} + +// videoToAllListResp 将视频模型转换为全部视频列表项 +func videoToAllListResp(item *videoinformationmod.VideoInformation, imgCdn string, topicMap map[int64]proto.VideoTopicInfo) *proto.VideoAllListItemResp { + resp := &proto.VideoAllListItemResp{ + Id: item.Id, + Slug: item.Slug, + Title: item.Title, + MediaType: appVideoMediaType(item.MediaType), + AddedTime: item.AddedTime, + CreatorId: item.CreatorId, + CreatorName: item.CreatorName, + CoverImg: netutil.MergeURL(imgCdn, item.CoverImg), + Keyworld: item.Keyworld, + Tags: item.Tags, + TopicIds: item.TopicIds, + TopicNames: make([]string, 0, len(item.TopicIds)), + Topics: make([]proto.VideoTopicInfo, 0, len(item.TopicIds)), + WatchTimes: item.Real.WatchTimes + item.Fake.WatchTimes, + VideoTime: item.Video.VideoTime, + VideoCount: item.VideoCount, + } + for _, id := range item.TopicIds { + topic, ok := topicMap[id] + if !ok { + continue + } + resp.TopicNames = append(resp.TopicNames, topic.Name) + resp.Topics = append(resp.Topics, topic) + } + return resp +} + +// videoToInfoResp 将视频模型转换为详情响应,并给视频播放地址增加鉴权参数 +func videoToInfoResp(item *videoinformationmod.VideoInformation, imgCdn, ip, ua string, includeCurrentWatch bool, userId int64) *proto.VideoInfoResp { + video := proto.VideoData{ + TaskId: item.Video.TaskId, + FileId: item.Video.FileId, + Url: item.Video.Url, + Cover: netutil.MergeURL(imgCdn, item.Video.Cover), + Height: item.Video.Height, + Width: item.Video.Width, + VideoTime: item.Video.VideoTime, + PreMp4FileNameFs: item.Video.PreMp4FileNameFs, + UnencryptedPreMp4: item.Video.UnencryptedPreMp4, + } + if video.Url != "" { + video.Url = GetM3U8AuthParam(item.Id, video.Url, ip, ua, userId) + } + watchTimes := item.Real.WatchTimes + item.Fake.WatchTimes + if includeCurrentWatch { + watchTimes++ + } + resp := &proto.VideoInfoResp{ + Id: item.Id, + Slug: item.Slug, + Title: item.Title, + CoverImg: netutil.MergeURL(imgCdn, item.CoverImg), + Keyworld: item.Keyworld, + Tags: item.Tags, + TagsName: make([]string, 0, len(item.Tags)), + TopicIds: item.TopicIds, + Topics: make([]proto.VideoTopicInfo, 0, len(item.TopicIds)), + WatchTimes: watchTimes, + CountLike: item.Real.Likes, + CountCollect: item.Real.Collects, + CountComment: item.Real.Comments, + IsLike: videoOperateStatusNo, + IsCollect: videoOperateStatusNo, + Video: video, + } + for _, tagId := range item.Tags { + tagInfo := configser.GetTag(tagId) + if tagInfo != nil { + resp.TagsName = append(resp.TagsName, tagInfo.Name) + } + } + return resp +} + +// recordVideoWatchHistory 记录当前登录用户的视频观看历史 +func recordVideoWatchHistory(ctx context.Context, userId int64, videoId int64) { + if userId <= 0 || videoId <= 0 { + return + } + go func() { + if err := videowatchhistorymod.SaveVideoWatchHistory(context.Background(), userId, videoId); err != nil { + log.Error("记录视频观看历史失败", log.Any("用户ID", userId), log.Any("视频ID", videoId), log.E(err)) + } + }() +} + +// collectVideoIdsFromHistories 按观看历史顺序收集视频ID并去重 +func collectVideoIdsFromHistories(list videowatchhistorymod.VideoWatchHistoryList) []int64 { + result := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, item := range list { + if item == nil || item.VideoId <= 0 { + continue + } + if _, ok := seen[item.VideoId]; ok { + continue + } + seen[item.VideoId] = struct{}{} + result = append(result, item.VideoId) + } + return result +} + +// collectVideoIdsFromCollects 按收藏列表顺序收集视频ID并去重 +func collectVideoIdsFromCollects(list videocollectmod.VideoCollectList) []int64 { + result := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, item := range list { + if item == nil || item.VideoId <= 0 { + continue + } + if _, ok := seen[item.VideoId]; ok { + continue + } + seen[item.VideoId] = struct{}{} + result = append(result, item.VideoId) + } + return result +} + +// findUserVideoMap 批量查询用户列表关联的视频数据 +func findUserVideoMap(ctx context.Context, videoIds []int64) (map[int64]*videoinformationmod.VideoInformation, *srvdef.SrvErr) { + result := make(map[int64]*videoinformationmod.VideoInformation, len(videoIds)) + if len(videoIds) == 0 { + return result, nil + } + list, err := videoinformationmod.FindManyVideoInformations(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": videoIds}, + videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK, + }, options.Find().SetProjection(videoUserListProjection)) + if err != nil { + log.Error("批量查询用户视频列表失败", log.Any("视频ID集合", videoIds), log.E(err)) + return nil, err + } + for _, item := range list { + if item == nil { + continue + } + result[item.Id] = item + } + return result, nil +} + +// findUserCollectedVideoMap 批量查询当前用户收藏的视频ID +func findUserCollectedVideoMap(ctx context.Context, userId int64, videoIds []int64) (map[int64]struct{}, *srvdef.SrvErr) { + result := make(map[int64]struct{}, len(videoIds)) + if userId <= 0 || len(videoIds) == 0 { + return result, nil + } + list, err := videocollectmod.FindManyVideoCollect(ctx, bson.M{ + videocollectmod.FN.VideoCollect.UserId: userId, + videocollectmod.FN.VideoCollect.VideoId: bson.M{"$in": videoIds}, + }, options.Find().SetProjection(bson.D{ + {Key: videocollectmod.FN.VideoCollect.VideoId, Value: 1}, + })) + if err != nil { + log.Error("批量查询用户视频收藏状态失败", log.Any("用户ID", userId), log.Any("视频ID集合", videoIds), log.E(err)) + return nil, err + } + for _, item := range list { + if item == nil { + continue + } + result[item.VideoId] = struct{}{} + } + return result, nil +} + +// buildVideoHistoryData 组装视频观看历史响应 +func buildVideoHistoryData( + ctx context.Context, + historyList videowatchhistorymod.VideoWatchHistoryList, + videoMap map[int64]*videoinformationmod.VideoInformation, + collectMap map[int64]struct{}, +) []proto.VideoHistoryData { + result := make([]proto.VideoHistoryData, 0, len(historyList)) + imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + for _, history := range historyList { + if history == nil { + continue + } + item := videoMap[history.VideoId] + if item == nil { + continue + } + _, collected := collectMap[item.Id] + result = append(result, proto.VideoHistoryData{ + Id: item.Id, + Slug: item.Slug, + Title: item.Title, + CoverImg: netutil.MergeURL(imgCdn, item.CoverImg), + Keyworld: item.Keyworld, + Tags: item.Tags, + WatchTimes: item.Real.WatchTimes + item.Fake.WatchTimes, + VideoTime: item.Video.VideoTime, + CountLike: item.Real.Likes, + CountCollect: item.Real.Collects, + CountComment: item.Real.Comments, + IsCollect: videoOperateStatusByFlag(collected), + HistoryAt: history.UpdatedAt, + }) + } + return result +} + +// buildVideoCollectData 组装视频收藏列表响应 +func buildVideoCollectData( + ctx context.Context, + collectList videocollectmod.VideoCollectList, + videoMap map[int64]*videoinformationmod.VideoInformation, +) []proto.VideoCollectData { + result := make([]proto.VideoCollectData, 0, len(collectList)) + imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + for _, collect := range collectList { + if collect == nil { + continue + } + item := videoMap[collect.VideoId] + if item == nil { + continue + } + result = append(result, proto.VideoCollectData{ + Id: item.Id, + Slug: item.Slug, + Title: item.Title, + CoverImg: netutil.MergeURL(imgCdn, item.CoverImg), + Keyworld: item.Keyworld, + Tags: item.Tags, + WatchTimes: item.Real.WatchTimes + item.Fake.WatchTimes, + VideoTime: item.Video.VideoTime, + CountLike: item.Real.Likes, + CountCollect: item.Real.Collects, + CountComment: item.Real.Comments, + IsCollect: videoOperateStatusYes, + CollectedAt: collect.CreatedAt, + }) + } + return result +} + +// queryVideoTopicsByIds 按视频题材ID集合查询题材信息 +func queryVideoTopicsByIds(ctx context.Context, topicIds []int64) ([]proto.VideoTopicInfo, *srvdef.SrvErr) { + result := make([]proto.VideoTopicInfo, 0) + if len(topicIds) == 0 { + return result, nil + } + ids := make([]int64, 0, len(topicIds)) + seen := make(map[int64]struct{}, len(topicIds)) + for _, id := range topicIds { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return result, nil + } + topics, err := videotopicmod.FindManyVideoTopicModels(ctx, bson.M{ + videotopicmod.FN.VideoTopicModel.Id: bson.M{"$in": ids}, + }, options.Find().SetProjection(bson.D{ + {Key: videotopicmod.FN.VideoTopicModel.Id, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Name, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Slug, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Title, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Desc, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Image, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.VideoCategoryId, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.SeoInfo.Title, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.SeoInfo.Description, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.SeoInfo.Keyworld, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Works, Value: 1}, + })) + if err != nil { + return nil, err + } + topicMap := make(map[int64]proto.VideoTopicInfo, len(topics)) + imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + for _, item := range topics { + info := videoTopicToInfo(item, imgCdn) + topicMap[info.Id] = info + } + for _, id := range ids { + if topic, ok := topicMap[id]; ok { + result = append(result, topic) + } + } + return result, nil +} + +// buildVideoDetailSeoInfo 组装视频详情页SEO信息 +// +// canonical 统一指向Web/H5真实播放页:/series-play/{slug} +func buildVideoDetailSeoInfo(item *videoinformationmod.VideoInformation) *proto.SeoInfo { + seo := configser.GetSiteSeoInfo() + if item == nil { + return &seo + } + if title := strings.TrimSpace(item.Title); title != "" { + seo.Title = title + } + if desc := truncateVideoSEODescription(strings.TrimSpace(item.Desc), 120); desc != "" { + seo.Description = desc + } + seo.Keywords = buildVideoSEOKeywords(item, seo.Keywords) + seo.CanonicalURL = buildVideoDetailCanonicalURL(seo.CanonicalURL, item.Slug, item.Id) + return &seo +} + +// buildVideoDetailCanonicalURL 拼接视频播放页标准URL +func buildVideoDetailCanonicalURL(baseURL, slug string, id int64) string { + pagePath := h5path.VideoPlay(slug, id) + if pagePath == "" { + pagePath = videoH5DetailPath + } + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return pagePath + } + return baseURL + pagePath +} + +// buildVideoSEOKeywords 合并视频关键词和全站关键词,并去重 +func buildVideoSEOKeywords(item *videoinformationmod.VideoInformation, siteKeywords []string) []string { + seen := make(map[string]struct{}, len(item.Keyworld)+len(siteKeywords)+1) + keywords := make([]string, 0, len(item.Keyworld)+len(siteKeywords)+1) + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + keywords = append(keywords, value) + } + add(item.Title) + for _, keyword := range item.Keyworld { + add(keyword) + } + for _, keyword := range siteKeywords { + add(keyword) + } + return keywords +} + +// truncateVideoSEODescription 限制meta description长度,避免详情长文本直接写进SEO描述 +func truncateVideoSEODescription(desc string, maxRunes int) string { + if maxRunes <= 0 || desc == "" { + return "" + } + runes := []rune(desc) + if len(runes) <= maxRunes { + return desc + } + return string(runes[:maxRunes]) +} + +func VideoDetails(ctx context.Context, videoId int64, userId int64, ip, ua string) (resp proto.VideoInfoData, serr *srvdef.SrvErr) { + imgCdn := configser.GetAppImageCdn(ip) + videoFilter := bson.M{ + videoinformationmod.FN.VideoInformation.Id: videoId, + videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK, + } + + videoInfo, serr := videoinformationmod.FindOneVideoInformation(ctx, videoFilter) + if serr != nil { + log.Error("VideoDetails", log.Any("videoFilter", videoFilter), log.E(serr)) + return + } + // 查询分类 + + resp = toVideoInfoData(videoInfo, imgCdn) + for _, tagId := range videoInfo.Tags { + tagInfo := configser.GetTag(tagId) + if tagInfo != nil { + resp.Tags = append(resp.Tags, proto.TagsInfoData{ + Id: tagInfo.Id, + Name: tagInfo.Name, + Slug: tagInfo.Slug, + }) + } + } + if resp.WatchTimes > 0 { + resp.WatchTimes = resp.WatchTimes * 100 + } else { + resp.WatchTimes = utils.GetRandom(2000, 10000) + } + if resp.Content != "" { + re := regexp.MustCompile(`data-src="([^"]+?\.m3u8)"`) + // 查找所有匹配项 + matches := re.FindAllStringSubmatch(resp.Content, -1) + for _, match := range matches { + newUrl := GetM3U8AuthParam(videoId, match[1], ip, ua, userId) + resp.Content = strings.ReplaceAll(resp.Content, match[1], newUrl) + } + } + + if len(resp.Video.Url) > 0 || len(resp.Nodes) > 0 { + applyVideoUrlAuth(&resp, videoId, ip, ua, userId) + } + go func() { + _ = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: videoId, + }, bson.M{"$inc": bson.M{videoinformationmod.FN.VideoInformation.Real.ShowTimes: 1}}) + if len(videoInfo.Tags) > 0 { + tagIds := videoInfo.Tags + if len(tagIds) > 10 { + tagIds = tagIds[:10] + } + _ = videotagmod.UpdateManyVideoTagInfo(ctx, bson.M{ + videotagmod.FN.VideoTagInfo.Id: bson.M{"$in": tagIds}, + }, bson.M{"$inc": bson.M{ + videotagmod.FN.VideoTagInfo.WatchTimes: 1, + }}) + } + }() + return resp, nil +} + +func RichtextData(ctx context.Context, richtextId int64) (proto.RichTextInfo, *srvdef.SrvErr) { + //cacheKey := fmt.Sprintf("rich_text_data_%d", richtextId) + var resp *proto.RichTextInfo + //if cacheData, exists := Cache.Get(cacheKey); exists { + // if item, ok := cacheData.(proto.RichTextInfo); ok { + // resp = &item + // } + //} + //if resp == nil || resp.Id == 0 { + filter := bson.M{ + richtextcontentmod.FN.RichTextContentModel.Id: richtextId, + } + + richtextInfo, serr := richtextcontentmod.FindOneRichTextContentModelModel(ctx, filter) + if serr != nil { + log.Error("VideoTagData", log.Any("videoFilter", filter), log.E(serr)) + return proto.RichTextInfo{}, serr + } + data := &proto.RichTextInfo{ + Id: richtextInfo.Id, + Title: richtextInfo.Title, + Content: richtextInfo.Content, + Desc: richtextInfo.Desc, + SeoInfo: richtextInfo.SeoInfo, + } + resp = data + // Cache.Set(cacheKey, data, 17*time.Minute) + //} + + return *resp, nil +} + +func IncWatchTimes(ctx context.Context, videoId int64) { + _ = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: videoId, + }, bson.M{"$inc": bson.M{ + videoinformationmod.FN.VideoInformation.Real.WatchTimes: 1, + }}) +} + +// VideoTopicList 查询app端视频题材列表 +func VideoTopicList(ctx context.Context, req *proto.VideoTopicListReq) (*proto.VideoTopicListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter := bson.M{ + videotopicmod.FN.VideoTopicModel.Status: true, + } + mediaType, err := resolveAppVideoMediaType(req.MediaType) + if err != nil { + return nil, err + } + if req.MediaType != videoAllTypeAll { + filter[videotopicmod.FN.VideoTopicModel.VideoTopicType] = mediaType + } + list, err := videotopicmod.FindManyVideoTopicModels(ctx, filter, options.Find(). + SetSort(bson.D{ + {Key: videotopicmod.FN.VideoTopicModel.Rank, Value: -1}, + {Key: videotopicmod.FN.VideoTopicModel.Id, Value: -1}, + })) + if err != nil { + return nil, err + } + imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + resp := &proto.VideoTopicListResp{List: make([]proto.VideoTopicInfo, 0, len(list))} + for _, item := range list { + resp.List = append(resp.List, videoTopicToInfo(item, imgCdn)) + } + return resp, nil +} + +// videoAllListFilter 构建全部视频分页过滤条件 +func videoAllListFilter(ctx context.Context, req *proto.VideoAllListReq) (bson.M, *srvdef.SrvErr) { + filter := bson.M{ + videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK, + } + mediaType, err := resolveAppVideoMediaType(req.MediaType) + if err != nil { + return nil, err + } + if req.MediaType != videoAllTypeAll { + filter[videoinformationmod.FN.VideoInformation.MediaType] = mediaType + } + topicID, serr := resolveVideoTopicID(ctx, req.TopicId, req.TopicSlug) + if serr != nil { + return nil, serr + } + if topicID > 0 { + filter[videoinformationmod.FN.VideoInformation.TopicIds] = topicID + } + return filter, nil +} + +// videoAllListSort 构建全部视频分页排序 +func videoAllListSort(sortType int64) bson.D { + switch sortType { + case videoAllSortHot, videoAllSortCollect: + return bson.D{ + {Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + } + case videoAllSortLatest: + fallthrough + default: + return bson.D{ + {Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1}, + {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1}, + } + } +} + +// resolveAppVideoMediaType 将app类型映射到视频表mediaType +func resolveAppVideoMediaType(mediaType int64) (videoinformationmod.MediaType, *srvdef.SrvErr) { + switch mediaType { + case videoAllTypeAll: + return 0, nil + case videoAllTypeCollection: + return videoinformationmod.MediaType_Collection, nil + case videoAllTypeAnime: + return videoinformationmod.MediaType_VideoV2, nil + default: + return 0, srvdef.New(srvdef.ErrParamError, "类型无效", "类型无效") + } +} + +// appVideoMediaType 将视频表mediaType转成app类型 +func appVideoMediaType(mediaType videoinformationmod.MediaType) int64 { + switch mediaType { + case videoinformationmod.MediaType_Collection: + return videoAllTypeCollection + case videoinformationmod.MediaType_VideoV2: + return videoAllTypeAnime + default: + return int64(mediaType) + } +} + +// buildVideoTopicMap 批量查询视频题材 +func buildVideoTopicMap(ctx context.Context, list videoinformationmod.VideoInformationList) (map[int64]proto.VideoTopicInfo, *srvdef.SrvErr) { + ids := make([]int64, 0) + seen := make(map[int64]struct{}) + for _, item := range list { + if item == nil { + continue + } + for _, id := range item.TopicIds { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + } + result := make(map[int64]proto.VideoTopicInfo, len(ids)) + if len(ids) == 0 { + return result, nil + } + topics, err := videotopicmod.FindManyVideoTopicModels(ctx, bson.M{ + videotopicmod.FN.VideoTopicModel.Id: bson.M{"$in": ids}, + }, options.Find().SetProjection(bson.D{ + {Key: videotopicmod.FN.VideoTopicModel.Id, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Name, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Slug, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Title, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Desc, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Image, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.VideoCategoryId, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.SeoInfo.Title, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.SeoInfo.Description, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.SeoInfo.Keyworld, Value: 1}, + {Key: videotopicmod.FN.VideoTopicModel.Works, Value: 1}, + })) + if err != nil { + return nil, err + } + imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + for _, item := range topics { + info := videoTopicToInfo(item, imgCdn) + result[info.Id] = info + } + return result, nil +} + +// videoTopicToInfo 转换视频题材响应 +func videoTopicToInfo(item *videotopicmod.VideoTopicModel, imgCdn string) proto.VideoTopicInfo { + if item == nil { + return proto.VideoTopicInfo{} + } + return proto.VideoTopicInfo{ + Id: item.Id, + Name: item.Name, + Slug: item.Slug, + Title: item.Title, + Desc: item.Desc, + Image: netutil.MergeURL(imgCdn, item.Image), + VideoCategoryId: item.VideoCategoryId, + SeoInfo: item.SeoInfo, + Works: item.Works, + } +} + +func VideoTopicData(ctx context.Context, isAv bool, topicId int64, sortType int64, pageNum int64) (videoCount int64, videoInfos []proto.VideoInfoData, sortItem videoinformationmod.VideoSortItem, err *srvdef.SrvErr) { + imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx)) + videoCount = int64(0) + sort := videoinformationmod.VideoSortType(sortType) + sortItem = sort.GetSortItem() + videoInfos = make([]proto.VideoInfoData, 0) + + filter := bson.M{ + videoinformationmod.FN.VideoInformation.MediaType: videoinformationmod.MediaType_VideoV2, + videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK, + } + opts := &options.FindOptions{} + opts.SetLimit(pubdef.PageSize).SetSkip((pageNum - 1) * pubdef.PageSize).SetSort(sort.GetSortBson()) + videoCount, _ = videoinformationmod.CountVideoInformations(ctx, filter) + if videoCount == 0 { + return + } + videoList, err := videoinformationmod.FindManyVideoInformations(ctx, filter, opts) + if err != nil { + log.Warn("VideoTopicData FindManyVideoInfomations", log.Any("filter", filter), log.E(err)) + return + } + + for i := 0; i < len(videoList); i++ { + info := toVideoInfoData(videoList[i], imgCdn) + for _, tid := range videoList[i].Tags { + tagInfo := configser.GetTag(tid) + if tagInfo != nil { + info.Tags = append(info.Tags, proto.TagsInfoData{ + Id: tagInfo.Id, + Name: tagInfo.Name, + Slug: tagInfo.Slug, + }) + } + } + if info.WatchTimes > 0 { + info.WatchTimes = info.WatchTimes * 100 + } else { + info.WatchTimes = utils.GetRandom(2000, 10000) + } + videoInfos = append(videoInfos, info) + } + + return +} diff --git a/app/service/videoser/videocategory.go b/app/service/videoser/videocategory.go new file mode 100644 index 0000000..3b7cb00 --- /dev/null +++ b/app/service/videoser/videocategory.go @@ -0,0 +1,15 @@ +package videoser + +import ( + "context" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videocategorymod" + + "go.mongodb.org/mongo-driver/bson" +) + +func GetVideoCategoryById(ctx context.Context, id int64) (*videocategorymod.VideoCategory, *srvdef.SrvErr) { + return videocategorymod.FindOneVideoCategory(ctx, bson.M{ + videocategorymod.FN.VideoCategory.Id: id, + }) +} diff --git a/app/service/videoser/videocreator.go b/app/service/videoser/videocreator.go new file mode 100644 index 0000000..347550a --- /dev/null +++ b/app/service/videoser/videocreator.go @@ -0,0 +1,15 @@ +package videoser + +import ( + "context" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videocreatermod" + + "go.mongodb.org/mongo-driver/bson" +) + +func GetVideoCreatorById(ctx context.Context, id int64) (*videocreatermod.VideoCreaterInfo, *srvdef.SrvErr) { + return videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{ + videocreatermod.FN.VideoCreaterInfo.Id: id, + }) +} diff --git a/app/service/videoser/videotag.go b/app/service/videoser/videotag.go new file mode 100644 index 0000000..641ed9f --- /dev/null +++ b/app/service/videoser/videotag.go @@ -0,0 +1,15 @@ +package videoser + +import ( + "context" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videotagmod" + + "go.mongodb.org/mongo-driver/bson" +) + +func GetVideoTagById(ctx context.Context, id int64) (*videotagmod.VideoTagInfo, *srvdef.SrvErr) { + return videotagmod.FindOneVideoTagInfo(ctx, bson.M{ + videotagmod.FN.VideoTagInfo.Id: id, + }) +} diff --git a/app/service/videoser/videotopic.go b/app/service/videoser/videotopic.go new file mode 100644 index 0000000..7825082 --- /dev/null +++ b/app/service/videoser/videotopic.go @@ -0,0 +1,15 @@ +package videoser + +import ( + "context" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videotopicmod" + + "go.mongodb.org/mongo-driver/bson" +) + +func GetVideoTopicById(ctx context.Context, id int64) (*videotopicmod.VideoTopicModel, *srvdef.SrvErr) { + return videotopicmod.FindOneVideoTopicModel(ctx, bson.M{ + videotopicmod.FN.VideoTopicModel.Id: id, + }) +} diff --git a/app/service/videoser/vtt.go b/app/service/videoser/vtt.go new file mode 100644 index 0000000..2478088 --- /dev/null +++ b/app/service/videoser/vtt.go @@ -0,0 +1,158 @@ +package videoser + +import ( + "bufio" + "bytes" + "fmt" + "io" + http2 "net/http" + "strings" + "videoweb/app/appg" + "videoweb/app/service/configser" + "videoweb/pub/common" + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/errors" + "videoweb/pub/common/http" + "videoweb/pub/common/log" + "videoweb/pub/common/m3u8" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/redis" + "videoweb/pub/storage/proxy" + + "github.com/gin-gonic/gin" +) + +func GetVTTEx(ctx *gin.Context) { + fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) { + vttName := ctx.Param("key") + var req m3u8.Req + if err = ctx.BindQuery(&req); err != nil { + return + } + + req.Name = vttName + cfg := configser.GetConfigInfo() + cdnDomain := cfg.ImgCdn + originDomain := appg.Conf.OrigDomain + + buff, err := TransformVttEx(req.Name, cdnDomain, originDomain) + if err != nil { + return + } + contentType = "application/vnd.apple.mpegurl" + reader = bytes.NewReader(buff) + contentLength = int64(len(buff)) + extraHeaders = map[string]string{} + return + } + contentLength, contentType, reader, extraHeaders, err := fn(ctx) + if err != nil { + common.ServerJSON(ctx, srvdef.DownloadM3u8File, err) + return + } + ctx.DataFromReader(http2.StatusOK, contentLength, contentType, reader, extraHeaders) +} + +func TransformVttEx(name, cdnDomain, originDomain string) (buff []byte, err error) { + if name == "" { + err = errors.MakeWithMsg(errors.M3u8FileName, "m3u8 file name is empty") + return + } + if cdnDomain == "" || originDomain == "" { + err = errors.MakeWithMsg(errors.DomainErr, "cdnDomain or origDomain is empty") + return + } + // 处理媒资库 桶1(v1) 桶2(v2) 桶3(v3) + mediaVerson := proxy.AppendsMediaS3Version(name) + if len(mediaVerson) > 0 { + name = mediaVerson + name + } + + // 原始vtt + srcReader, err := readFromRedis(name, originDomain) + if err != nil { + return + } + + raw, err := io.ReadAll(srcReader) + if err != nil { + return nil, errors.MakeWithMsg(errors.M3u8Read, "read vtt reader fail: "+err.Error()) + } + // 替换为cdn路径 + newData := rewriteVttWithCDN(raw, cdnDomain) + reader := bytes.NewReader(newData) + buff, err = io.ReadAll(reader) + if err != nil { + return nil, errors.MakeWithMsg(errors.M3u8Read, "read vtt fail: "+err.Error()) + } + return +} + +// 从redis缓存中读取 +func readFromRedis(name, domain string) (reader io.Reader, err error) { + vttKey := redisconst.MediaM3U8Key(name) + data, err := redis.GetBytes(vttKey) + if err == nil && len(data) > 0 { + reader = bytes.NewReader(data) + return + } + requestUrl := utils.JoinUrlPath(domain, name) + resp, err := http.GetLargeFile(requestUrl, nil) + if err != nil { + fmt.Println("requestUrl111", requestUrl) + return + } + fmt.Println("requestUrl", requestUrl, resp.StatusCode) + if resp.StatusCode != 200 { + msg := fmt.Sprintf("read vtt fail, url: %s, status code %d", requestUrl, resp.StatusCode) + err = errors.MakeWithMsg(errors.M3u8Read, msg) + return + } + if err := redis.Set(vttKey, resp.Data, + redisconst.MediaM3U8CacheExpire); err != nil { + log.Info("vtt:readFromRedis redis set err", log.E(err)) + } + reader = bytes.NewReader(resp.Data) + return +} + +// rewriteVttWithCDN 将 vtt 内 "path#xywh=..." 的 path 改为 cdnDomain/path +func rewriteVttWithCDN(vttData []byte, cdnDomain string) []byte { + cdnDomain = strings.TrimSpace(cdnDomain) + cdnDomain = strings.TrimRight(cdnDomain, "/") + + var out bytes.Buffer + scanner := bufio.NewScanner(bytes.NewReader(vttData)) + + for scanner.Scan() { + line := scanner.Text() + trim := strings.TrimSpace(line) + + // 只处理缩略图行:xxx#xywh=... + if strings.Contains(trim, "#xywh=") { + parts := strings.SplitN(trim, "#xywh=", 2) + if len(parts) == 2 { + p := strings.TrimSpace(parts[0]) // 原路径 + xywh := parts[1] + + // 已经是绝对URL则不改 + if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") { + out.WriteString(p + "#xywh=" + xywh + "\n") + continue + } + + // 处理相对路径,确保只有一个 / + p = strings.TrimLeft(p, "/") + newPath := cdnDomain + "/" + p + out.WriteString(newPath + "#xywh=" + xywh + "\n") + continue + } + } + + out.WriteString(line + "\n") + } + + // scanner.Err() + return out.Bytes() +} 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..3a7b00d --- /dev/null +++ b/app/skd/ipstat/sync.go @@ -0,0 +1,49 @@ +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 + +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 + } + + 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/sitemap/data.go b/app/skd/job/sitemap/data.go new file mode 100644 index 0000000..81c75ed --- /dev/null +++ b/app/skd/job/sitemap/data.go @@ -0,0 +1,374 @@ +package sitemap + +import ( + "context" + "strings" + "sync" + "time" + "videoweb/pub/models/homeconfigmod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/pub/models/playletthememod" + "videoweb/pub/seo/h5path" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type idWithTime struct { + Id int64 + Slug string + LastMod time.Time + IsOriginal bool + ChapterID int64 + SkipPlaySitemap bool +} + +type staticPageEntry struct { + path string + changeFreq string + priority float32 +} + +func buildStaticURLs(webAddr string, entries []staticPageEntry) []URL { + urls := make([]URL, 0, len(entries)) + now := sitemapNow() + for _, entry := range entries { + urls = append(urls, URL{ + Loc: h5path.SitemapAbs(webAddr, entry.path), + LastMod: now, + ChangeFreq: entry.changeFreq, + Priority: entry.priority, + }) + } + return urls +} + +func entityPath(prefix, slug string, id int64) string { + return h5path.Entity(prefix, slug, id) +} + +// staticPages 对齐 https://sgdj-h5.80sygtm.com/sitemap.xml 的固定页面。 +func staticPages() []staticPageEntry { + return []staticPageEntry{ + {path: "/", changeFreq: "daily", priority: 1}, + {path: "/shorts", changeFreq: "daily", priority: 0.8}, + {path: "/series", changeFreq: "daily", priority: 0.8}, + {path: "/topics", changeFreq: "daily", priority: 0.8}, + {path: "/rank", changeFreq: "daily", priority: 0.8}, + {path: "/home-road", changeFreq: "daily", priority: 0.8}, + {path: "/apps", changeFreq: "daily", priority: 0.8}, + {path: "/nav", changeFreq: "monthly", priority: 0.4}, + {path: "/info/about-us", changeFreq: "monthly", priority: 0.3}, + {path: "/info/user-agreement", changeFreq: "monthly", priority: 0.3}, + {path: "/info/privacy-policy", changeFreq: "monthly", priority: 0.3}, + {path: "/info/help-center", changeFreq: "monthly", priority: 0.3}, + {path: "/info/2257", changeFreq: "monthly", priority: 0.3}, + {path: "/info/dmca", changeFreq: "monthly", priority: 0.3}, + } +} + +// getURL 拉取上架短剧、专题、标签和首页模块,按线上 H5 路由写入 sitemap。 +func getURL(sg *SitemapGenerator) { + webAddr := strings.TrimSpace(sg.WebAddr) + if webAddr == "" { + return + } + + ctx := context.Background() + var ( + wg sync.WaitGroup + listed listedPlaylets + themeItems []idWithTime + tagItems []idWithTime + homeConfigs []homeConfigItem + ) + + wg.Add(4) + go func() { + defer wg.Done() + listed = getPlayletList(ctx) + }() + go func() { + defer wg.Done() + themeItems = getThemeList(ctx) + }() + go func() { + defer wg.Done() + tagItems = getTagList(ctx) + }() + go func() { + defer wg.Done() + homeConfigs = getHomeConfigList(ctx) + }() + wg.Wait() + + // 没有上架短剧的题材和标签不写入,避免空列表页被收录。 + themeItems = keepWithListedPlaylets(themeItems, listed.themeIDs) + tagItems = keepWithListedPlaylets(tagItems, listed.tagIDs) + + pageURLs := buildStaticURLs(webAddr, staticPages()) + for _, item := range themeItems { + if path := h5path.Topic(item.Slug, item.Id); path != "" { + pageURLs = append(pageURLs, newURL(webAddr, path, item.LastMod, "daily", 0.8)) + } + } + for _, item := range tagItems { + if path := h5path.TopicTag(item.Slug, item.Id); path != "" { + pageURLs = append(pageURLs, newURL(webAddr, path, item.LastMod, "weekly", 0.6)) + } + } + for _, item := range homeConfigs { + if item.Type != homeconfigmod.TypePlaylet { + continue + } + if path := h5path.ShortsMore(item.Slug, item.Id); path != "" { + pageURLs = append(pageURLs, newURL(webAddr, path, item.LastMod, "daily", 0.7)) + } + } + if len(pageURLs) > 0 { + _ = sg.writeSitemapFile("sitemap-pages.xml", pageURLs) + } + + // 详情页是 /drama/{slug}。各集 /play/{slug}/{episode} 不进 sitemap,避免和详情页重复。 + // 原创且第一集无水印的剧不写入,避免空壳页被提交。 + populateFirstChapters(ctx, listed.items) + if urls := buildDramaURLs(webAddr, listed.items, "weekly", 0.7); len(urls) > 0 { + _ = sg.writeSitemapFiles("sitemap-drama", urls) + } + sg.removeObsoleteSitemaps("sitemap-playletdetail", "sitemap-seriesplay") +} + +type listedPlaylets struct { + items []idWithTime + themeIDs map[int64]struct{} + tagIDs map[int64]struct{} +} + +type homeConfigItem struct { + Id int64 + Slug string + Type int32 + LastMod time.Time +} + +func newURL(webAddr, path string, lastMod time.Time, changeFreq string, priority float32) URL { + mod := sitemapLastMod(lastMod) + if mod == "" { + mod = sitemapNow() + } + return URL{ + Loc: h5path.SitemapAbs(webAddr, path), + LastMod: mod, + ChangeFreq: changeFreq, + Priority: priority, + } +} + +func keepWithListedPlaylets(items []idWithTime, listed map[int64]struct{}) []idWithTime { + if len(items) == 0 || len(listed) == 0 { + return nil + } + out := make([]idWithTime, 0, len(items)) + for _, item := range items { + if _, ok := listed[item.Id]; ok { + out = append(out, item) + } + } + return out +} + +func addListedIDs(dst map[int64]struct{}, ids []int64) { + for _, id := range ids { + if id > 0 { + dst[id] = struct{}{} + } + } +} + +func buildDramaURLs(webAddr string, items []idWithTime, changeFreq string, priority float32) []URL { + urls := make([]URL, 0, len(items)) + for _, item := range items { + if item.SkipPlaySitemap { + continue + } + path := h5path.Drama(item.Slug, item.Id) + if path == "" { + continue + } + urls = append(urls, newURL(webAddr, path, item.LastMod, changeFreq, priority)) + } + return urls +} + +func skipOriginalChapterWithoutWatermark(isOriginal bool, watermarkURL string) bool { + return isOriginal && strings.TrimSpace(watermarkURL) == "" +} + +// populateFirstChapters 每剧只看第一集;原创且无水印则不写详情 URL。 +func populateFirstChapters(ctx context.Context, items []idWithTime) { + for start := 0; start < len(items); start += 500 { + end := start + 500 + if end > len(items) { + end = len(items) + } + ids := make([]int64, 0, end-start) + byID := make(map[int64]*idWithTime, end-start) + for i := start; i < end; i++ { + if items[i].Id <= 0 { + continue + } + ids = append(ids, items[i].Id) + byID[items[i].Id] = &items[i] + } + if len(ids) == 0 { + continue + } + chapters, err := playletchaptermod.FindManyPlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.PlayletId: bson.M{"$in": ids}, + }, options.Find(). + SetSort(bson.D{ + {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.PlayletId: 1, + playletchaptermod.FN.PlayletChapter.WatermarkVideoUrl: 1, + })) + if err != nil { + return + } + seenFirst := make(map[int64]struct{}, len(ids)) + for _, ch := range chapters { + if ch == nil || ch.Id <= 0 { + continue + } + item := byID[ch.PlayletId] + if item == nil { + continue + } + if _, ok := seenFirst[ch.PlayletId]; ok { + continue + } + seenFirst[ch.PlayletId] = struct{}{} + if skipOriginalChapterWithoutWatermark(item.IsOriginal, ch.WatermarkVideoUrl) { + item.SkipPlaySitemap = true + continue + } + item.ChapterID = ch.Id + } + } +} + +func getPlayletList(ctx context.Context) listedPlaylets { + result := listedPlaylets{ + items: make([]idWithTime, 0), + themeIDs: make(map[int64]struct{}), + tagIDs: make(map[int64]struct{}), + } + filter := bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + } + pageSize := int64(1000) + 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.IsOriginal: 1, + playletmod.FN.Playlet.UpdatedAt: 1, + playletmod.FN.Playlet.OnlineAt: 1, + playletmod.FN.Playlet.ThemeIds: 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 + } + result.items = append(result.items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: lastMod, IsOriginal: info.IsOriginal}) + addListedIDs(result.themeIDs, info.ThemeIds) + addListedIDs(result.tagIDs, info.TagIds) + } + if len(list) < int(pageSize) { + break + } + } + return result +} + +func getThemeList(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++ { + skip := i * pageSize + list, _ := playletthememod.FindManyPlayletTheme(ctx, filter, options.Find(). + SetSort(bson.M{playletthememod.FN.PlayletTheme.Rank: -1}). + SetSkip(skip). + SetLimit(pageSize)) + for _, info := range list { + if info == nil || info.Id <= 0 || strings.TrimSpace(info.Name) == "" { + continue + } + items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt}) + } + if len(list) < int(pageSize) { + break + } + } + return items +} + +func getTagList(ctx context.Context) []idWithTime { + filter := bson.M{ + playlettagmod.FN.PlayletTag.Status: true, + } + pageSize := int64(1000) + items := make([]idWithTime, 0) + for i := int64(0); i < 100; i++ { + skip := i * pageSize + list, _ := playlettagmod.FindManyPlayletTag(ctx, filter, options.Find(). + SetSort(bson.M{playlettagmod.FN.PlayletTag.Rank: -1}). + SetSkip(skip). + SetLimit(pageSize)) + for _, info := range list { + if info == nil || info.Id <= 0 || strings.TrimSpace(info.Name) == "" { + continue + } + items = append(items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt}) + } + if len(list) < int(pageSize) { + break + } + } + return items +} + +func getHomeConfigList(ctx context.Context) []homeConfigItem { + list, _ := homeconfigmod.FindManyHomeConfig(ctx, bson.M{ + homeconfigmod.FN.HomeConfig.Status: true, + homeconfigmod.FN.HomeConfig.Type: bson.M{ + "$in": []int32{homeconfigmod.TypePlaylet}, + }, + }, options.Find().SetSort(bson.M{homeconfigmod.FN.HomeConfig.Rank: -1})) + items := make([]homeConfigItem, 0, len(list)) + for _, info := range list { + if info == nil || info.Id <= 0 { + continue + } + items = append(items, homeConfigItem{Id: info.Id, Slug: info.Slug, Type: info.Type, LastMod: info.UpdatedAt}) + } + return items +} diff --git a/app/skd/job/sitemap/generate.go b/app/skd/job/sitemap/generate.go new file mode 100644 index 0000000..9bc9aba --- /dev/null +++ b/app/skd/job/sitemap/generate.go @@ -0,0 +1,35 @@ +package sitemap + +import ( + "strings" + "sync" + "videoweb/app/service/configser" + "videoweb/pub/common/log" +) + +var generateMu sync.Mutex + +// GenerateSitemap 生成全站 sitemap 分片及索引文件。 +func GenerateSitemap() { + generateMu.Lock() + defer generateMu.Unlock() + + webAddr := strings.TrimSpace(configser.GetConfigInfo().WebAddr) + if webAddr == "" { + log.Warn("GenerateSitemap: WebAddr is empty, skip") + return + } + + generator := NewSitemapGenerator(webAddr, OutputDir) + if err := generator.Generate(); err != nil { + log.Error("GenerateSitemap failed", log.E(err)) + return + } + if err := writeRobotsTXT(webAddr); err != nil { + log.Error("GenerateSitemap write robots.txt failed", log.E(err)) + return + } + log.Info("GenerateSitemap done", + log.Any("files", len(generator.SitemapFiles)), + log.Any("webAddr", webAddr)) +} diff --git a/app/skd/job/sitemap/job.go b/app/skd/job/sitemap/job.go new file mode 100644 index 0000000..b9fa695 --- /dev/null +++ b/app/skd/job/sitemap/job.go @@ -0,0 +1,35 @@ +package sitemap + +import ( + "os" + "time" + "videoweb/pub/common/log" +) + +func StartSitemapJob(interval time.Duration, outDir string) { + if interval <= 0 { + interval = 6 * time.Hour + } + if outDir == "" { + outDir = OutputDir + } + + go func() { + if mkErr := os.MkdirAll(outDir, 0755); mkErr != nil { + log.Error("StartSitemapJob mkdir failed", log.E(mkErr)) + return + } + if mkErr := os.MkdirAll(PageOutputDir, 0755); mkErr != nil { + log.Error("StartSitemapJob mkdir page dir failed", log.E(mkErr)) + return + } + + GenerateSitemap() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + GenerateSitemap() + } + }() +} diff --git a/app/skd/job/sitemap/sitemap.go b/app/skd/job/sitemap/sitemap.go new file mode 100644 index 0000000..8bcd226 --- /dev/null +++ b/app/skd/job/sitemap/sitemap.go @@ -0,0 +1,291 @@ +package sitemap + +import ( + "encoding/xml" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + stylesheetIndex = `` + "\n" + stylesheetURLSet = `` + "\n" + + defaultMaxURLsPerFile = 40000 + sitemapTimeLayout = "2006-01-02T15:04:05.000Z" + + // OutputDir 分片 sitemap 输出目录(相对 app 进程 cwd)。 + OutputDir = "../h5/template/pages/sitemaps" + // PageOutputDir sitemap 索引输出目录,供 H5 /sitemap.xml 读取 + PageOutputDir = "../h5/template/pages" +) + +type URL struct { + Loc string `xml:"loc"` + LastMod string `xml:"lastmod"` + ChangeFreq string `xml:"changefreq"` + Priority float32 `xml:"priority"` +} + +type Sitemap struct { + XMLName xml.Name `xml:"urlset"` + Xmlns string `xml:"xmlns,attr"` + URLs []URL `xml:"url"` +} + +type SitemapIndex struct { + XMLName xml.Name `xml:"sitemapindex"` + Xmlns string `xml:"xmlns,attr"` + Sitemaps []SitemapIndexEntry `xml:"sitemap"` +} + +type SitemapIndexEntry struct { + Loc string `xml:"loc"` + LastMod string `xml:"lastmod"` +} + +type SitemapGenerator struct { + MaxURLsPerFile int + OutputDir string + WebAddr string + SitemapFiles []string +} + +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 +} + +func (sg *SitemapGenerator) Generate() error { + sg.SitemapFiles = make([]string, 0) + + if err := os.MkdirAll(sg.OutputDir, 0755); err != nil { + return err + } + if err := os.MkdirAll(PageOutputDir, 0755); err != nil { + return err + } + + getURL(sg) + + if err := sg.generateSitemapIndex(); err != nil { + return err + } + return copySitemapIndexToPageRoot() +} + +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() + + if _, err := file.WriteString(xml.Header); err != nil { + return err + } + if _, err := file.WriteString(stylesheetURLSet); err != nil { + return err + } + encoder := xml.NewEncoder(file) + encoder.Indent("", " ") + + if err := encoder.Encode(sitemap); err != nil { + return err + } + + sg.SitemapFiles = append(sg.SitemapFiles, filename) + return nil +} + +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 +} + +// removeObsoleteSitemaps 清掉旧站路径留下的分片,避免索引更新后磁盘上仍能被直接访问。 +func (sg *SitemapGenerator) removeObsoleteSitemaps(baseNames ...string) { + for _, baseName := range baseNames { + baseName = strings.TrimSuffix(strings.TrimSpace(baseName), ".xml") + if baseName == "" { + continue + } + sg.cleanupSitemapShards(baseName, 0) + _ = os.Remove(filepath.Join(sg.OutputDir, baseName+".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)) + } + } +} + +func (sg *SitemapGenerator) generateSitemapIndex() error { + index := SitemapIndex{ + Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9", + Sitemaps: make([]SitemapIndexEntry, 0, len(sg.SitemapFiles)), + } + + now := sitemapNow() + for _, filename := range sg.SitemapFiles { + index.Sitemaps = append(index.Sitemaps, SitemapIndexEntry{ + Loc: fmt.Sprintf("%s/sitemaps/%s", strings.TrimSuffix(sg.WebAddr, "/"), filename), + LastMod: now, + }) + } + + filePath := filepath.Join(sg.OutputDir, "sitemap.xml") + file, err := os.Create(filePath) + if err != nil { + return err + } + defer file.Close() + + if _, err := file.WriteString(xml.Header); err != nil { + return err + } + if _, err := file.WriteString(stylesheetIndex); err != nil { + return err + } + encoder := xml.NewEncoder(file) + encoder.Indent("", " ") + + return encoder.Encode(index) +} + +func copySitemapIndexToPageRoot() error { + src := filepath.Join(OutputDir, "sitemap.xml") + dst := filepath.Join(PageOutputDir, "sitemap.xml") + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0644) +} + +func writeRobotsTXT(webAddr string) error { + body := buildRobotsTXT(webAddr) + if body == "" { + return nil + } + if err := os.MkdirAll(PageOutputDir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(PageOutputDir, "robots.txt"), []byte(body), 0644) +} + +func sitemapNow() string { + return time.Now().UTC().Format(sitemapTimeLayout) +} + +func sitemapLastMod(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(sitemapTimeLayout) +} + +func buildRobotsTXT(webAddr string) string { + base := strings.TrimSuffix(strings.TrimSpace(webAddr), "/") + if base == "" { + return "" + } + // 与线上 H5 robots 一致:搜索和账户页不收录;媒资代理同样不交给爬虫。 + body := "User-agent: *\nAllow: /\n\n" + + "Disallow: /search\n" + + "Disallow: /info/user-center\n" + + "Disallow: /info/deposit\n" + + "Disallow: /info/account-settings\n" + + "Disallow: /info/feedback\n" + + "Disallow: /api/\n" + + "Disallow: /h5/\n" + + "Disallow: /enkey\n" + + "Disallow: /newenkey\n" + + "Disallow: /healthz\n" + + "Disallow: /cdn-cgi/\n\n" + + "Sitemap: " + base + "/sitemap.xml\n" + if host := hostFromWebAddr(base); host != "" { + body += "Host: " + host + "\n" + } + return body +} + +func hostFromWebAddr(webAddr string) string { + webAddr = strings.TrimSpace(webAddr) + webAddr = strings.TrimPrefix(webAddr, "https://") + webAddr = strings.TrimPrefix(webAddr, "http://") + if i := strings.IndexByte(webAddr, '/'); i >= 0 { + webAddr = webAddr[:i] + } + return webAddr +} diff --git a/app/skd/seo/seo.go b/app/skd/seo/seo.go new file mode 100644 index 0000000..e472b4c --- /dev/null +++ b/app/skd/seo/seo.go @@ -0,0 +1,59 @@ +package seo + +import ( + "context" + "sync" + "time" + "videoweb/app/skd/job/sitemap" + "videoweb/pub/common/log" + "videoweb/pub/seo/sitemapdirty" + + "github.com/robfig/cron/v3" +) + +var seoCron *cron.Cron + +func init() { + seoCron = cron.New(cron.WithSeconds()) +} + +var ( + dirtyMu sync.Mutex + // generatedDirtyAt 最近一次 sitemap 生成所对应的内容变更时间 + generatedDirtyAt time.Time +) + +// Start 启动 SEO 静态资源生成(sitemap)。 +// 页面级搜索引擎推送由 web 进程的 seopush 任务负责,不在这里处理。 +func Start() { + generatedDirtyAt = sitemapdirty.LastDirtyAt(context.Background()) + sitemap.StartSitemapJob(6*time.Hour, sitemap.OutputDir) + + // 短剧发布/更新/下架发生在 web 进程,这里检查它留下的变更标记,有变更才重新生成 + _, _ = seoCron.AddFunc("0 */2 * * * ?", func() { + regenerateSitemapIfDirty() + }) + seoCron.Start() + log.Info("seo skd started") +} + +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)) + sitemap.GenerateSitemap() + generatedDirtyAt = dirtyAt +} + +func Stop() { + if seoCron != nil { + seoCron.Stop() + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dce2549 --- /dev/null +++ b/go.mod @@ -0,0 +1,124 @@ +module videoweb + +go 1.26 + +toolchain go1.26.0 + +require ( + github.com/DeanThompson/ginpprof v0.0.0-20201112072838-007b1e56b2e1 + github.com/PuerkitoBio/purell v1.1.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/gosimple/slug v1.15.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.0.68 + 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.1 + 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.46.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/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/alicebob/miniredis/v2 v2.38.0 // 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.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/golang/protobuf v1.5.4 // 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/unidecode v1.0.1 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.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/mailru/easyjson v0.7.6 // 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/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/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/xxjwxc/public v0.0.0-20200603141144-4001846f9957 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yuin/gopher-lua v1.1.1 // 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 + golang.org/x/arch v0.20.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.39.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..dfce67d --- /dev/null +++ b/go.sum @@ -0,0 +1,534 @@ +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.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +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/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +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.3.2/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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +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.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +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.0.68 h1:HM+zMsS0iOwREnRKieB+RmK3Sgthwf1Kftgi3GxIp7U= +github.com/nyaruka/phonenumbers v1.0.68/go.mod h1:sDaTZ/KPX5f8qyV9qN+hIm+4ZBARJrupC6LuhshJq1U= +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/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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg= +github.com/swaggo/swag v1.16.1/go.mod h1:9/LMvHycG3NFHfR6LwvikHv5iFvmPADQ359cKikGxto= +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/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/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/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +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= +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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +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/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.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +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-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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-20210420072515-93ed5bcd2bfe/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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.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.6/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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +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-20200227125254-8fa46927fb4f/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.0-20200615113413-eeeca48fe776/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..9c02280 --- /dev/null +++ b/project.md @@ -0,0 +1,85 @@ +--- +项目名称: 熟果短剧WEB站 +一级部门: 北斗 +二级部门: 瑞升 + +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-1049-sgdj-server.git + +最后审计人: + +最后审计时间: + +告警群: + +备注: + +--- + +# 1. 项目说明 + +- 熟果短剧WEB站 +- app 包含前端API +- web 包含管理后端API + + +# 2. 基础设施与中间件 + +- MongoDB 主从 +- Redis 单节点 +- Elasticsearch 搜索(v7) + + +# 3. 运维部署 + +- Shell脚本部署(scp + supervisorctl) +- Telegram Bot 部署通知 + + +# 4. 风险说明 + +- Redis 热Key风险 + + +# 5. 历史事故(可选) + +- + + +# 6. 交接说明(可选) + +- appid 代码固定:1049 +- ES的索引KEY: websgdjweb_video_index_v1 +- /wbapi/sgdjweb api路径及名称 +- 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..4f87259 --- /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 // 删除一个媒体 + MessageTypePostAdd // 添加一个帖子 + MessageTypePostDel // 删除一个帖子 +) + +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..6e4139c --- /dev/null +++ b/pub/common/common.go @@ -0,0 +1,173 @@ +package common + +import ( + "errors" + "fmt" + "net/http" + "os" + "time" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/ipaddr" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/useragent" + + "strconv" + + "github.com/gin-gonic/gin" +) + +var ( + cryptSecret string + env consts.RunMod + //routineCount int64 +) + +// 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() + 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 = ua.SysType + } + if devType != "" { + devType = ua.DevType + } + log.Warn("Error:", + log.Any("UID", strconv.FormatInt(id, 10)), + log.Any("IP", ipaddr.ResolveClientIP(c)), + 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() + 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 = ua.SysType + } + if devType != "" { + devType = ua.DevType + } + log.Warn("Error:", + log.Any("UID", strconv.FormatInt(id, 10)), + log.Any("IP", ipaddr.ResolveClientIP(c)), + 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 = ipaddr.ResolveClientIP(ctx) + } + } + 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..77cb7b7 --- /dev/null +++ b/pub/common/enum/consts/consts.go @@ -0,0 +1,128 @@ +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" + CtxSystemName = "SYSTEM_NAME" + CtxSystemVersion = "SYSTEM_VERSION" + 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 +} diff --git a/pub/common/enum/redisconst/const.go b/pub/common/enum/redisconst/const.go new file mode 100644 index 0000000..98f2dda --- /dev/null +++ b/pub/common/enum/redisconst/const.go @@ -0,0 +1,211 @@ +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 + appPlayletHomeCacheFmt = "app:playlet:home:%d:%s" + appPlayletHomeCacheKeyFmt = "app:playlet:home:%d" + appPlayletHomeCacheVersionFmt = "app:playlet:home:version:%d" + AppPlayletHomeCacheExpire = 10 * time.Minute + AppPlayletForwardHeatExpire = 3 * time.Second + + //禁止登陆设置 + 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 + + // 访问 IP 去重统计:每个 appId 每天一个 SET + ipStatKey = "ipstat:%d:%s" + IpStatExpire = 49 * time.Hour + + // m3u8 / enkey 防盗链 + m3u8DayRateLimitIp = "m3u8dayrate:ip:%s:%s" + m3u8DayRateLimitUser = "m3u8dayrate:user:%d:%s" + m3u8DailyMediaIpFmt = "m3u8:daily:media:ip:%s:%d:%s" + m3u8DailyMediaUserFmt = "m3u8:daily:media:user:%d:%d:%s" + m3u8UserVipExpireFmt = "m3u8:user:vipExpire:%d" + M3U8DayRateLimitMax = int64(500) // 非有效会员/未登录:按 IP 日限 + M3U8DayRateLimitVipMax = int64(1000) // 有效会员:按用户日限 + M3U8UserVipExpireCache = 5 * time.Minute +) + +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) +} + +func MediaM3U8Key(name string) string { + return fmt.Sprintf(mediaM3U8Fmt, name) +} + +func AppPlayletHomeCacheKey() string { + return fmt.Sprintf(appPlayletHomeCacheKeyFmt, pubdef.AppId) +} + +func AppPlayletHomeCacheVersionKey() string { + return fmt.Sprintf(appPlayletHomeCacheVersionFmt, pubdef.AppId) +} + +func AppPlayletHomeCacheDataKey(version string) string { + return fmt.Sprintf(appPlayletHomeCacheFmt, pubdef.AppId, version) +} + +func AppPlayletForwardHeatKey(operator string, playletId int64) string { + operator = strings.ReplaceAll(operator, ":", "-") + return fmt.Sprintf("app:playlet:forward:heat:%s:%d", operator, playletId) +} + +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) +} + +func IpStatKey(appId int64, date string) string { + return fmt.Sprintf(ipStatKey, appId, date) +} + +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) +} + +func M3U8DayRateLimitUserKey(userId int64) string { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.Local + } + day := time.Now().In(loc).Format("20060102") + return fmt.Sprintf(m3u8DayRateLimitUser, userId, day) +} + +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 +} + +func normalizeM3U8Ip(ip string) string { + return strings.ReplaceAll(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) +} + +func M3U8DailyMediaUserKey(userId int64, 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(m3u8DailyMediaUserFmt, userId, mediaId, day) +} + +func M3U8UserVipExpireKey(userId int64) string { + return fmt.Sprintf(m3u8UserVipExpireFmt, userId) +} 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..6af873c --- /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 // + PostsNewArgsInvalid // 帖子参数错误 + PostDupi // 帖子重复发送 + PostNotExisted // 帖子不存在 + PostLikeOnLiked // 重复点赞帖子 + PostUndoLikeOnUnliked // 尚未点赞,不可取消点赞 + PostFavOnFaved // 重复 收藏 + PostUndoFavOnUnfaved // 尚未收藏,不可取消收藏 + PostDisLikeOnDisLiked // 重复屏蔽帖子 + PostUndoDisLikeOnUnliked // 尚未屏蔽,不可取消屏蔽 + UserDoNotForSelf // 用户不能对自己进行此项操作 + UserWatchDupi // 不可重复关注同一用户 + UserNoWatch // 没有关注该用户,不可取消关注 + PostRepleNewArgsInvalid // 帖子 回复 参数错误 + PostReplyNotExisted // 评论不存在 + PostReplyAlready // 评论不存在 + NoWatchableUsers // 没有可以关注的用户池 + PostReplyLikeOnLiked // 重复点赞帖子 + PostReplyUndoLikeOnUnliked // 尚未点赞,不可取消点赞 + PostPriceInvalid // 帖子定价异常,不可购买 + PostIsBuyed // 帖子已购买,不可重复购买 + NoBalance // 余额不足 + BalanceErr // 编辑余额错误 + NoAccount // 没账户 + AccountDisabled // 账户已被封禁 + BuySelf // 自己帖子,不用购买 + RedisError // 服务器繁忙 + Button // 落地页按钮 + ReplyTimeShort // 回复间隔时间太短 + OperateLog // 日志出错 + RoleErr // 角色权限不够 + DocExists // 查找的文档已存在 + PostUnknownError // 未知错误 + 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..71625f2 --- /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 +} 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..2191535 --- /dev/null +++ b/pub/common/ipaddr/ip.go @@ -0,0 +1,438 @@ +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 统计。 +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("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 +} + +func shouldTrustForwardedHeaders(remoteIP, clientIP string) bool { + if isPrivateOrLoopbackIP(remoteIP) || isLocalMachineIP(remoteIP) { + return true + } + return clientIP != "" && remoteIP != "" && clientIP != remoteIP +} + +// isLocalMachineIP 判断对端是否本机网卡地址。 +// H5 与 1043 一样用本机公网 IP 回源 app,此时对端不是 127.0.0.1,但仍应信任 X-Real-IP。 +func isLocalMachineIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + addrs, err := net.InterfaceAddrs() + if err != nil { + return false + } + for _, addr := range addrs { + var candidate net.IP + switch v := addr.(type) { + case *net.IPNet: + candidate = v.IP + case *net.IPAddr: + candidate = v.IP + } + if candidate != nil && candidate.Equal(ip) { + return true + } + } + return false +} + +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..c471ec7 --- /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 返回短剧原片使用的新 M3U8 解密密钥。 +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..a4c060a --- /dev/null +++ b/pub/common/m3u8/transformer.go @@ -0,0 +1,153 @@ +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 前缀。 +// 短剧水印未完成时仍播放原片,原片路径如 k12/...,需要保留原路径。 +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..ab6ae70 --- /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 len(relativePath) == 0 { + 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..afc1b35 --- /dev/null +++ b/pub/common/playletcache/cache.go @@ -0,0 +1,20 @@ +package playletcache + +import ( + "videoweb/pub/common/enum/redisconst" + "videoweb/pub/common/log" + "videoweb/pub/redis" +) + +// ClearHomeCache 清除app首页短剧缓存 +func ClearHomeCache() { + if redis.Client() == nil { + return + } + if err := redis.Del(redisconst.AppPlayletHomeCacheKey()); err != nil { + log.Warn("清除app首页短剧缓存失败", log.E(err)) + } + if err := redis.Client().Incr(redisconst.AppPlayletHomeCacheVersionKey()).Err(); err != nil { + log.Warn("更新app首页短剧缓存版本失败", log.E(err)) + } +} 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..078c6e0 --- /dev/null +++ b/pub/common/response/base_page.go @@ -0,0 +1,11 @@ +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..c3da0b7 --- /dev/null +++ b/pub/common/srvdef/codedef.go @@ -0,0 +1,338 @@ +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 ( + // ErrESSearchError ES搜索失败,单独用于搜索接口,避免和通用ES操作错误混淆。 + ErrESSearchError CodeType = DBLevel + 100 +) +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 // 视频不需要支付 + PostNoNeed2Pay // 帖子不需要支付 + 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 // 文件上传失败 + PosoStatusFaild // 帖子状态错误 + 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 //系统配置错误 + ComicsCateNotExist // 漫画分类不存在 + ComicsTagNotExist // 漫画标签不存在 + ComicsTopicNotExist // 漫画主题不存在 + ErrComicsChapterNotExist // 漫画章节不存在 + ComicsNotExist // 漫画不存在 + ComicsCVNotExist // 漫画CV不存在 + ComicsMediaNotExist // 漫画视频不存在 + ComicsRankNotExist // 排行榜不存在 + VomicsTopicNotExist // 有声漫不存在 + ComicsIsBlack // 漫画在黑名单中 + CommentTextIsEmpty // 评论内容为空 + ComicsIsVip // 漫画需要会员 + ComicsIsNeedPay // 漫画需要付费 + ComicsChapterExist // 漫画章节已存在 + ObjectNotExist // 数据不存在 + GameWtdrErroTip3 // 请填写银行名字或升级App版本 + UserDoTaskErr // 用户任务出错 + GetPrizeFail // 获取奖品失败 + NovelIsNeedPay // 小说需要付费 + NovelIsVip // 小说需要vip + LotteryActivityNotEnable // 抽奖活动没有启用 + CurrTimeNotActivity // 当前时间没有活动 + AIOrderStripsCountLimit // 今天AI脱衣提交次数达到上限 + NotWtdrChannel // 没有匹配到提现通过 + ExcelExportErr // 文件导出错误 + ComicsNotJoinActivity // 这本漫画没有参与活动 + 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 // 不是会员禁止发布 + PublistVIPExpired // 会员已过期禁止发布 + PublistContentErrDenied // 包含敏感词汇 + PostRepeatBuy // 帖子重复购买 + UserNotEnoughMoney // 用户的钻石数量不足 + PostStatusError // 订单状态错误 + PostNotAllowComplaint // 不允许举报 + UserNotEnoughScore // 用户的钻石数量不足 + ErrUserAccExist // 账号已存在 + UserAccountNotBind // 账号未注册 + VerifyOKPostCountLimit //上架的帖子数量达到限制 + ErrUserBeBanned //ErrUserBeBanned 用户已经禁言 1004 + ErrCustomerBanned //ErrCustomerBanned 客服已休息 1005 + ErrVersionUpdate //ErrVersionUpdate 版本强制更新 1006 + DarkAlleyNotExist // 暗巷攻略信息不存在 +) diff --git a/pub/common/srvdef/msgdef.go b/pub/common/srvdef/msgdef.go new file mode 100644 index 0000000..9c33923 --- /dev/null +++ b/pub/common/srvdef/msgdef.go @@ -0,0 +1,62 @@ +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", + ErrESSearchError: "elastic search 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", + PublistNotVIPPermissionDenied: "vip required to publish", + PublistVIPExpired: "vip expired", + DarkAlleyNotExist: "dark alley does not exist", + 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..000eec6 --- /dev/null +++ b/pub/common/srvdef/tipdef.go @@ -0,0 +1,167 @@ +package srvdef + +var codeAndTip = map[CodeType]string{ + Success: "成功", + Failure: "失敗", + ErrCardNotExists: "沒有會員卡信息", + ErrMadouCoinNotExists: "沒有金幣信息", + ErrGetRechargeType: "充值方式獲取錯誤", + ErrRchgOrderNotExist: "充值訂單不存在", + ErrBuyVIPCardFail: "購買會員卡失敗", + ErrOperationTooFrequently: "用戶操作太頻繁", + PublistNotVIPPermissionDenied: "请开通VIP后发布", + PublistVIPExpired: "VIP已过期,请续费后发布", + DarkAlleyNotExist: "信息不存在", + 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: "视频不需要支付", + PostNoNeed2Pay: "帖子不需要支付", + 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操作错误", + ErrESSearchError: "搜索服务暂不可用", + 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: "没有卡券信息", + VerifyOKPostCountLimit: "上架的帖子数量达到限制", + CouponCanNotUse: "卡券无法使用", + LotteryActivityNotEnable: "抽奖活动没有启用", + CurrTimeNotActivity: "当前时间没有活动", + AIOrderStripsCountLimit: "今天AI脱衣提交次数达到上限", + NotWtdrChannel: "没有匹配到提现通过", + ComicsNotJoinActivity: "这本漫画没有参与活动", + 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/statcenter/registration_policy.go b/pub/common/statcenter/registration_policy.go new file mode 100644 index 0000000..bd2beb2 --- /dev/null +++ b/pub/common/statcenter/registration_policy.go @@ -0,0 +1,5 @@ +package statcenter + +// IOSRegistrationDistrictCode 原生 iOS 商店包用户的固定渠道码。 +// 未配置时不覆盖用户原 DistrictCode。 +const IOSRegistrationDistrictCode = "" diff --git a/pub/common/statcenter/stat.go b/pub/common/statcenter/stat.go new file mode 100644 index 0000000..64bd6e9 --- /dev/null +++ b/pub/common/statcenter/stat.go @@ -0,0 +1,227 @@ +package statcenter + +import ( + "time" + "videoweb/pub/pubdef" + + "github.com/shopspring/decimal" +) + +type KFKTopic string + +const ( + STAT_APPID int32 = pubdef.AppId + USER_REG KFKTopic = "user_register" // 用户注册 + USER_RECH KFKTopic = "user_recharge" // 用户充值(成功) + USER_RECH_ALL KFKTopic = "user_recharge_all" // 全部充值订单 + USER_ACCE KFKTopic = "user_access" // 用户访问 + USER_BINDING KFKTopic = "user_binding" // 用户绑定(邮箱/正式注册) + USER_INVITE KFKTopic = "user_invite" // 用户邀请绑定 + VipJob KFKTopic = "vip" // VIP 购买(兼容旧任务) + RechargeAll KFKTopic = "rechargeAll" // 全部充值(兼容旧任务名) + RegisterLightJob KFKTopic = "registerLight" // H5 轻量注册 + ConsumeRecordJob KFKTopic = "consume_record" // 产品消费流水 + CardSellJob KFKTopic = "card_sell" // 会员卡销售流水 + AI_SELL_JOB KFKTopic = "ai_sell" // AI 消费(本项目无此业务) +) + +const ( + StatVipCard = iota // 会员卡 + StatValueAddSer // 增值/其他消费 + StatVideoUnlock // 视频解锁 +) + +const ( + CurrencyTypeGold = iota // 金币 + CurrencyTypeCash // 现金 +) + +// StatcenterSyncReq 统计中心增量同步请求 +type StatcenterSyncReq struct { + Job string `json:"job" bson:"job"` // 同步任务类型 + UserId uint64 `json:"userId" bson:"userId"` // 用户(注册) + PlatformId string `json:"platformId" bson:"platformId"` // 原始平台Id(访问/流水) + SuccessTime time.Time `json:"successTime"` // 游标时间(购买会员、提现、充值) + MaxSize int64 `json:"maxSize" bson:"maxSize"` // 最大条数 +} + +// StatcenterSyncResp 统计中心增量同步响应 +type StatcenterSyncResp struct { + Code int `json:"code"` // 200正常 其他异常 + Msg string `json:"msg"` // 错误消息 + Job string `json:"job" bson:"job"` // 类型 + VisitList []UserVisitMsg `json:"visitList"` // 日活记录(旧字段,兼容) + AccessList []UserVisitMsg `json:"accessList"` // 日活记录 + RegisterList []UserRegisterMsg `json:"registerList"` // 注册记录 + BindingList []UserBindingMsg `json:"bindingList"` // 用户绑定记录 + InviteList []UserInviteBindMsg `json:"inviteList"` // 邀请绑定 + ConsumeList []ConsumeRecordMsg `json:"consumeList"` // 消费流水 + RechargeList []UserRechargeMsg `json:"rechargeList"` // 充值记录 + AllRechargeList []UserRechargeMsg `json:"allRechargeList"` // 全部支付订单 + VipList []UserBuyVIPMsg `json:"buyVipList"` // VIP购买记录 + CardSellList []CardSellMsg `json:"cardSellList"` // 会员卡销售流水 + AiSellList []AiSellMsg `json:"aiSellList"` // AI销售流水 +} + +// 用户访问消息 +type UserVisitMsg struct { + UserId uint64 `json:"userId" bson:"userId"` // 用户ID + PlatformId string `json:"platformId" bson:"platformId"` // 原始平台流水Id + AppId int32 `json:"appId" bson:"appId"` // appID + SysType string `json:"sysType" bson:"sysType"` // 操作系统类型 安卓 IOS + DevType string `json:"devType,omitempty" bson:"devType"` // 设备型号 + IP string `json:"ip,omitempty" bson:"ip"` // IP + Version string `json:"version,omitempty" bson:"version"` // APP版本号 + DevID string `json:"devID,omitempty" bson:"devID"` // 设备ID + VisitAt time.Time `json:"visitAt,omitempty" bson:"visitAt"` // 访问时间 + IsDirect bool `json:"isDirect,omitempty"` // true:是直推用户 + DistrictCode string `json:"districtCode,omitempty"` // 渠道码 + RegisterTime time.Time `json:"registerTime,omitempty"` // 用户注册时间 +} + +// 用户注册消息 +type UserRegisterMsg struct { + UserId uint64 `json:"userId" bson:"userId" binding:"required"` // 用户ID + AppId int32 `json:"appId" bson:"appId" binding:"required"` // appID + SysType string `json:"sysType" bson:"sysType" binding:"required"` // 操作系统类型 安卓 IOS + PlatformId string `json:"platformId" bson:"platformId"` // 原始平台流水Id + DevType string `json:"devType" bson:"devType"` // 设备类型 + Mobile string `json:"mobile" bson:"mobile"` // 手机号 + IP string `json:"ip,omitempty" bson:"ip"` // IP + Name string `json:"name" bson:"name"` // 名称 + IsDirect bool `json:"isDirect" bson:"isDirect" binding:"required"` // true:是直推用户 + DistrictCode string `json:"districtCode" bson:"districtCode" ` // 渠道码 + PromSeqe string `json:"promSeqe,omitempty" bson:"promSeqe"` // 全名代理 + PromCode string `json:"promCode,omitempty" bson:"promCode"` // 推广序列 + PUC string `json:"puc" bson:"puc"` // 应用中心推广 + RegisterTime time.Time `json:"registerTime,omitempty" bson:"registerTime" binding:"required"` // 注册时间 +} + +// UserBindingMsg 用户绑定(本项目对应邮箱正式注册) +type UserBindingMsg struct { + UserId uint64 `json:"userId"` // 用户ID + AppId int32 `json:"appId"` // appID + PlatformId string `json:"platformId"` // 原始平台流水Id + SysType string `json:"sysType"` // 操作系统类型 + DevType string `json:"devType"` // 设备类型 + Mobile string `json:"mobile"` // 手机号/绑定账号(本项目填邮箱) + BindingTime *time.Time `json:"bindingTime"` // 绑定时间 +} + +// UserInviteBindMsg 邀请绑定 +type UserInviteBindMsg struct { + UserId uint64 `json:"userId" bson:"userId" binding:"required"` + AppId int32 `json:"appId" bson:"appId" binding:"required"` + ParentPromCode string `json:"parentPromCode,omitempty" bson:"promSeqe"` + InviteTime time.Time `json:"inviteTime,omitempty" bson:"inviteTime" binding:"required"` +} + +// ConsumeRecordMsg 消费流水 +type ConsumeRecordMsg struct { + AppID int32 `json:"appID" bson:"appID"` + UID uint64 `json:"uid" bson:"uid"` + Type int `json:"type" bson:"type"` // 消费类型 + CurrencyType int `json:"currencyType" bson:"currencyType"` // 0金币 1人民币 + Money decimal.Decimal `json:"money" bson:"money"` // 人民币 元 + Amount decimal.Decimal `json:"amount" bson:"amount"` // 金币 + Uniq string `json:"uniq" bson:"uniq"` + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` +} + +// AiSellMsg AI 销售流水 +type AiSellMsg struct { + AppID int32 `json:"appID" bson:"appID"` + UID uint64 `json:"uid" bson:"uid"` + UniqID string `json:"uniqID" bson:"uniqID"` + Amount int64 `json:"amount" bson:"amount"` + TranType string `json:"tranType" bson:"tranType"` + SysType string `json:"sysType" bson:"sysType"` + DevType string `json:"devType" bson:"devType"` + CurrencyType string `json:"currencyType" bson:"currencyType"` + TranCreatedAt time.Time `json:"tranCreatedAt" bson:"tranCreatedAt"` + IsRepurchase string `json:"isRepurchase" bson:"isRepurchase"` +} + +// CardSellMsg 会员卡销售流水 +type CardSellMsg struct { + AppID int32 `json:"appID" bson:"appID"` + UID uint64 `json:"uid" bson:"uid"` + UniqID string `json:"uniqID" bson:"uniqID"` + Amount int64 `json:"amount" bson:"amount"` + TranTypeInt int64 `json:"tranTypeInt" bson:"tranTypeInt"` + TranType string `json:"tranType" bson:"tranType"` + SysType string `json:"sysType" bson:"sysType"` + CurrencyType int `json:"currencyType" bson:"currencyType"` // 0金币 1现金 + TranCreatedAt time.Time `json:"tranCreatedAt" bson:"tranCreatedAt"` + Product Product `json:"product" bson:"product"` +} + +// Product 售卡产品信息 +type Product struct { + ID string `json:"id"` + Name string `json:"name"` + DiscountedPrice int64 `json:"discountedPrice"` +} + +// 用户充值消息 +type UserRechargeMsg struct { + UserId uint64 `json:"userId" bson:"userId" binding:"required"` + AppId int32 `json:"appId" bson:"appId" binding:"required"` + PlatformId string `json:"platformId" bson:"platformId"` + SysType string `json:"sysType" bson:"sysType" binding:"required"` + DevType string `json:"devType" bson:"devType"` + ChannelName string `json:"channelName" bson:"channelName"` + CID string `json:"cid" bson:"cid"` + Type string `bson:"type" json:"type"` + OrderId string `json:"orderId" bson:"orderId" binding:"required"` + OID string `json:"oid" bson:"oid"` + Money int64 `json:"money" bson:"money" binding:"required"` + PayMoney int64 `json:"payMoney" bson:"payMoney" binding:"required"` + Status int `json:"status" bson:"status"` + Rate string `json:"rate"` + SuccessAt time.Time `json:"successAt,omitempty" bson:"successAt"` + PaymentAt time.Time `json:"paymentAt"` + ProductType int `json:"productType" bson:"productType"` + ChanShareMod int `json:"chanShareMod" bson:"chanShareMod"` + IsNewOrOldUser bool `json:"isNewOrOldUser" bson:"isNewOrOldUser"` + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + DistrictCode string `json:"districtCode" bson:"districtCode"` + RegisterTime time.Time `json:"registerTime,omitempty" bson:"registerTime"` +} + +type BuyVIPType int64 + +const ( + BuyVIPTypeMoney BuyVIPType = 0 + iota // 现金 + BuyVIPTypeGold // 金币 +) + +// 用户购买VIP +type UserBuyVIPMsg struct { + AppId int32 `json:"appId" bson:"appId"` + UID uint64 `json:"uid" bson:"uid"` + PlatformId string `json:"platformId" bson:"platformId"` + Name string `json:"name" bson:"name"` + Money int64 `json:"money" bson:"money"` + Amount int64 `json:"amount" bson:"amount"` + BuyVIPType BuyVIPType `json:"buyVIPType" bson:"buyVIPType"` + OID string `json:"oid" bson:"oid"` + DeviceType string `json:"deviceType" bson:"deviceType"` + Status int `json:"status" bson:"status"` + SuccessAt time.Time `json:"successTime"` + IsDirect bool `json:"isDirect" bson:"isDirect"` + DistrictCode string `json:"districtCode" bson:"districtCode"` + PromCode string `json:"promCode" bson:"promCode"` +} + +// MapOrderStatusToStat 将本项目订单状态映射为统计中心约定:2失败 3成功 +func MapOrderStatusToStat(status pubdef.VipOrderStatus) int { + switch status { + case pubdef.VipOrderSuccess: + return 3 + case pubdef.VipOrderFailed: + return 2 + default: + return int(status) + } +} 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..17bc556 --- /dev/null +++ b/pub/common/useragent/ua.go @@ -0,0 +1,194 @@ +package useragent + +import ( + "context" + "net/url" + "strings" + "videoweb/pub/common/enum/consts" + "videoweb/pub/common/log" + + "github.com/gin-gonic/gin" +) + +type UA struct { + UserAgent string + DevID string + DevType string + SysType string + Ver string + BuildID string + SystemName string + SystemVersion string + DeviceBrand string + DeviceModel string + SessionID string + SDKCommon *SDKCommonFields // SDK 公共参数独立保存,不能与原业务 UA 字段混用。 + RequestPath string // 仅用于定位埋点参数缺失的业务接口。 +} + +func IsH5(sysType string) bool { + if sysType == consts.H5IOS || sysType == consts.H5Android || sysType == consts.H5PC || sysType == consts.H5 || sysType == consts.Light { + return true + } + return false +} + +func (s UA) IsH5() bool { + return IsH5(s.SysType) +} + +// FromContext 读取当前请求已经解析好的 UA。下单要在请求结束前把这份值存进订单。 +func FromContext(ctx context.Context) UA { + if ctx == nil { + return UA{} + } + if c, ok := ctx.(*gin.Context); ok { + if value, exists := c.Get(consts.CtxUA); exists { + if ua, ok := value.(UA); ok { + return ua + } + } + } + if value := ctx.Value(consts.CtxUA); value != nil { + if ua, ok := value.(UA); ok { + return ua + } + } + return UA{} +} + +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)) + } + ua.SessionID = c.GetHeader("sid") + userAgent := c.GetHeader("X-User-Agent") // 兼容 H5 UserAgent + if userAgent == "" { + userAgent = c.Request.UserAgent() + } + c.Set(consts.CtxUAH5, c.Request.UserAgent()) + if userAgent != "" { + 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 { + segment := strings.TrimSpace(v) + if segment == "" { + continue + } + vss := strings.SplitN(segment, "=", 2) + if len(vss) < 2 { + continue + } + key := strings.TrimSpace(vss[0]) + value := strings.TrimSpace(vss[1]) + lowerKey := strings.ToLower(key) + switch lowerKey { + case "devid": + ua.DevID = value + c.Set(consts.CtxDevId, ua.DevID) + case "devtype": + ua.DevType = value + c.Set(consts.CtxDevType, ua.DevType) + case "systype": + inputSysType := strings.ToLower(strings.TrimSpace(value)) + switch { + case strings.Contains(inputSysType, "h5_android"): + ua.SysType = consts.H5Android + case strings.Contains(inputSysType, "h5_ios"): + ua.SysType = consts.H5IOS + case strings.Contains(inputSysType, "h5_pc"): + ua.SysType = consts.H5PC + case strings.Contains(inputSysType, "android"): + ua.SysType = consts.Android + case strings.Contains(inputSysType, "ios"): + ua.SysType = consts.IOS + case strings.Contains(inputSysType, "h5"): + ua.SysType = consts.H5 + case strings.Contains(inputSysType, "pc"): + ua.SysType = consts.PC + case strings.Contains(inputSysType, "light"): + ua.SysType = consts.Light + } + c.Set(consts.CtxSysType, ua.SysType) + case "ver": + ua.Ver = value + c.Set(consts.CtxVersion, ua.Ver) + case "buildid": + ua.BuildID = value + c.Set(consts.CtxBuildId, ua.BuildID) + case "system_name", "system-name", "systemname": + if ua.SystemName == "" { + ua.SystemName = value + } + case "system_version", "system-version", "systemversion": + if ua.SystemVersion == "" { + ua.SystemVersion = value + } + case "device_brand", "device-brand", "devicebrand": + if ua.DeviceBrand == "" { + ua.DeviceBrand = value + } + case "device_model", "device-model", "devicemodel": + if ua.DeviceModel == "" { + ua.DeviceModel = value + } + case "sid": + if ua.SessionID == "" { + ua.SessionID = value + } + default: + log.Warn("user-agent unknown", log.Any("ua", userAgent), log.Any("segment", segment)) + } + } + } else { + log.Warn("user-agent empty") + } + ua.UserAgent = uastr + if ua.DeviceBrand == "" { + brand := strings.TrimSpace(c.GetHeader("device_brand")) + if brand == "" { + brand = strings.TrimSpace(c.GetHeader("device-brand")) + } + ua.DeviceBrand = brand + } + if ua.DeviceModel == "" { + model := strings.TrimSpace(c.GetHeader("device_model")) + if model == "" { + model = strings.TrimSpace(c.GetHeader("device-model")) + } + ua.DeviceModel = model + } + if ua.SystemName == "" { + name := strings.TrimSpace(c.GetHeader("system_name")) + if name == "" { + name = strings.TrimSpace(c.GetHeader("system-name")) + } + ua.SystemName = name + } + if ua.SystemVersion == "" { + version := strings.TrimSpace(c.GetHeader("system_version")) + if version == "" { + version = strings.TrimSpace(c.GetHeader("system-version")) + } + ua.SystemVersion = version + } + if ua.SystemName == "" { + ua.SystemName = ua.SysType + } + if ua.SystemVersion == "" { + ua.SystemVersion = ua.Ver + } + c.Set(consts.CtxSystemName, ua.SystemName) + c.Set(consts.CtxSystemVersion, ua.SystemVersion) + 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..6515214 --- /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)) +// } 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..ae11c8c --- /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 + } +} 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..9ccdbb7 --- /dev/null +++ b/pub/common/utils/path.go @@ -0,0 +1,89 @@ +package utils + +import ( + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/PuerkitoBio/purell" +) + +// PathSegment 路由路径段:有 slug 用 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) +} + +// 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 +} + +func JoinUrlPathV2(host string, uri string) string { + return path.Join(host, uri) +} 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..027de31 --- /dev/null +++ b/pub/common/utils/slug_source.go @@ -0,0 +1,271 @@ +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{ + "中文字幕", "中文翻譯", "中文翻译", "无码流出", "無碼流出", + "无码破解", "無碼破解", "无修正", "無修正", "完整版", "抢先版", + "高清版", "字幕版", "HDYS", "魔改电视剧", "魔改电影", "HDDJ", "91DJ", + "AI魔改", +} + +// 技术规格、版本标记,单独出现在标题开头时去掉。 +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..5c3976c --- /dev/null +++ b/pub/common/utils/strings.go @@ -0,0 +1,129 @@ +package utils + +import ( + "encoding/json" + "math/rand/v2" + "strings" + "videoweb/pub/common/netutil" + + "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: "否", +} + +// ApplyImgCdn 尝试解析节点 Content JSON,若含 "img" 字段则对其拼接 CDN 后重新序列化。 +// 不符合 {"img": "..."} 结构的 Content 原样返回。 +func ApplyImgCdn(cdn, content string) string { + if content == "" || cdn == "" { + return content + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(content), &m); err != nil { + return content // 非 JSON 对象,原样返回 + } + imgVal, ok := m["img"] + if !ok { + return content // 无 img 字段,原样返回 + } + imgStr, ok := imgVal.(string) + if !ok || imgStr == "" { + return content + } + m["img"] = netutil.MergeURL(cdn, imgStr) + b, err := json.Marshal(m) + if err != nil { + return content + } + return string(b) +} + +// 截取指定长度的字符串 +func GetPrefix(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) +} + +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 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..9167454 --- /dev/null +++ b/pub/common/utils/time.go @@ -0,0 +1,286 @@ +package utils + +import ( + "fmt" + "time" + "videoweb/pub/pubdef" +) + +var ( + LocBJ, _ = time.LoadLocation("Asia/Shanghai") +) + +func init() { + time.Local = LocBJ +} + +func Now() time.Time { + return time.Now().Local() +} + +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(time.Local).Date() + return time.Date(year, month, 1, 0, 0, 0, 0, t.Location()) +} + +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 { + bt := BeginOfTime(t) + 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()) +} diff --git a/pub/common/utils/time_test.go b/pub/common/utils/time_test.go new file mode 100644 index 0000000..dae7c5b --- /dev/null +++ b/pub/common/utils/time_test.go @@ -0,0 +1,130 @@ +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_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") + } + } +} + +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..48d4e3b --- /dev/null +++ b/pub/common/v10/validate.go @@ -0,0 +1,178 @@ +package v10 + +import ( + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "github.com/nyaruka/phonenumbers" + + "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() + } +} + +// 参数解析 例如:3 +func ParamIsNumber(str string) bool { + reg := regexp.MustCompile(`^(\d+)$`) + return reg.MatchString(str) +} 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..50da23e --- /dev/null +++ b/pub/dataReport/handle.go @@ -0,0 +1,266 @@ +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(eventId, event string, districtCode string, ua useragent.UA, uid, ip string) CommonField { + return NewCommonFieldAt(eventId, event, districtCode, ua, uid, ip, time.Now().Unix()) +} + +func NewCommonFieldAt(eventId, event string, districtCode string, ua useragent.UA, uid, ip string, clientTS int64) CommonField { + channel := strings.TrimSpace(districtCode) + if eventId == "" { + eventId = GenerateGlobalEventID() + } + // 五项公共字段和 app_version 使用当前请求中的 SDK 原值。 + // device_id 只取 SDK,不用业务 DevID 补,避免和客户端埋点不是同一个编号。 + // device 为空时才用本次请求的 SysType 映射成 Android / iOS / PC。 + 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{} + } + sid := strings.TrimSpace(sdk.SID) + if sid == "" { + sid = strings.TrimSpace(ua.SessionID) + } + return CommonField{ + UID: uid, + EventID: eventId, + Event: event, + Channel: channel, + AppID: pubdef.TrackerAppId(), + AppVersion: sdk.AppVersion, + ClientTS: fmt.Sprintf("%v", clientTS), + Device: reportDevice(sdk.Device, ua.SysType, ua.SystemName), + DeviceID: sdk.DeviceID, + DeviceFingerprint: sdk.DeviceFingerprint, + FPVersion: sdk.FPVersion, + UserAgent: strings.TrimSpace(ua.UserAgent), + DeviceBrand: strings.TrimSpace(ua.DeviceBrand), + DeviceModel: strings.TrimSpace(ua.DeviceModel), + SystemName: strings.TrimSpace(ua.SystemName), + SystemVersion: strings.TrimSpace(ua.SystemVersion), + SID: sid, + IP: ip, + } +} + +// reportDevice 优先使用 SDK 的设备类型。SDK 没给时,用本次请求的系统类型映射。 +func reportDevice(sdkDevice, sysType, systemName string) string { + if device := strings.TrimSpace(sdkDevice); device != "" { + return device + } + text := strings.ToLower(strings.TrimSpace(sysType) + " " + strings.TrimSpace(systemName)) + // "macos" 含 "ios",先归一化,避免 Mac 被判成 iOS。 + text = strings.ReplaceAll(text, "macos", "mac") + switch { + case strings.Contains(text, "ios") || strings.Contains(text, "iphone") || strings.Contains(text, "ipad"): + return "iOS" + case strings.Contains(text, "android"): + return "Android" + case strings.Contains(text, "pc") || strings.Contains(text, "windows") || strings.Contains(text, "mac") || strings.Contains(text, "linux") || strings.Contains(text, "h5"): + return "PC" + default: + return "" + } +} + +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..19dad4d --- /dev/null +++ b/pub/dataReport/type.go @@ -0,0 +1,223 @@ +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" + 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/esmanager/client.go b/pub/esmanager/client.go new file mode 100644 index 0000000..8fe6ead --- /dev/null +++ b/pub/esmanager/client.go @@ -0,0 +1,192 @@ +package esmanager + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "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 { + existsRes, err := m.client.Indices.Exists([]string{indexName}) + if err != nil { + return err + } + defer existsRes.Body.Close() + if existsRes.StatusCode == http.StatusOK { + return nil + } + if existsRes.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(existsRes.Body) + return errors.New(fmt.Sprintf("%+v body:%s", existsRes, string(body))) + } + res, err := m.client.Indices.Create(indexName, func(request *esapi.IndicesCreateRequest) { + request.Body = marshalSearchM(setting) + }) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode == http.StatusOK || res.StatusCode == http.StatusCreated { + return nil + } + body, _ := io.ReadAll(res.Body) + if res.StatusCode == http.StatusBadRequest && strings.Contains(string(body), "resource_already_exists_exception") { + return nil + } + return errors.New(fmt.Sprintf("%+v body:%s", res, string(body))) +} + +// 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() + if res.StatusCode != http.StatusOK { + return errors.New(fmt.Sprintf("%+v", res)) + } + 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()), + m.client.Bulk.WithRefresh("true"), + ) + 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)) + } + 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..186df25 --- /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 +} diff --git a/pub/esmanager/manager.go b/pub/esmanager/manager.go new file mode 100644 index 0000000..9eb2cf8 --- /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 +} 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/aicommentrecord/record.go b/pub/models/aicommentrecord/record.go new file mode 100644 index 0000000..ff449a1 --- /dev/null +++ b/pub/models/aicommentrecord/record.go @@ -0,0 +1,224 @@ +package aicommentrecord + +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.AiCommentRecord) + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: FN.AiCommentRecord.Key, Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{FN.AiCommentRecord.Key: bson.M{"$exists": true}})}, + {Keys: bson.D{{Key: FN.AiCommentRecord.Vid, Value: 1}, {Key: FN.AiCommentRecord.TaskId, Value: 1}}}, + {Keys: bson.D{{Key: FN.AiCommentRecord.ObjType, Value: 1}, {Key: FN.AiCommentRecord.Vid, Value: 1}}}, + {Keys: bson.D{{Key: FN.AiCommentRecord.Vid, Value: 1}, {Key: FN.AiCommentRecord.Status, Value: 1}, {Key: FN.AiCommentRecord.CreatedAt, Value: 1}}}, + {Keys: bson.D{{Key: FN.AiCommentRecord.TaskId, Value: 1}, {Key: FN.AiCommentRecord.Status, Value: 1}}}, + {Keys: bson.D{{Key: FN.AiCommentRecord.ObjType, Value: 1}, {Key: FN.AiCommentRecord.Status, Value: 1}, {Key: FN.AiCommentRecord.CreatedAt, Value: 1}}}, + {Keys: bson.D{{Key: FN.AiCommentRecord.Status, Value: 1}, {Key: FN.AiCommentRecord.CreatedAt, Value: 1}}}, + {Keys: bson.D{{Key: FN.AiCommentRecord.CreatedAt, Value: 1}}}, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(aicommentrecord:Init) Create AiCommentRecord Index failed! err:%+v:", err)) + } +} + +// Reserve claims one playlet before the external submit request. A failed claim +// can be retried, while a submitted or completed claim blocks duplicate work. +func Reserve(ctx context.Context, record *AiCommentRecord) (bool, *srvdef.SrvErr) { + now := time.Now() + record.Status = StatusSubmitting + record.CreatedAt = now + record.UpdatedAt = now + result, err := db.UpsertOne(ctx, bson.M{FN.AiCommentRecord.Key: record.Key}, bson.M{"$setOnInsert": record}) + if err != nil { + log.Warn("aicommentrecord:Reserve failed", log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + if result.UpsertedCount > 0 { + return true, nil + } + result, err = db.UpdateOne(ctx, + bson.M{FN.AiCommentRecord.Key: record.Key, FN.AiCommentRecord.Status: bson.M{"$in": []int{StatusFailed, StatusAbandoned}}}, + bson.M{"$set": bson.M{ + FN.AiCommentRecord.Status: StatusSubmitting, + FN.AiCommentRecord.TaskId: "", + FN.AiCommentRecord.RetainCount: record.RetainCount, + FN.AiCommentRecord.TargetCount: record.TargetCount, + FN.AiCommentRecord.Title: record.Title, + FN.AiCommentRecord.Desc: record.Desc, + FN.AiCommentRecord.CreatedAt: now, + FN.AiCommentRecord.UpdatedAt: now, + }}, + ) + if err != nil { + log.Warn("aicommentrecord:Reserve retry failed", log.E(err)) + return false, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result.ModifiedCount > 0, nil +} + +func SetSubmission(ctx context.Context, key, taskID string, status int, desc string) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, + bson.M{FN.AiCommentRecord.Key: key, FN.AiCommentRecord.Status: StatusSubmitting}, + bson.M{"$set": bson.M{ + FN.AiCommentRecord.TaskId: taskID, + FN.AiCommentRecord.Status: status, + FN.AiCommentRecord.Desc: desc, + FN.AiCommentRecord.UpdatedAt: time.Now(), + }}, + ) + if err != nil { + log.Warn("aicommentrecord:SetSubmission failed", log.E(err), log.Any("key", key)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount == 0 { + log.Warn("aicommentrecord:SetSubmission reservation not found", log.Any("key", key)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +func InsertMany(ctx context.Context, records AiCommentRecordList) *srvdef.SrvErr { + if len(records) == 0 { + return nil + } + now := time.Now() + for _, record := range records { + if record.CreatedAt.IsZero() { + record.CreatedAt = now + } + record.UpdatedAt = now + } + if _, err := db.InsertMany(ctx, &records); err != nil { + log.Warn("aicommentrecord:InsertMany db.InsertMany failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +func FindByStatus(ctx context.Context, status int, limit int64) (AiCommentRecordList, *srvdef.SrvErr) { + if limit <= 0 { + limit = 100 + } + records := make(AiCommentRecordList, 0) + err := db.Find(ctx, + &records, + bson.M{FN.AiCommentRecord.Status: status}, + options.Find().SetSort(bson.D{{Key: FN.AiCommentRecord.CreatedAt, Value: 1}}).SetLimit(limit), + ) + if err != nil { + log.Warn("aicommentrecord:FindByStatus db.Find failed", log.E(err), log.Any("status", status)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return records, nil +} + +func FindFailedForRetry(ctx context.Context, limit int64) (AiCommentRecordList, *srvdef.SrvErr) { + if limit <= 0 { + limit = 100 + } + records := make(AiCommentRecordList, 0) + err := db.Find(ctx, &records, + bson.M{FN.AiCommentRecord.Status: StatusFailed}, + options.Find().SetSort(bson.D{{Key: FN.AiCommentRecord.UpdatedAt, Value: 1}}).SetLimit(limit), + ) + if err != nil { + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return records, nil +} + +func SetStatusByKey(ctx context.Context, key string, status int, desc string) *srvdef.SrvErr { + _, err := db.UpdateOne(ctx, + bson.M{FN.AiCommentRecord.Key: key}, + bson.M{"$set": bson.M{ + FN.AiCommentRecord.Status: status, + FN.AiCommentRecord.Desc: desc, + FN.AiCommentRecord.UpdatedAt: time.Now(), + }}, + ) + if err != nil { + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +func FindByVIDsAndObjType(ctx context.Context, objType int64, vids []string) (AiCommentRecordList, *srvdef.SrvErr) { + if len(vids) == 0 { + return AiCommentRecordList{}, nil + } + records := make(AiCommentRecordList, 0) + filter := bson.M{FN.AiCommentRecord.Vid: bson.M{"$in": vids}} + if objType > 0 { + filter[FN.AiCommentRecord.ObjType] = objType + } + if err := db.Find(ctx, &records, filter); err != nil { + log.Warn("aicommentrecord:FindByVIDsAndObjType db.Find failed", log.E(err), log.Any("objType", objType), log.Any("vids", vids)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return records, nil +} + +func BatchUpdateStatus(ctx context.Context, taskIDs []string, status int) *srvdef.SrvErr { + if len(taskIDs) == 0 { + return nil + } + if _, err := db.UpdateMMany(ctx, + bson.M{FN.AiCommentRecord.TaskId: bson.M{"$in": taskIDs}}, + bson.M{"$set": bson.M{ + FN.AiCommentRecord.Status: status, + FN.AiCommentRecord.UpdatedAt: time.Now(), + }}, + ); err != nil { + log.Warn("aicommentrecord:BatchUpdateStatus db.UpdateMMany failed", log.E(err), log.Any("taskIDs", taskIDs)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +func UpdateStatusAndDescByTaskID(ctx context.Context, taskID string, status int, desc string) *srvdef.SrvErr { + if taskID == "" { + return nil + } + if _, err := db.UpdateOne(ctx, + bson.M{FN.AiCommentRecord.TaskId: taskID}, + bson.M{"$set": bson.M{ + FN.AiCommentRecord.Status: status, + FN.AiCommentRecord.Desc: desc, + FN.AiCommentRecord.UpdatedAt: time.Now(), + }}, + ); err != nil { + log.Warn("aicommentrecord:UpdateStatusAndDescByTaskID db.UpdateOne failed", log.E(err), log.Any("taskID", taskID)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +func ExpireSubmittedBefore(ctx context.Context, deadline time.Time, desc string) (int64, *srvdef.SrvErr) { + result, err := db.UpdateMMany(ctx, + bson.M{ + FN.AiCommentRecord.Status: bson.M{"$in": []int{StatusSubmitting, StatusSubmitted}}, + FN.AiCommentRecord.CreatedAt: bson.M{"$lt": deadline}, + }, + bson.M{"$set": bson.M{ + FN.AiCommentRecord.Status: StatusFailed, + FN.AiCommentRecord.Desc: desc, + FN.AiCommentRecord.UpdatedAt: time.Now(), + }}, + ) + if err != nil { + log.Warn("aicommentrecord:ExpireSubmittedBefore db.UpdateMMany failed", log.E(err), log.Any("deadline", deadline)) + return 0, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result.ModifiedCount, nil +} diff --git a/pub/models/aicommentrecord/tag.go b/pub/models/aicommentrecord/tag.go new file mode 100644 index 0000000..caf8cfc --- /dev/null +++ b/pub/models/aicommentrecord/tag.go @@ -0,0 +1,42 @@ +package aicommentrecord + +type TagAiCommentRecord struct { + Id string + Key string + Vid string + Title string + TaskId string + ObjType string + RetainCount string + TargetCount string + Status string + Desc string + CreatedAt string + UpdatedAt string + DeletedAt string +} + +var FN struct { + AiCommentRecord TagAiCommentRecord +} + +var TN struct { + AiCommentRecord string +} + +func init() { + FN.AiCommentRecord.Id = "_id" + FN.AiCommentRecord.Key = "key" + FN.AiCommentRecord.Vid = "vid" + FN.AiCommentRecord.Title = "title" + FN.AiCommentRecord.TaskId = "task_id" + FN.AiCommentRecord.ObjType = "objType" + FN.AiCommentRecord.RetainCount = "retainCount" + FN.AiCommentRecord.TargetCount = "targetCount" + FN.AiCommentRecord.Status = "status" + FN.AiCommentRecord.Desc = "desc" + FN.AiCommentRecord.CreatedAt = "createdAt" + FN.AiCommentRecord.UpdatedAt = "updatedAt" + FN.AiCommentRecord.DeletedAt = "deletedAt" + TN.AiCommentRecord = "ai_comment_record" +} diff --git a/pub/models/aicommentrecord/type.go b/pub/models/aicommentrecord/type.go new file mode 100644 index 0000000..fb79070 --- /dev/null +++ b/pub/models/aicommentrecord/type.go @@ -0,0 +1,35 @@ +package aicommentrecord + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +const ( + StatusSubmitting = 0 + StatusSubmitted = 1 + StatusSuccess = 2 + StatusFailed = 3 + StatusAbandoned = 4 +) + +// AiCommentRecord 记录已提交到自动评论服务的短剧任务,避免重复提交。 +// @table:ai_comment_record +type AiCommentRecord struct { + ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` + Key string `json:"key" bson:"key,omitempty"` + Vid string `json:"vid" bson:"vid"` + Title string `json:"title" bson:"title"` + TaskId string `json:"taskId" bson:"task_id"` + ObjType int64 `json:"objType" bson:"objType"` + RetainCount int `json:"retainCount" bson:"retainCount,omitempty"` // 该任务结果需保留的评论数 + TargetCount int `json:"targetCount" bson:"targetCount,omitempty"` // 本轮内容最终需要达到的AI评论数 + Status int `json:"status" bson:"status"` + Desc string `json:"desc" bson:"desc"` + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` + DeletedAt time.Time `json:"deletedAt" bson:"deletedAt"` +} + +type AiCommentRecordList []*AiCommentRecord diff --git a/pub/models/analyticsdailystat/analytics.go b/pub/models/analyticsdailystat/analytics.go new file mode 100644 index 0000000..75d247b --- /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 +} diff --git a/pub/models/analyticsdailystat/tag.go b/pub/models/analyticsdailystat/tag.go new file mode 100755 index 0000000..d98856c --- /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" +} diff --git a/pub/models/analyticsdailystat/type.go b/pub/models/analyticsdailystat/type.go new file mode 100644 index 0000000..943e3ba --- /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 diff --git a/pub/models/analyticsstat/analytics.go b/pub/models/analyticsstat/analytics.go new file mode 100644 index 0000000..d4c55ce --- /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 +} diff --git a/pub/models/analyticsstat/tag.go b/pub/models/analyticsstat/tag.go new file mode 100755 index 0000000..b7418b6 --- /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" +} diff --git a/pub/models/analyticsstat/type.go b/pub/models/analyticsstat/type.go new file mode 100644 index 0000000..e455267 --- /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 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..e202bab --- /dev/null +++ b/pub/models/cfg/tag.go @@ -0,0 +1,90 @@ +package cfg + +type TagSysCfg struct { + AppAddr string + OfficialAppAddr 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 + CustomerServiceHost string + WeiChart string + DHNewUrlAddr1 string + DHNewUrlAddr2 string + DHNewUrlAddr3 string + DHForeverUrlAddr string + DHTitle string + DHKeyworld string + DHDescription string + DHSiteName string + ChannelCode string + EnableH265 string + AppKey string +} + +var FN struct { + SysCfg TagSysCfg +} + +var TN struct { + SysCfg string +} + +func init() { + FN.SysCfg.AppAddr = "appAddr" + FN.SysCfg.OfficialAppAddr = "officialAppAddr" + 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.CustomerServiceHost = "customerServiceHost" + FN.SysCfg.WeiChart = "weiChart" + FN.SysCfg.DHNewUrlAddr1 = "dHNewUrlAddr1" + FN.SysCfg.DHNewUrlAddr2 = "dHNewUrlAddr2" + FN.SysCfg.DHNewUrlAddr3 = "dHNewUrlAddr3" + FN.SysCfg.DHForeverUrlAddr = "dHForeverUrlAddr" + FN.SysCfg.DHTitle = "dhTitle" + FN.SysCfg.DHKeyworld = "dhKeyworld" + FN.SysCfg.DHDescription = "dhDescription" + FN.SysCfg.DHSiteName = "dhSiteName" + FN.SysCfg.ChannelCode = "channelCode" + FN.SysCfg.EnableH265 = "enableH265" + 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..0f8106e --- /dev/null +++ b/pub/models/cfg/type.go @@ -0,0 +1,74 @@ +package cfg + +import "strings" + +// @table:sys_cfg +type SysCfg struct { + Id int64 `json:"id" bson:"_id"` + UnencryptedCdn string `json:"unencryptedCdn" bson:"unencryptedCdn"` // 不加密资源使用的cdn + SitemapDefaultImages []string `json:"sitemapDefaultImages" bson:"sitemapDefaultImages"` // Sitemap里面默认封面图 + ImgAppCdn string `json:"imgAppCdn" bson:"imgAppCdn"` // 图片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使用) + ImgAppUsCdn string `json:"imgAppUsCdn" bson:"imgAppUsCdn"` // 图片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 + CustomerServiceHost string `json:"customerServiceHost" bson:"customerServiceHost"` // 在线客服 + //网站管理 + 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下载地址(落地页) + OfficialAppAddr string `json:"officialAppAddr" bson:"officialAppAddr"` //官网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"` //应用导航 + //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"` // 所有使用中的域名 + AdvitiesTopicNum int64 `json:"advitiesTopicNum" bson:"advitiesTopicNum"` // 广告间隔 + 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网站名称 + ActressBackGround string `json:"actressBackGround" bson:"actressBackGround"` // 女优通用背景图 + ChannelCode string `json:"channelCode" bson:"channelCode"` // 渠道码 + EnableH265 bool `json:"enableH265" bson:"enableH265"` // 是否允许客户端使用 H265 地址播放 + 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/cointransmod/tag.go b/pub/models/cointransmod/tag.go new file mode 100644 index 0000000..1365a31 --- /dev/null +++ b/pub/models/cointransmod/tag.go @@ -0,0 +1,40 @@ +package cointransmod + +type TagCoinTrans struct { + Id string + UserId string + UserName string + FlowDir string + TranType string + Amount string + BalanceBefore string + BalanceAfter string + OrderNo string + RelatedId string + Remark string + CreatedAt string +} + +var FN struct { + CoinTrans TagCoinTrans +} +var TN struct { + CoinTrans string +} + +func init() { + FN.CoinTrans.Id = "_id" + FN.CoinTrans.UserId = "userId" + FN.CoinTrans.UserName = "userName" + FN.CoinTrans.FlowDir = "flowDir" + FN.CoinTrans.TranType = "tranType" + FN.CoinTrans.Amount = "amount" + FN.CoinTrans.BalanceBefore = "balanceBefore" + FN.CoinTrans.BalanceAfter = "balanceAfter" + FN.CoinTrans.OrderNo = "orderNo" + FN.CoinTrans.RelatedId = "relatedId" + FN.CoinTrans.Remark = "remark" + FN.CoinTrans.CreatedAt = "createdAt" + + TN.CoinTrans = "coin_trans" +} diff --git a/pub/models/cointransmod/trans.go b/pub/models/cointransmod/trans.go new file mode 100644 index 0000000..566414c --- /dev/null +++ b/pub/models/cointransmod/trans.go @@ -0,0 +1,80 @@ +package cointransmod + +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 coll *mongos.Collection + +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.CoinTrans) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + // 按用户+时间倒序查流水(最常用) + {Keys: bson.D{ + {Key: FN.CoinTrans.UserId, Value: 1}, + {Key: FN.CoinTrans.CreatedAt, Value: -1}, + }}, + // 按交易类型筛选 + {Keys: bson.D{{Key: FN.CoinTrans.TranType, Value: 1}}}, + // 按订单号查(幂等校验) + {Keys: bson.D{{Key: FN.CoinTrans.OrderNo, Value: 1}}}, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(cointransmod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOneCoinTrans 写入一条流水记录 +func InsertOneCoinTrans(ctx context.Context, doc *CoinTrans) *srvdef.SrvErr { + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Error("cointransmod:InsertOneCoinTrans failed", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindManyCoinTrans 分页查询流水列表 +func FindManyCoinTrans(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (CoinTransList, *srvdef.SrvErr) { + var data CoinTransList + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("cointransmod:FindManyCoinTrans failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountCoinTrans 统计流水条数 +func CountCoinTrans(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter) + if err != nil { + log.Warn("cointransmod:CountCoinTrans failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// StatSyncConsumeList 按 ID 游标增量拉取金币消费流水(统计中心) +func StatSyncConsumeList(ctx context.Context, startId int64, size int64) (CoinTransList, error) { + opt := (&options.FindOptions{}).SetLimit(size).SetSort(bson.D{{Key: FN.CoinTrans.Id, Value: 1}}) + filter := bson.M{ + FN.CoinTrans.Id: bson.M{"$gt": startId}, + FN.CoinTrans.FlowDir: FlowOut, + } + list := CoinTransList{} + if err := coll.Find(ctx, &list, filter, opt); err != nil { + return nil, err + } + return list, nil +} diff --git a/pub/models/cointransmod/type.go b/pub/models/cointransmod/type.go new file mode 100644 index 0000000..551efa2 --- /dev/null +++ b/pub/models/cointransmod/type.go @@ -0,0 +1,64 @@ +package cointransmod + +import "time" + +// FlowDir 收支方向 +type FlowDir int8 + +const ( + FlowIn FlowDir = 1 // 收入(充值、赠送) + FlowOut FlowDir = -1 // 支出(消费、解锁) +) + +func (f FlowDir) Desc() string { + if f == FlowIn { + return "收入" + } + return "支出" +} + +// TranType 交易类型 +type TranType int32 + +const ( + TranTypeCoinRecharge TranType = 1 // 金币充值(购买金币) + TranTypeVipGiftCoin TranType = 2 // 购买会员赠送金币 + TranTypeDarkAlley TranType = 3 // 历史遗留交易类型(已下线) + TranTypeVideoUnlock TranType = 4 // 视频解锁 + TranTypeAdminAdjust TranType = 5 // 管理员手动调整 + TranTypeCoinGift TranType = 6 // 金币充值赠送 +) + +var TranTypeNameMap = map[TranType]string{ + TranTypeCoinRecharge: "金币充值", + TranTypeVipGiftCoin: "会员赠送", + TranTypeDarkAlley: "历史交易", + TranTypeVideoUnlock: "视频解锁", + TranTypeAdminAdjust: "管理员调整", + TranTypeCoinGift: "充值赠送", +} + +func (t TranType) Desc() string { + if name, ok := TranTypeNameMap[t]; ok { + return name + } + return "未知" +} + +// @table: coin_trans +type CoinTrans struct { + Id int64 `json:"id" bson:"_id"` // 唯一ID + UserId int64 `json:"userId" bson:"userId"` // 用户ID + UserName string `json:"userName" bson:"userName"` // 用户昵称(快照) + FlowDir FlowDir `json:"flowDir" bson:"flowDir"` // 收支方向:1收入 -1支出 + TranType TranType `json:"tranType" bson:"tranType"` // 交易类型 + Amount int64 `json:"amount" bson:"amount"` // 本次交易金币数(正整数) + BalanceBefore int64 `json:"balanceBefore" bson:"balanceBefore"` // 交易前余额 + BalanceAfter int64 `json:"balanceAfter" bson:"balanceAfter"` // 交易后余额 + OrderNo string `json:"orderNo" bson:"orderNo"` // 关联订单号(可为空) + RelatedId int64 `json:"relatedId" bson:"relatedId"` // 关联内容ID(视频等,可为空) + Remark string `json:"remark" bson:"remark"` // 备注描述 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 交易时间 +} + +type CoinTransList []*CoinTrans diff --git a/pub/models/commentlikemod/like.go b/pub/models/commentlikemod/like.go new file mode 100644 index 0000000..ac1d23e --- /dev/null +++ b/pub/models/commentlikemod/like.go @@ -0,0 +1,90 @@ +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" +) + +var coll *mongos.Collection + +// Init 初始化 comment_like 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.CommentLike) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.CommentLike.UserId, Value: 1}, + {Key: FN.CommentLike.CommentId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.CommentLike.CommentId, Value: 1}, + {Key: FN.CommentLike.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.CommentLike.ObjectType, Value: 1}, + {Key: FN.CommentLike.ObjectId, Value: 1}, + {Key: FN.CommentLike.CreatedAt, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(commentlikemod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// InsertOneCommentLike 插入一条评论点赞关系 +func InsertOneCommentLike(ctx context.Context, doc *CommentLike) (bool, *srvdef.SrvErr) { + if doc.Id == 0 { + doc.Id = mongos.Lid.CommentLike.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) { + return false, nil + } + log.Warn("插入评论点赞关系失败", log.Any("doc", doc), log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return true, nil +} + +// DeleteOneCommentLike 删除一条评论点赞关系 +func DeleteOneCommentLike(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("删除评论点赞关系失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindManyCommentLike 按条件查询评论点赞关系 +func FindManyCommentLike(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (CommentLikeList, *srvdef.SrvErr) { + data := make(CommentLikeList, 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, "", "") + } + return data, nil +} diff --git a/pub/models/commentlikemod/tag.go b/pub/models/commentlikemod/tag.go new file mode 100644 index 0000000..4fae5a2 --- /dev/null +++ b/pub/models/commentlikemod/tag.go @@ -0,0 +1,32 @@ +package commentlikemod + +// TagCommentLike 保存 comment_like 集合字段名 +type TagCommentLike struct { + Id string + UserId string + CommentId string + ObjectId string + ObjectType string + CreatedAt string + UpdatedAt string +} + +var FN struct { + CommentLike TagCommentLike +} + +var TN struct { + CommentLike string +} + +func init() { + FN.CommentLike.Id = "_id" + FN.CommentLike.UserId = "userId" + FN.CommentLike.CommentId = "commentId" + FN.CommentLike.ObjectId = "objectId" + FN.CommentLike.ObjectType = "objectType" + FN.CommentLike.CreatedAt = "createdAt" + FN.CommentLike.UpdatedAt = "updatedAt" + + TN.CommentLike = "comment_like" +} diff --git a/pub/models/commentlikemod/type.go b/pub/models/commentlikemod/type.go new file mode 100644 index 0000000..6adad53 --- /dev/null +++ b/pub/models/commentlikemod/type.go @@ -0,0 +1,19 @@ +package commentlikemod + +import "time" + +// CommentLike @table: comment_like +// +// CommentLike 评论点赞关系模型 +type CommentLike struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 点赞记录ID + UserId int64 `json:"userId" bson:"userId"` // 用户ID + CommentId int64 `json:"commentId" bson:"commentId"` // 评论ID + ObjectId int64 `json:"objectId" bson:"objectId"` // 评论对象ID + ObjectType int64 `json:"objectType" bson:"objectType"` // 评论对象类型 10-短剧 11-短剧剧集 20-视频 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// CommentLikeList 评论点赞关系列表 +type CommentLikeList []*CommentLike diff --git a/pub/models/commentmod/comment.go b/pub/models/commentmod/comment.go new file mode 100644 index 0000000..ed361ad --- /dev/null +++ b/pub/models/commentmod/comment.go @@ -0,0 +1,241 @@ +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.AiTaskId, Value: 1}, {Key: FN.Comment.AiIndex, Value: 1}}, + Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{FN.Comment.AiTaskId: bson.M{"$exists": true}}), + }, + { + 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.ParentsId, Value: 1}, + {Key: FN.Comment.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.UserId, Value: 1}, + {Key: FN.Comment.ObjectType, 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.ParentsId, Value: 1}, + {Key: FN.Comment.CountLike, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Comment.ParentsId, Value: 1}, + {Key: FN.Comment.Level, Value: 1}, + {Key: FN.Comment.ReviewStatus, Value: 1}, + {Key: FN.Comment.CreatedAt, Value: 1}, + {Key: FN.Comment.Id, 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)) + } +} + +// InsertAICommentOnce makes a generated result safe to process again after a crash. +func InsertAICommentOnce(ctx context.Context, comment *CommentModel) (bool, *srvdef.SrvErr) { + filter := bson.M{FN.Comment.AiTaskId: comment.AiTaskId, FN.Comment.AiIndex: comment.AiIndex} + result, err := db.UpsertOne(ctx, filter, bson.M{"$setOnInsert": comment}) + if err != nil { + log.Warn("comment:InsertAICommentOnce failed", log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return result.UpsertedCount > 0, nil +} + +// 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 +} + +// 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..ec24b5c --- /dev/null +++ b/pub/models/commentmod/tag.go @@ -0,0 +1,60 @@ +package commentmod + +type TagComment struct { + CreatedAt string + Id string + Level string + ObjectId string + ParentsId string + ReplyCommentId string + ReplyUserName string + Text string + TopRank string + UpdatedAt string + UserId string + UserName string + UserAvatar string + ReviewStatus string + IsAi string + AiTaskId string + AiIndex 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.ReplyCommentId = "replyCommentId" + FN.Comment.ReplyUserName = "replyUserName" + FN.Comment.Text = "text" + FN.Comment.TopRank = "topRank" + FN.Comment.UpdatedAt = "updatedAt" + FN.Comment.UserId = "userId" + FN.Comment.UserName = "userName" + FN.Comment.UserAvatar = "userAvatar" + FN.Comment.ReviewStatus = "reviewStatus" + FN.Comment.IsAi = "isAi" + FN.Comment.AiTaskId = "aiTaskId" + FN.Comment.AiIndex = "aiIndex" + FN.Comment.Ip = "ip" + FN.Comment.CommentCount = "commentCount" + FN.Comment.CountLike = "countLike" + FN.Comment.ObjectName = "objectName" + FN.Comment.ObjectType = "objectType" + TN.Comment = "_comment" +} diff --git a/pub/models/commentmod/type.go b/pub/models/commentmod/type.go new file mode 100644 index 0000000..c702288 --- /dev/null +++ b/pub/models/commentmod/type.go @@ -0,0 +1,34 @@ +package commentmod + +import ( + "time" +) + +// 评论管理 +// @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 int64 `json:"objectType" bson:"objectType"` // 对象类型 + ObjectName string `json:"objectName" bson:"objectName"` // 对象名 + UserId int64 `json:"userId" bson:"userId"` // 评论用户ID + UserName string `json:"userName" bson:"userName"` // 用户名称 + UserAvatar string `json:"userAvatar" bson:"userAvatar"` // 评论用户头像 + Level int64 `json:"level" bson:"level"` // 评论级别,1-一级评论 2-二级评论 + ParentsId int64 `json:"parentsId" bson:"parentsId"` // 上级评论ID,一级评论为0 + ReplyCommentId int64 `json:"replyCommentId" bson:"replyCommentId"` // 被回复评论ID,一级评论为0 + 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 审核失败 + IsAi bool `json:"isAi" bson:"isAi,omitempty"` // 自动生成评论 + AiTaskId string `json:"-" bson:"aiTaskId,omitempty"` // 生成任务ID + AiIndex int `json:"-" bson:"aiIndex,omitempty"` // 任务内序号 + 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..dc48243 --- /dev/null +++ b/pub/models/contactusmod/contactus.go @@ -0,0 +1,100 @@ +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 coll *mongos.Collection + +// Init 初始化 contactus 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.ContactUs) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.ContactUs.UserId, Value: 1}, + {Key: FN.ContactUs.CreatedAt, Value: -1}, + {Key: FN.ContactUs.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.ContactUs.Status, Value: 1}, + {Key: FN.ContactUs.CreatedAt, Value: -1}, + {Key: FN.ContactUs.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.ContactUs.QuestionType, Value: 1}, + {Key: FN.ContactUs.CreatedAt, Value: -1}, + {Key: FN.ContactUs.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.ContactUs.CreatedAt, Value: -1}, + {Key: FN.ContactUs.Id, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(contactusmod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOneContactUs 插入一条联系我们数据 +// +// 当 doc.Id 为空时,会使用 mongos.Lid.ContactUs 自动生成自增ID +func InsertOneContactUs(ctx context.Context, doc *ContactUs) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.ContactUs.Get() + } + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("联系我们记录写入失败", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindManyContactUs 按条件查询多条联系我们数据 +func FindManyContactUs(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (ContactUsList, *srvdef.SrvErr) { + var data ContactUsList + 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, "", "") + } + return data, nil +} + +// CountContactUs 统计满足条件的联系我们数量 +func CountContactUs(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 +} + +// UpdateManyContactUsWithResult 批量更新联系我们数据,并返回命中数量和修改数量 +func UpdateManyContactUsWithResult(ctx context.Context, filter bson.M, update bson.M) (matchedCount int64, modifiedCount int64, serr *srvdef.SrvErr) { + result, err := coll.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("联系我们批量更新失败", log.E(err), log.Any("filter", filter)) + return 0, 0, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result.MatchedCount, result.ModifiedCount, nil +} diff --git a/pub/models/contactusmod/tag.go b/pub/models/contactusmod/tag.go new file mode 100644 index 0000000..6e76124 --- /dev/null +++ b/pub/models/contactusmod/tag.go @@ -0,0 +1,40 @@ +package contactusmod + +// TagContactUs 保存contactus集合字段名 +type TagContactUs struct { + Id string + UserId string + Email string + QuestionType string + Title string + Content string + Attachments string + Status string + Remark string + CreatedAt string + UpdatedAt string +} + +var FN struct { + ContactUs TagContactUs +} + +var TN struct { + ContactUs string +} + +func init() { + FN.ContactUs.Id = "_id" + FN.ContactUs.UserId = "userId" + FN.ContactUs.Email = "email" + FN.ContactUs.QuestionType = "questionType" + FN.ContactUs.Title = "title" + FN.ContactUs.Content = "content" + FN.ContactUs.Attachments = "attachments" + FN.ContactUs.Status = "status" + FN.ContactUs.Remark = "remark" + FN.ContactUs.CreatedAt = "createdAt" + FN.ContactUs.UpdatedAt = "updatedAt" + + TN.ContactUs = "contactus" +} diff --git a/pub/models/contactusmod/type.go b/pub/models/contactusmod/type.go new file mode 100644 index 0000000..d50cd53 --- /dev/null +++ b/pub/models/contactusmod/type.go @@ -0,0 +1,34 @@ +package contactusmod + +import "time" + +// ContactUs @table: contactus +// +// ContactUs 联系我们集合模型 +type ContactUs struct { + // ID + Id int64 `json:"id" bson:"_id,omitempty"` + // 用户ID + UserId int64 `json:"userId" bson:"userId"` + // 邮箱 + Email string `json:"email" bson:"email"` + // 问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他 + QuestionType int32 `json:"questionType" bson:"questionType"` + // 标题 + Title string `json:"title" bson:"title"` + // 内容 + Content string `json:"content" bson:"content"` + // 附件地址,可存放多张图片或文件地址 + Attachments []string `json:"attachments" bson:"attachments"` + // 处理状态,0-待处理、1-已处理 + Status int32 `json:"status" bson:"status"` + // 处理备注 + Remark string `json:"remark" bson:"remark,omitempty"` + // 创建时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + // 更新时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` +} + +// ContactUsList 联系我们列表 +type ContactUsList []*ContactUs 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/eshelper.go b/pub/models/esmod/eshelper.go new file mode 100644 index 0000000..4429469 --- /dev/null +++ b/pub/models/esmod/eshelper.go @@ -0,0 +1,58 @@ +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 = "websgdjweb_video_index_v1" + playletIndex = "websgdjweb_playlet_index_v1" +) + +func GetVideoIndex() string { + return videoIndex +} + +func GetPlayletIndex() string { + return playletIndex +} +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..1a8d3eb --- /dev/null +++ b/pub/models/esmod/esop.go @@ -0,0 +1,277 @@ +package esmod + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "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 + } + initPlayletIndex(playletIndex) +} + +// 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 { + log.Warn("初始化视频ES索引失败,跳过索引创建", log.Any("索引", esIndex), log.E(err)) + } +} + +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.OperateType: esmanager.M{ + "type": "integer", + }, + 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.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.PlayName: esmanager.M{ + "type": "text", + "analyzer": esmanager.AnalyzerIkMaxWord, + "search_analyzer": esmanager.AnalyzerIkSmart, + }, + FN.ESPlaylet.Description: esmanager.M{ + "type": "keyword", + "index": false, + }, + FN.ESPlaylet.Slug: esmanager.M{ + "type": "keyword", + }, + 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.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", + }, + 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, + }, + }, + }, + }, + }, + } + if err := esmanager.Instance.InitIndices(esIndex, setting); err != nil { + log.Warn("初始化短剧ES索引失败,跳过索引创建", log.Any("索引", esIndex), log.E(err)) + } +} diff --git a/pub/models/esmod/recordop.go b/pub/models/esmod/recordop.go new file mode 100644 index 0000000..49ea08c --- /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 +} diff --git a/pub/models/esmod/tag.go b/pub/models/esmod/tag.go new file mode 100755 index 0000000..33aa66f --- /dev/null +++ b/pub/models/esmod/tag.go @@ -0,0 +1,299 @@ +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 + Slug 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 + OperateType string + ShowOnBanner string + Banner string + OnlineAt string + ThemeIds string + ThemeNames string + CategoryIds string + Title string + PlayName string + Description string + Slug string + Desc string + Anchor string + Cover string + TagIds string + TagNames string + TotalEpisode string + UpdateStatus string + CountBrowse string + HeatValue string + CountCollect string + CountLike string + UpdatedAt string + CreatedAt string + SeoInfo string +} + +type TagESActress struct { + Id string + Alias string + Avatar string + Meas string + Office string + DebutDate string + BornDate string + Region string + Score string + Works 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 +} +type TagESPost struct { + Id string + CreateId string + Title string + Cover string + Thumb string + TagIds string + Kind string + Nodes string + VideoId string + Status string + Remark string + Rank string + AddedTime string + Real struct { + WatchTimes string + ShowTimes string + Comments string + WeekWatchs string + MonthWatchs string + TodayWatchs string + } + CreatedAt string + UpdatedAt string + Keyworlds string + FirstTag string + Desc string + IsHot string + Sort string +} + +var FN struct { + ESRecord TagESRecord + ESVideo TagESVideo + ESPlaylet TagESPlaylet + ESActress TagESActress + ESPost TagESPost +} + +var TN struct { + ESRecord string + ESVideo string + ESPlaylet string + ESActress string + ESPost 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", + Slug: "slug", + 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", + OperateType: "operateType", + ShowOnBanner: "showOnBanner", + Banner: "banner", + OnlineAt: "onlineAt", + ThemeIds: "themeIds", + ThemeNames: "themeNames", + CategoryIds: "categoryIds", + Title: "title", + PlayName: "playName", + Description: "description", + Slug: "slug", + Desc: "desc", + Anchor: "anchor", + Cover: "cover", + TagIds: "tagIds", + TagNames: "tagNames", + TotalEpisode: "totalEpisode", + UpdateStatus: "updateStatus", + CountBrowse: "countBrowse", + HeatValue: "heatValue", + CountCollect: "countCollect", + CountLike: "countLike", + UpdatedAt: "updatedAt", + CreatedAt: "createdAt", + SeoInfo: "seoInfo", + } + + FN.ESActress = TagESActress{ + Id: "_id", + Alias: "alias", + Avatar: "avatar", + Meas: "meas", + Office: "office", + DebutDate: "debutDate", + BornDate: "bornDate", + Region: "region", + Score: "score", + Works: "works", + 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", + } + + FN.ESPost = TagESPost{ + Id: "_id", + CreateId: "createId", + Title: "title", + Cover: "cover", + Thumb: "thumb", + TagIds: "tagIds", + Kind: "kind", + Nodes: "nodes", + VideoId: "videoId", + Status: "status", + Remark: "remark", + Rank: "rank", + AddedTime: "addedTime", + 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"}, + CreatedAt: "createdAt", + UpdatedAt: "updatedAt", + Keyworlds: "keyworlds", + FirstTag: "firstTag", + Desc: "desc", + IsHot: "isHot", + Sort: "sort", + } + + TN.ESRecord = "es_record" + TN.ESVideo = "es_video" + TN.ESPlaylet = "es_playlet" + TN.ESActress = "es_actress" + TN.ESPost = "es_post" +} diff --git a/pub/models/esmod/type.go b/pub/models/esmod/type.go new file mode 100644 index 0000000..9ae8f86 --- /dev/null +++ b/pub/models/esmod/type.go @@ -0,0 +1,136 @@ +//go:generate gen_bson_tag -m esmod +//esmod essearch数据结构定义 + +package esmod + +import ( + "time" + "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"` // 帖子标题 + Slug string `json:"slug" bson:"slug"` // slug + 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 videoinformationmod.InformationSatus `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"` // 视频 + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类Id + Tags []int64 `json:"tags" bson:"tags"` // 标签 + Real videoinformationmod.Flow `json:"real" bson:"real"` // 真实流量 + VideoCount int64 `json:"videoCount" bson:"videoCount"` // 合集视频数量 +} + +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"` // 是否爆剧(true-是,false-否) + IsRecommend bool `json:"isRecommend" bson:"isRecommend"` // 是否推荐 true-推荐,false-不推荐 + OperateType playletmod.OperateType `json:"operateType" bson:"operateType"` // 运营类型 1-短剧 2-漫剧 + ShowOnBanner bool `json:"showOnBanner" bson:"showOnBanner"` // 是否显示在首页banner + Banner string `json:"banner" bson:"banner"` // banner图 + OnlineAt time.Time `json:"onlineAt" bson:"onlineAt"` // 上架时间 + ThemeIds []int64 `json:"themeIds" bson:"themeIds"` // 题材ID集合 + ThemeNames []string `json:"themeNames" bson:"themeNames"` // 题材名称集合 + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类ID集合 + Title string `json:"title" bson:"title"` // 标题 + PlayName string `json:"playName" bson:"playName"` // 剧名 + Description string `json:"description" bson:"description"` // SEO Description + 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"` // 封面 + 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"` // 展示点赞数 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 +} + +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"` +} 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/feedbackmod/feedback.go b/pub/models/feedbackmod/feedback.go new file mode 100644 index 0000000..461955c --- /dev/null +++ b/pub/models/feedbackmod/feedback.go @@ -0,0 +1,93 @@ +package feedbackmod + +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 coll *mongos.Collection + +// Init 初始化 feedback 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.Feedback) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.Feedback.UserId, Value: 1}, + {Key: FN.Feedback.CreatedAt, Value: -1}, + {Key: FN.Feedback.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Feedback.Status, Value: 1}, + {Key: FN.Feedback.CreatedAt, Value: -1}, + {Key: FN.Feedback.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Feedback.CreatedAt, Value: -1}, + {Key: FN.Feedback.Id, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(feedbackmod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOneFeedback 插入一条用户意见反馈数据 +// +// 当 doc.Id 为空时,会使用 mongos.Lid.Feedback 自动生成自增ID +func InsertOneFeedback(ctx context.Context, doc *Feedback) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.Feedback.Get() + } + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("用户意见反馈写入失败", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindManyFeedback 按条件查询多条用户意见反馈数据 +func FindManyFeedback(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (FeedbackList, *srvdef.SrvErr) { + var data FeedbackList + 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, "", "") + } + return data, nil +} + +// CountFeedback 统计满足条件的用户意见反馈数量 +func CountFeedback(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 +} + +// UpdateManyFeedbackWithResult 批量更新用户意见反馈数据,并返回命中数量和修改数量 +func UpdateManyFeedbackWithResult(ctx context.Context, filter bson.M, update bson.M) (matchedCount int64, modifiedCount int64, serr *srvdef.SrvErr) { + result, err := coll.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("用户意见反馈批量更新失败", log.E(err), log.Any("filter", filter)) + return 0, 0, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result.MatchedCount, result.ModifiedCount, nil +} diff --git a/pub/models/feedbackmod/tag.go b/pub/models/feedbackmod/tag.go new file mode 100644 index 0000000..918a998 --- /dev/null +++ b/pub/models/feedbackmod/tag.go @@ -0,0 +1,38 @@ +package feedbackmod + +// TagFeedback 保存feedback集合字段名 +type TagFeedback struct { + Id string + UserId string + FeedbackType string + Content string + Screenshots string + Contact string + Status string + Remark string + CreatedAt string + UpdatedAt string +} + +var FN struct { + Feedback TagFeedback +} + +var TN struct { + Feedback string +} + +func init() { + FN.Feedback.Id = "_id" + FN.Feedback.UserId = "userId" + FN.Feedback.FeedbackType = "feedbackType" + FN.Feedback.Content = "content" + FN.Feedback.Screenshots = "screenshots" + FN.Feedback.Contact = "contact" + FN.Feedback.Status = "status" + FN.Feedback.Remark = "remark" + FN.Feedback.CreatedAt = "createdAt" + FN.Feedback.UpdatedAt = "updatedAt" + + TN.Feedback = "feedback" +} diff --git a/pub/models/feedbackmod/type.go b/pub/models/feedbackmod/type.go new file mode 100644 index 0000000..22b54ff --- /dev/null +++ b/pub/models/feedbackmod/type.go @@ -0,0 +1,32 @@ +package feedbackmod + +import "time" + +// Feedback @table: feedback +// +// Feedback 用户意见反馈集合模型 +type Feedback struct { + // ID + Id int64 `json:"id" bson:"_id,omitempty"` + // 用户ID + UserId int64 `json:"userId" bson:"userId"` + // 问题类型,1-功能建议、2-播放问题、3-账号问题、4-其他 + FeedbackType int32 `json:"feedbackType" bson:"feedbackType"` + // 描述内容 + Content string `json:"content" bson:"content"` + // 问题截图,可存放多张图片地址 + Screenshots []string `json:"screenshots" bson:"screenshots"` + // 联系方式,可为空 + Contact string `json:"contact" bson:"contact,omitempty"` + // 处理状态,0-待处理、1-已处理 + Status int32 `json:"status" bson:"status"` + // 处理备注 + Remark string `json:"remark" bson:"remark,omitempty"` + // 创建时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + // 更新时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` +} + +// FeedbackList 用户意见反馈列表 +type FeedbackList []*Feedback 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/homeconfigmod/home_config.go b/pub/models/homeconfigmod/home_config.go new file mode 100644 index 0000000..6776564 --- /dev/null +++ b/pub/models/homeconfigmod/home_config.go @@ -0,0 +1,136 @@ +package homeconfigmod + +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_config 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.HomeConfig) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.HomeConfig.Type, Value: 1}, + {Key: FN.HomeConfig.SortType, Value: 1}, + {Key: FN.HomeConfig.Status, Value: 1}, + {Key: FN.HomeConfig.Rank, Value: -1}, + {Key: FN.HomeConfig.UpdatedAt, Value: -1}, + {Key: FN.HomeConfig.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.HomeConfig.Name, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.HomeConfig.Slug, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.HomeConfig.CreatedAt, Value: -1}, + {Key: FN.HomeConfig.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.HomeConfig.Status, Value: 1}, + {Key: FN.HomeConfig.Rank, Value: -1}, + {Key: FN.HomeConfig.Id, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(homeconfigmod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +// InsertOneHomeConfig 插入一条首页配置数据 +func InsertOneHomeConfig(ctx context.Context, doc *HomeConfig) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.HomeConfig.Get() + } + now := time.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("homeconfigmod:InsertOneHomeConfig failed", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneHomeConfig 删除一条首页配置数据 +func DelOneHomeConfig(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("homeconfigmod:DelOneHomeConfig failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindManyHomeConfig 按条件查询多条首页配置数据 +func FindManyHomeConfig(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (HomeConfigList, *srvdef.SrvErr) { + data := make(HomeConfigList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("homeconfigmod:FindManyHomeConfig failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountHomeConfig 统计满足条件的首页配置数量 +func CountHomeConfig(ctx context.Context, filter bson.M, opts ...*options.CountOptions) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter, opts...) + if err != nil { + log.Warn("homeconfigmod:CountHomeConfig failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// FindOneHomeConfig 查询一条首页配置 +func FindOneHomeConfig(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*HomeConfig, *srvdef.SrvErr) { + var doc HomeConfig + if err := coll.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("homeconfigmod:FindOneHomeConfig failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// UpdateOneHomeConfig 更新一条首页配置数据 +func UpdateOneHomeConfig(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("homeconfigmod:UpdateOneHomeConfig 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/homeconfigmod/tag.go b/pub/models/homeconfigmod/tag.go new file mode 100644 index 0000000..cf5c8a8 --- /dev/null +++ b/pub/models/homeconfigmod/tag.go @@ -0,0 +1,61 @@ +package homeconfigmod + +// TagHomeConfig 保存 home_config 集合字段名 +type TagHomeConfig struct { + Id string + Name string + Slug string + Desc string + Keyword string + Label string + Style string + CardBackgroundImage string + TopicIds string + CategoryId string + RecommendType string + Type string + Rank string + Limit string + SortType string + Status string + SeoInfo struct { + Description string + Keyworld string + Title string + } + CreatedAt string + UpdatedAt string +} + +var FN struct { + HomeConfig TagHomeConfig +} + +var TN struct { + HomeConfig string +} + +func init() { + FN.HomeConfig.Id = "_id" + FN.HomeConfig.Name = "name" + FN.HomeConfig.Slug = "slug" + FN.HomeConfig.Desc = "desc" + FN.HomeConfig.Keyword = "keyword" + FN.HomeConfig.Label = "label" + FN.HomeConfig.Style = "style" + FN.HomeConfig.CardBackgroundImage = "cardBackgroundImage" + FN.HomeConfig.TopicIds = "topicIds" + FN.HomeConfig.CategoryId = "categoryId" + FN.HomeConfig.RecommendType = "recommendType" + FN.HomeConfig.Type = "type" + FN.HomeConfig.Rank = "rank" + FN.HomeConfig.Limit = "limit" + FN.HomeConfig.SortType = "sortType" + FN.HomeConfig.Status = "status" + FN.HomeConfig.SeoInfo.Description = "seoInfo.webDescription" + FN.HomeConfig.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.HomeConfig.SeoInfo.Title = "seoInfo.webTitle" + FN.HomeConfig.CreatedAt = "createdAt" + FN.HomeConfig.UpdatedAt = "updatedAt" + TN.HomeConfig = "home_config" +} diff --git a/pub/models/homeconfigmod/type.go b/pub/models/homeconfigmod/type.go new file mode 100644 index 0000000..17b559e --- /dev/null +++ b/pub/models/homeconfigmod/type.go @@ -0,0 +1,66 @@ +package homeconfigmod + +import ( + "strings" + "time" + "videoweb/pub/pubdef" +) + +const ( + TypePlaylet int32 = 1 // 短剧 + TypeTheme int32 = 3 // 题材 +) + +const ( + SortTypeRecommend int32 = 1 // 推荐 + SortTypeHot int32 = 2 // 热播 + SortTypeLatest int32 = 3 // 最近更新 + SortTypeOnlineAt int32 = 4 // 上架时间 + SortTypeCategory int32 = 5 // 分类 +) + +// HomeConfig @table: home_config +// +// HomeConfig 首页配置集合模型 +type HomeConfig struct { + Id int64 `json:"id" bson:"_id,omitempty"` // ID + Name string `json:"name" bson:"name"` // 名称 + Slug string `json:"slug" bson:"slug"` // slug,H5 更多页路径优先用它 + Desc string `json:"desc" bson:"desc"` // 描述 + Keyword []string `json:"keyword" bson:"keyword"` // 页面关键字 + Label string `json:"label" bson:"label"` + Style string `json:"style" bson:"style"` + CardBackgroundImage string `json:"cardBackgroundImage" bson:"cardBackgroundImage"` + TopicIds []int64 `json:"topicIds" bson:"topicIds"` + CategoryId int64 `json:"categoryId" bson:"categoryId"` + RecommendType int64 `json:"recommendType" bson:"recommendType"` // 参考项目排序;0 沿用本项目 sortType + Type int32 `json:"type" bson:"type"` // 首页配置类型 1-短剧 3-题材 + Rank int64 `json:"rank" bson:"rank"` // 排序,越大越靠前 + Limit int64 `json:"limit" bson:"limit"` // 首页展示数量,0 时短剧默认12条、题材默认8个 + SortType int32 `json:"sortType" bson:"sortType"` // 短剧模块排序 1-推荐 2-热播 3-最近更新 4-上架时间 5-分类,题材模块预览按专题详情默认排序 + Status bool `json:"status" bson:"status"` // 上下架状态 true-上架 false-下架 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// HomeConfigList 首页配置列表 +type HomeConfigList []*HomeConfig + +// NormalizeKeywords 去掉空白项和重复项,始终返回非 nil 数组 +func NormalizeKeywords(items []string) []string { + out := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} diff --git a/pub/models/hotsearchmod/hotsearch.go b/pub/models/hotsearchmod/hotsearch.go new file mode 100644 index 0000000..24747f9 --- /dev/null +++ b/pub/models/hotsearchmod/hotsearch.go @@ -0,0 +1,105 @@ +package hotsearchmod + +import ( + "context" + "fmt" + "strings" + "time" + "unicode" + "unicode/utf8" + "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.HotSearch) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{{Key: FN.HotSearch.Keyword, Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.HotSearch.Count, Value: -1}, + {Key: FN.HotSearch.UpdatedAt, Value: -1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(hotsearchmod:Init) Create HotSearch Index failed! err:%+v:", err)) + } +} + +// NormalizeKeyword 去掉首尾空白、压缩中间空格,并限制长度。 +func NormalizeKeyword(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + fields := strings.FieldsFunc(raw, unicode.IsSpace) + raw = strings.Join(fields, " ") + if utf8.RuneCountInString(raw) > MaxKeywordRunes { + raw = string([]rune(raw)[:MaxKeywordRunes]) + } + return raw +} + +// IncrByKeyword 按搜索词累加次数,不存在则插入。 +func IncrByKeyword(ctx context.Context, keyword string) *srvdef.SrvErr { + keyword = NormalizeKeyword(keyword) + if keyword == "" { + return nil + } + now := time.Now() + filter := bson.M{FN.HotSearch.Keyword: keyword} + update := bson.M{ + "$inc": bson.M{FN.HotSearch.Count: 1}, + "$set": bson.M{FN.HotSearch.UpdatedAt: now}, + "$setOnInsert": bson.M{ + FN.HotSearch.Id: mongos.Lid.HotSearch.Get(), + FN.HotSearch.Keyword: keyword, + FN.HotSearch.CreatedAt: now, + }, + } + if _, err := db.UpsertOne(ctx, filter, update); err != nil { + if _, retryErr := db.UpdateOne(ctx, filter, bson.M{ + "$inc": bson.M{FN.HotSearch.Count: 1}, + "$set": bson.M{FN.HotSearch.UpdatedAt: now}, + }); retryErr != nil { + log.Warn("hotsearchmod:IncrByKeyword failed", log.E(err), log.E(retryErr), log.Any("keyword", keyword)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + } + return nil +} + +func FindTopHotSearch(ctx context.Context, limit int64) (HotSearchList, *srvdef.SrvErr) { + if limit <= 0 { + limit = TopLimit + } + opts := options.Find(). + SetSort(bson.D{ + {Key: FN.HotSearch.Count, Value: -1}, + {Key: FN.HotSearch.UpdatedAt, Value: -1}, + {Key: FN.HotSearch.Id, Value: -1}, + }). + SetLimit(limit). + SetProjection(bson.D{ + {Key: FN.HotSearch.Id, Value: 1}, + {Key: FN.HotSearch.Keyword, Value: 1}, + {Key: FN.HotSearch.Count, Value: 1}, + }) + data := make(HotSearchList, 0) + if err := db.Find(ctx, &data, bson.M{}, opts); err != nil { + log.Warn("hotsearchmod:FindTopHotSearch db.Find failed", log.E(err)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} diff --git a/pub/models/hotsearchmod/hotsearch_test.go b/pub/models/hotsearchmod/hotsearch_test.go new file mode 100644 index 0000000..e08de5a --- /dev/null +++ b/pub/models/hotsearchmod/hotsearch_test.go @@ -0,0 +1,31 @@ +package hotsearchmod + +import "testing" + +func TestNormalizeKeyword(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "空串", raw: " ", want: ""}, + {name: "压缩空白", raw: " 霸道 总裁\t ", want: "霸道 总裁"}, + {name: "截断长度", raw: stringsRepeat("测", 60), want: stringsRepeat("测", MaxKeywordRunes)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeKeyword(tt.raw); got != tt.want { + t.Fatalf("NormalizeKeyword() = %q, want %q", got, tt.want) + } + }) + } +} + +func stringsRepeat(s string, n int) string { + out := make([]rune, 0, n) + r := []rune(s)[0] + for i := 0; i < n; i++ { + out = append(out, r) + } + return string(out) +} diff --git a/pub/models/hotsearchmod/tag.go b/pub/models/hotsearchmod/tag.go new file mode 100644 index 0000000..b641e51 --- /dev/null +++ b/pub/models/hotsearchmod/tag.go @@ -0,0 +1,26 @@ +package hotsearchmod + +type TagHotSearch struct { + Id string + Keyword string + Count string + CreatedAt string + UpdatedAt string +} + +var FN struct { + HotSearch TagHotSearch +} + +var TN struct { + HotSearch string +} + +func init() { + FN.HotSearch.Id = "_id" + FN.HotSearch.Keyword = "keyword" + FN.HotSearch.Count = "count" + FN.HotSearch.CreatedAt = "createdAt" + FN.HotSearch.UpdatedAt = "updatedAt" + TN.HotSearch = "hot_search" +} diff --git a/pub/models/hotsearchmod/type.go b/pub/models/hotsearchmod/type.go new file mode 100644 index 0000000..780f102 --- /dev/null +++ b/pub/models/hotsearchmod/type.go @@ -0,0 +1,20 @@ +package hotsearchmod + +import "time" + +const ( + MaxKeywordRunes = 50 + TopLimit = 10 +) + +// HotSearch 热搜词 +// @table:hot_search +type HotSearch struct { + Id int64 `json:"id" bson:"_id,omitempty"` + Keyword string `json:"keyword" bson:"keyword"` // 搜索词 + Count int64 `json:"count" bson:"count"` // 搜索次数 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 首次记录时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 最近搜索时间 +} + +type HotSearchList []*HotSearch diff --git a/pub/models/ipblackmod/ipblack.go b/pub/models/ipblackmod/ipblack.go new file mode 100644 index 0000000..98de34b --- /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 +} diff --git a/pub/models/ipblackmod/tag.go b/pub/models/ipblackmod/tag.go new file mode 100755 index 0000000..6bf762a --- /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" +} diff --git a/pub/models/ipblackmod/type.go b/pub/models/ipblackmod/type.go new file mode 100644 index 0000000..8264bf8 --- /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 diff --git a/pub/models/ipdailystatmod/ipdailystat.go b/pub/models/ipdailystatmod/ipdailystat.go new file mode 100644 index 0000000..023d831 --- /dev/null +++ b/pub/models/ipdailystatmod/ipdailystat.go @@ -0,0 +1,59 @@ +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) + + 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) +} + +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 +} + +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..c9d72b4 --- /dev/null +++ b/pub/models/ipdailystatmod/type.go @@ -0,0 +1,12 @@ +package ipdailystatmod + +import "time" + +// IpDailyStat 每日访客 IP 去重统计。 +// @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..66c1d77 --- /dev/null +++ b/pub/models/models.go @@ -0,0 +1,113 @@ +package models + +import ( + "context" + "videoweb/pub/common/log" + "videoweb/pub/models/aicommentrecord" + "videoweb/pub/models/announcementmod" + "videoweb/pub/models/cfg" + "videoweb/pub/models/cointransmod" + "videoweb/pub/models/commentlikemod" + "videoweb/pub/models/commentmod" + "videoweb/pub/models/contactusmod" + "videoweb/pub/models/detailnavmod" + "videoweb/pub/models/esmod" + "videoweb/pub/models/eventmod" + "videoweb/pub/models/feedbackmod" + "videoweb/pub/models/homebannermod" + "videoweb/pub/models/homeconfigmod" + "videoweb/pub/models/hotsearchmod" + "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/playletchaptercollectmod" + "videoweb/pub/models/playletchapterlikemod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletchapterunlockmod" + "videoweb/pub/models/playletcollectmod" + "videoweb/pub/models/playletlikemod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/pub/models/playletthememod" + "videoweb/pub/models/playlettopicgroupmod" + "videoweb/pub/models/playletwatchhistorymod" + "videoweb/pub/models/playletwatchprogressmod" + "videoweb/pub/models/rechargepackagemod" + "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/uservisitlog" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videocollectmod" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/models/videolikemod" + "videoweb/pub/models/videotagmod" + "videoweb/pub/models/videotopicmod" + "videoweb/pub/models/videowatchhistorymod" + "videoweb/pub/models/vipordermod" +) + +func Init() { + log.Info("models init start..") + ctx := context.Background() + cfg.Init(ctx) + mwacc.Init(ctx) + oplog.Init(ctx) + m3u8requestlogmod.Init(ctx) + + syncfilemod.Init(ctx) + seopushmod.Init(ctx) + richtextcontentmod.Init(ctx) + announcementmod.Init(ctx) + hotsearchmod.Init(ctx) + homebannermod.Init(ctx) + homeconfigmod.Init(ctx) + commentmod.Init(ctx) + aicommentrecord.Init(ctx) + commentlikemod.Init(ctx) + ipblackmod.Init(ctx) + ipdailystatmod.Init(ctx) + esmod.Init(ctx) + detailnavmod.Init(ctx) + eventmod.Init(ctx) + translationcategorymod.Init(ctx) + translationmod.Init(ctx) + playletthememod.Init(ctx) + playlettopicgroupmod.Init(ctx) + playlettagmod.Init(ctx) + playletmod.Init(ctx) + playletbackflowstatmod.Init(ctx) + playletchaptermod.Init(ctx) + playletchapterlikemod.Init(ctx) + playletchaptercollectmod.Init(ctx) + playletchapterunlockmod.Init(ctx) + playletlikemod.Init(ctx) + playletcollectmod.Init(ctx) + playletwatchhistorymod.Init(ctx) + playletwatchprogressmod.Init(ctx) + feedbackmod.Init(ctx) + videocategorymod.Init(ctx) + videocreatermod.Init(ctx) + videotagmod.Init(ctx) + videoinformationmod.Init(ctx) + videolikemod.Init(ctx) + videocollectmod.Init(ctx) + videowatchhistorymod.Init(ctx) + videotopicmod.Init(ctx) + rechargepackagemod.Init(ctx) + cointransmod.Init(ctx) + contactusmod.Init(ctx) + vipordermod.Init(ctx) + usermod.Init(ctx) + userlogin.Init(ctx) + uservisitlog.Init(ctx) + log.Info("models init end") +} diff --git a/pub/models/mwacc/acc.go b/pub/models/mwacc/acc.go new file mode 100644 index 0000000..c120231 --- /dev/null +++ b/pub/models/mwacc/acc.go @@ -0,0 +1,169 @@ +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.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..1b13c5e --- /dev/null +++ b/pub/models/mwacc/web.go @@ -0,0 +1,52 @@ +package mwacc + +import ( + "time" +) + +type AccGetListRec struct { + Id int64 `bson:"_id"` // id + Username string `bson:"username"` // 用户名 + Acc string `bson:"acc"` // 用户名 + 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/playletchaptercollectmod/collect.go b/pub/models/playletchaptercollectmod/collect.go new file mode 100644 index 0000000..188a035 --- /dev/null +++ b/pub/models/playletchaptercollectmod/collect.go @@ -0,0 +1,115 @@ +package playletchaptercollectmod + +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_chapter_collect 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletChapterCollect) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletChapterCollect.UserId, Value: 1}, + {Key: FN.PlayletChapterCollect.ChapterId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapterCollect.ChapterId, Value: 1}, + {Key: FN.PlayletChapterCollect.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapterCollect.PlayletId, Value: 1}, + {Key: FN.PlayletChapterCollect.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapterCollect.UserId, Value: 1}, + {Key: FN.PlayletChapterCollect.CreatedAt, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletchaptercollectmod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// InsertOnePlayletChapterCollect 插入一条短剧剧集收藏关系 +func InsertOnePlayletChapterCollect(ctx context.Context, doc *PlayletChapterCollect) (bool, *srvdef.SrvErr) { + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletChapterCollect.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) { + return false, nil + } + log.Warn("插入短剧剧集收藏关系失败", log.Any("doc", doc), log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return true, nil +} + +// DeleteOnePlayletChapterCollect 删除一条短剧剧集收藏关系 +func DeleteOnePlayletChapterCollect(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("删除短剧剧集收藏关系失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DeleteManyPlayletChapterCollect 按条件批量删除短剧剧集收藏关系 +func DeleteManyPlayletChapterCollect(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 +} + +// FindManyPlayletChapterCollect 按条件查询短剧剧集收藏关系 +func FindManyPlayletChapterCollect(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletChapterCollectList, *srvdef.SrvErr) { + data := make(PlayletChapterCollectList, 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, "", "") + } + return data, nil +} + +// CountPlayletChapterCollect 统计满足条件的短剧剧集收藏关系数量 +func CountPlayletChapterCollect(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 +} diff --git a/pub/models/playletchaptercollectmod/tag.go b/pub/models/playletchaptercollectmod/tag.go new file mode 100644 index 0000000..a76d74d --- /dev/null +++ b/pub/models/playletchaptercollectmod/tag.go @@ -0,0 +1,30 @@ +package playletchaptercollectmod + +// TagPlayletChapterCollect 保存 playlet_chapter_collect 集合字段名 +type TagPlayletChapterCollect struct { + Id string + UserId string + PlayletId string + ChapterId string + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletChapterCollect TagPlayletChapterCollect +} + +var TN struct { + PlayletChapterCollect string +} + +func init() { + FN.PlayletChapterCollect.Id = "_id" + FN.PlayletChapterCollect.UserId = "userId" + FN.PlayletChapterCollect.PlayletId = "playletId" + FN.PlayletChapterCollect.ChapterId = "chapterId" + FN.PlayletChapterCollect.CreatedAt = "createdAt" + FN.PlayletChapterCollect.UpdatedAt = "updatedAt" + + TN.PlayletChapterCollect = "playlet_chapter_collect" +} diff --git a/pub/models/playletchaptercollectmod/type.go b/pub/models/playletchaptercollectmod/type.go new file mode 100644 index 0000000..d5ddbac --- /dev/null +++ b/pub/models/playletchaptercollectmod/type.go @@ -0,0 +1,18 @@ +package playletchaptercollectmod + +import "time" + +// PlayletChapterCollect @table: playlet_chapter_collect +// +// PlayletChapterCollect 短剧剧集收藏关系模型 +type PlayletChapterCollect struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 收藏记录ID + UserId int64 `json:"userId" bson:"userId"` // 用户ID + PlayletId int64 `json:"playletId" bson:"playletId"` // 短剧ID + ChapterId int64 `json:"chapterId" bson:"chapterId"` // 短剧剧集ID + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// PlayletChapterCollectList 短剧剧集收藏关系列表 +type PlayletChapterCollectList []*PlayletChapterCollect diff --git a/pub/models/playletchapterlikemod/like.go b/pub/models/playletchapterlikemod/like.go new file mode 100644 index 0000000..a34f9b8 --- /dev/null +++ b/pub/models/playletchapterlikemod/like.go @@ -0,0 +1,105 @@ +package playletchapterlikemod + +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_chapter_like 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletChapterLike) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletChapterLike.UserId, Value: 1}, + {Key: FN.PlayletChapterLike.ChapterId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapterLike.ChapterId, Value: 1}, + {Key: FN.PlayletChapterLike.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapterLike.PlayletId, Value: 1}, + {Key: FN.PlayletChapterLike.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapterLike.UserId, Value: 1}, + {Key: FN.PlayletChapterLike.CreatedAt, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletchapterlikemod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// InsertOnePlayletChapterLike 插入一条短剧剧集点赞关系 +func InsertOnePlayletChapterLike(ctx context.Context, doc *PlayletChapterLike) (bool, *srvdef.SrvErr) { + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletChapterLike.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) { + return false, nil + } + log.Warn("插入短剧剧集点赞关系失败", log.Any("doc", doc), log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return true, nil +} + +// DeleteOnePlayletChapterLike 删除一条短剧剧集点赞关系 +func DeleteOnePlayletChapterLike(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("删除短剧剧集点赞关系失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// DeleteManyPlayletChapterLike 按条件批量删除短剧剧集点赞关系 +func DeleteManyPlayletChapterLike(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 +} + +// FindManyPlayletChapterLike 按条件查询短剧剧集点赞关系 +func FindManyPlayletChapterLike(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletChapterLikeList, *srvdef.SrvErr) { + data := make(PlayletChapterLikeList, 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, "", "") + } + return data, nil +} diff --git a/pub/models/playletchapterlikemod/tag.go b/pub/models/playletchapterlikemod/tag.go new file mode 100644 index 0000000..91b7a72 --- /dev/null +++ b/pub/models/playletchapterlikemod/tag.go @@ -0,0 +1,30 @@ +package playletchapterlikemod + +// TagPlayletChapterLike 保存 playlet_chapter_like 集合字段名 +type TagPlayletChapterLike struct { + Id string + UserId string + PlayletId string + ChapterId string + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletChapterLike TagPlayletChapterLike +} + +var TN struct { + PlayletChapterLike string +} + +func init() { + FN.PlayletChapterLike.Id = "_id" + FN.PlayletChapterLike.UserId = "userId" + FN.PlayletChapterLike.PlayletId = "playletId" + FN.PlayletChapterLike.ChapterId = "chapterId" + FN.PlayletChapterLike.CreatedAt = "createdAt" + FN.PlayletChapterLike.UpdatedAt = "updatedAt" + + TN.PlayletChapterLike = "playlet_chapter_like" +} diff --git a/pub/models/playletchapterlikemod/type.go b/pub/models/playletchapterlikemod/type.go new file mode 100644 index 0000000..c5a13f6 --- /dev/null +++ b/pub/models/playletchapterlikemod/type.go @@ -0,0 +1,18 @@ +package playletchapterlikemod + +import "time" + +// PlayletChapterLike @table: playlet_chapter_like +// +// PlayletChapterLike 短剧剧集点赞关系模型 +type PlayletChapterLike struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 点赞记录ID + UserId int64 `json:"userId" bson:"userId"` // 用户ID + PlayletId int64 `json:"playletId" bson:"playletId"` // 短剧ID + ChapterId int64 `json:"chapterId" bson:"chapterId"` // 短剧剧集ID + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// PlayletChapterLikeList 短剧剧集点赞关系列表 +type PlayletChapterLikeList []*PlayletChapterLike diff --git a/pub/models/playletchaptermod/chapter.go b/pub/models/playletchaptermod/chapter.go new file mode 100644 index 0000000..8786235 --- /dev/null +++ b/pub/models/playletchaptermod/chapter.go @@ -0,0 +1,335 @@ +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 + +const duplicatePlayletChapterMsg = "短剧剧集已存在" + +// Init 初始化 playlet_chapter 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletChapter) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + commonIndexes := []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.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, commonIndexes); err != nil { + panic(fmt.Sprintf("(playletchaptermod:initIndex) Create Index failed! err:%+v:", err)) + } + + uniqueIndexes := []mongo.IndexModel{ + { + // 反向键序避免和线上已有 playletId_1_currentEpisode_1 普通索引冲突,同时兜住同一短剧同一集数的并发重复写入。 + Keys: bson.D{ + {Key: FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: FN.PlayletChapter.PlayletId, Value: 1}, + }, + Options: options.Index(). + SetName("uniq_playlet_chapter_episode"). + SetUnique(true), + }, + { + 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}, + }), + }, + } + for _, index := range uniqueIndexes { + if _, err := coll.CreateIndex(ctx, []mongo.IndexModel{index}); err != nil { + if isPlayletChapterDuplicateIndexErr(err) { + log.Warn("短剧章节唯一索引创建失败,请先清理历史重复章节数据", log.E(err), log.Any("index", index.Keys)) + continue + } + panic(fmt.Sprintf("(playletchaptermod:initIndex) Create Unique Index failed! err:%+v:", err)) + } + } +} + +// isPlayletChapterDuplicateIndexErr 判断唯一索引创建时是否命中历史重复数据。 +func isPlayletChapterDuplicateIndexErr(err error) bool { + if err == nil { + return false + } + return mongo.IsDuplicateKeyError(err) || strings.Contains(err.Error(), "E11000 duplicate key error") +} + +// 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 { + if mongo.IsDuplicateKeyError(err) { + return duplicatePlayletChapterErr() + } + 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 { + if mongo.IsDuplicateKeyError(err) { + return duplicatePlayletChapterErr() + } + log.Warn("playletchaptermod:InsertManyPlayletChapter failed", log.Any("count", len(data)), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// duplicatePlayletChapterErr 返回章节唯一约束冲突的统一业务错误。 +func duplicatePlayletChapterErr() *srvdef.SrvErr { + return srvdef.New(srvdef.ErrDbInputExist, duplicatePlayletChapterMsg, duplicatePlayletChapterMsg) +} + +// 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 +} + +type firstChapterRow struct { + PlayletId int64 `bson:"_id"` + ChapterId int64 `bson:"chapterId"` +} + +// FirstChapterIDs 按集数取每部短剧第一集的章节 ID,排序与详情页选集列表一致。 +func FirstChapterIDs(ctx context.Context, playletIDs []int64) (map[int64]int64, *srvdef.SrvErr) { + out := make(map[int64]int64, len(playletIDs)) + if len(playletIDs) == 0 { + return out, nil + } + const batchSize = 500 + for start := 0; start < len(playletIDs); start += batchSize { + end := start + batchSize + if end > len(playletIDs) { + end = len(playletIDs) + } + pipeline := []bson.M{ + {"$match": bson.M{ + FN.PlayletChapter.PlayletId: bson.M{"$in": playletIDs[start:end]}, + FN.PlayletChapter.Status: StatusUp, + }}, + {"$sort": bson.D{ + {Key: FN.PlayletChapter.CurrentEpisode, Value: 1}, + {Key: FN.PlayletChapter.Id, Value: 1}, + }}, + {"$group": bson.M{ + "_id": "$" + FN.PlayletChapter.PlayletId, + "chapterId": bson.M{"$first": "$_id"}, + }}, + } + rows := make([]firstChapterRow, 0) + if err := coll.Aggregate(ctx, &rows, pipeline); err != nil { + log.Warn("playletchaptermod:FirstChapterIDs failed", log.E(err), log.Any("count", end-start)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + for _, row := range rows { + if row.PlayletId > 0 && row.ChapterId > 0 { + out[row.PlayletId] = row.ChapterId + } + } + } + return out, 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 { + if mongo.IsDuplicateKeyError(err) { + return duplicatePlayletChapterErr() + } + 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 +} + +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 { + if mongo.IsDuplicateKeyError(err) { + return duplicatePlayletChapterErr() + } + 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..d7bf249 --- /dev/null +++ b/pub/models/playletchaptermod/tag.go @@ -0,0 +1,76 @@ +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 + Status string + PayType string + SingleCoins string + WatermarkStatus string + WatermarkRetryAt 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.Status = "status" + FN.PlayletChapter.PayType = "payType" + FN.PlayletChapter.SingleCoins = "singleCoins" + FN.PlayletChapter.WatermarkStatus = "watermarkStatus" + FN.PlayletChapter.WatermarkRetryAt = "watermarkRetryAt" + 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..322fd8b --- /dev/null +++ b/pub/models/playletchaptermod/type.go @@ -0,0 +1,87 @@ +package playletchaptermod + +import ( + "time" + "videoweb/pub/models/playletmod" + + "go.mongodb.org/mongo-driver/bson" +) + +// Status 剧集上架状态 +type Status int32 + +const ( + StatusDown Status = 0 // 下架 + StatusUp Status = 1 // 上架 +) + +// DefaultStatusByMediaSource 根据媒资来源生成默认上架状态 +func DefaultStatusByMediaSource(mediaSource string) Status { + if mediaSource == playletmod.MediaSourceHG { + return StatusDown + } + return StatusUp +} + +// VisibleStatusFilter 前台可见剧集过滤条件 +func VisibleStatusFilter() bson.M { + return bson.M{FN.PlayletChapter.Status: StatusUp} +} + +// 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"` // 不喜欢数 + Status Status `json:"status" bson:"status"` // 上架状态 0-下架 1-上架 + 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-处理失败 + WatermarkRetryAt time.Time `json:"watermarkRetryAt,omitempty" bson:"watermarkRetryAt,omitempty"` // 原片未处理完时的下次提交时间,到点前不参与 task/add + 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/playletchapterunlockmod/tag.go b/pub/models/playletchapterunlockmod/tag.go new file mode 100644 index 0000000..6848160 --- /dev/null +++ b/pub/models/playletchapterunlockmod/tag.go @@ -0,0 +1,34 @@ +package playletchapterunlockmod + +// TagPlayletChapterUnlock 保存 playlet_chapter_unlock 集合字段名。 +type TagPlayletChapterUnlock struct { + Id string + UserId string + PlayletId string + ChapterId string + Coins string + OrderNo string + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletChapterUnlock TagPlayletChapterUnlock +} + +var TN struct { + PlayletChapterUnlock string +} + +func init() { + FN.PlayletChapterUnlock.Id = "_id" + FN.PlayletChapterUnlock.UserId = "userId" + FN.PlayletChapterUnlock.PlayletId = "playletId" + FN.PlayletChapterUnlock.ChapterId = "chapterId" + FN.PlayletChapterUnlock.Coins = "coins" + FN.PlayletChapterUnlock.OrderNo = "orderNo" + FN.PlayletChapterUnlock.CreatedAt = "createdAt" + FN.PlayletChapterUnlock.UpdatedAt = "updatedAt" + + TN.PlayletChapterUnlock = "playlet_chapter_unlock" +} diff --git a/pub/models/playletchapterunlockmod/type.go b/pub/models/playletchapterunlockmod/type.go new file mode 100644 index 0000000..d9b374c --- /dev/null +++ b/pub/models/playletchapterunlockmod/type.go @@ -0,0 +1,19 @@ +package playletchapterunlockmod + +import "time" + +// PlayletChapterUnlock @table: playlet_chapter_unlock +// +// PlayletChapterUnlock 记录用户已用金币解锁的短剧剧集;一条记录代表该用户永久可观看该剧集。 +type PlayletChapterUnlock struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 解锁记录ID + UserId int64 `json:"userId" bson:"userId"` // 用户ID + PlayletId int64 `json:"playletId" bson:"playletId"` // 短剧ID + ChapterId int64 `json:"chapterId" bson:"chapterId"` // 短剧剧集ID + Coins int64 `json:"coins" bson:"coins"` // 解锁消耗金币 + OrderNo string `json:"orderNo" bson:"orderNo"` // 解锁流水号 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +type PlayletChapterUnlockList []*PlayletChapterUnlock diff --git a/pub/models/playletchapterunlockmod/unlock.go b/pub/models/playletchapterunlockmod/unlock.go new file mode 100644 index 0000000..bbe7fe0 --- /dev/null +++ b/pub/models/playletchapterunlockmod/unlock.go @@ -0,0 +1,132 @@ +package playletchapterunlockmod + +import ( + "context" + "fmt" + "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/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +// Init 初始化 playlet_chapter_unlock 集合和索引。 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletChapterUnlock) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletChapterUnlock.UserId, Value: 1}, + {Key: FN.PlayletChapterUnlock.ChapterId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapterUnlock.UserId, Value: 1}, + {Key: FN.PlayletChapterUnlock.PlayletId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletChapterUnlock.CreatedAt, Value: 1}, + {Key: FN.PlayletChapterUnlock.PlayletId, Value: 1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletchapterunlockmod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// InsertOnePlayletChapterUnlock 插入解锁记录。 +// +// 返回 inserted=false 表示该用户已经解锁过该剧集;调用方应将其视为幂等成功,不可继续扣费。 +func InsertOnePlayletChapterUnlock(ctx context.Context, doc *PlayletChapterUnlock) (bool, *srvdef.SrvErr) { + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletChapterUnlock.Get() + } + now := utils.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + if mongo.IsDuplicateKeyError(err) { + return false, nil + } + log.Warn("插入短剧剧集解锁记录失败", log.Any("doc", doc), log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return true, nil +} + +// ExistsPlayletChapterUnlock 判断用户是否已解锁指定剧集。 +func ExistsPlayletChapterUnlock(ctx context.Context, userId int64, chapterId int64) (bool, *srvdef.SrvErr) { + if userId <= 0 || chapterId <= 0 { + return false, nil + } + var doc PlayletChapterUnlock + err := coll.FindOneEx(ctx, &doc, bson.M{ + FN.PlayletChapterUnlock.UserId: userId, + FN.PlayletChapterUnlock.ChapterId: chapterId, + }, options.FindOne().SetProjection(bson.D{ + {Key: FN.PlayletChapterUnlock.Id, Value: 1}, + })) + if err != nil { + if err == mongo.ErrNoDocuments { + return false, nil + } + log.Warn("查询短剧剧集解锁记录失败", log.E(err), log.Any("userId", userId), log.Any("chapterId", chapterId)) + return false, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return doc.Id > 0, nil +} + +// DeleteManyPlayletChapterUnlock 按条件批量删除剧集解锁记录。 +func DeleteManyPlayletChapterUnlock(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 +} + +// FindManyPlayletChapterUnlock 按条件查询多条剧集解锁记录。 +func FindManyPlayletChapterUnlock(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletChapterUnlockList, *srvdef.SrvErr) { + data := make(PlayletChapterUnlockList, 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, "", "") + } + return data, nil +} + +// CountPlayletChapterUnlock 统计满足条件的剧集解锁记录数量 +func CountPlayletChapterUnlock(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 +} + +// AggregatePlayletChapterUnlock 聚合查询剧集解锁记录 +func AggregatePlayletChapterUnlock(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := coll.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("聚合短剧剧集解锁记录失败", log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/playletcollectmod/collect.go b/pub/models/playletcollectmod/collect.go new file mode 100644 index 0000000..b1b3c8a --- /dev/null +++ b/pub/models/playletcollectmod/collect.go @@ -0,0 +1,130 @@ +package playletcollectmod + +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_collect 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletCollect) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletCollect.UserId, Value: 1}, + {Key: FN.PlayletCollect.PlayletId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.PlayletCollect.PlayletId, Value: 1}, + {Key: FN.PlayletCollect.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletCollect.UserId, Value: 1}, + {Key: FN.PlayletCollect.CreatedAt, Value: -1}, + {Key: FN.PlayletCollect.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletCollect.CreatedAt, Value: 1}, + {Key: FN.PlayletCollect.PlayletId, Value: 1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletcollectmod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// InsertOnePlayletCollect 插入一条短剧收藏关系 +func InsertOnePlayletCollect(ctx context.Context, doc *PlayletCollect) (bool, *srvdef.SrvErr) { + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletCollect.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) { + return false, nil + } + log.Warn("插入短剧收藏关系失败", log.Any("doc", doc), log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return true, nil +} + +// ExistsPlayletCollect 查询短剧收藏关系是否存在 +func ExistsPlayletCollect(ctx context.Context, filter bson.M) (bool, *srvdef.SrvErr) { + var doc PlayletCollect + if err := coll.FindOneEx(ctx, &doc, filter, options.FindOne().SetProjection(bson.D{ + {Key: FN.PlayletCollect.Id, Value: 1}, + })); err != nil { + if err == mongo.ErrNoDocuments { + return false, nil + } + log.Warn("查询短剧收藏关系失败", log.E(err), log.Any("filter", filter)) + return false, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return true, nil +} + +// DeleteOnePlayletCollect 删除一条短剧收藏关系 +func DeleteOnePlayletCollect(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("删除短剧收藏关系失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindManyPlayletCollect 按条件查询短剧收藏关系列表 +func FindManyPlayletCollect(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletCollectList, *srvdef.SrvErr) { + data := make(PlayletCollectList, 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, "", "") + } + return data, nil +} + +// CountPlayletCollect 统计满足条件的短剧收藏关系数量 +func CountPlayletCollect(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 +} + +// AggregatePlayletCollect 聚合查询短剧收藏关系 +func AggregatePlayletCollect(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := coll.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("聚合短剧收藏关系失败", log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/playletcollectmod/tag.go b/pub/models/playletcollectmod/tag.go new file mode 100644 index 0000000..9d54a0b --- /dev/null +++ b/pub/models/playletcollectmod/tag.go @@ -0,0 +1,28 @@ +package playletcollectmod + +// TagPlayletCollect 保存 playlet_collect 集合字段名 +type TagPlayletCollect struct { + Id string + UserId string + PlayletId string + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletCollect TagPlayletCollect +} + +var TN struct { + PlayletCollect string +} + +func init() { + FN.PlayletCollect.Id = "_id" + FN.PlayletCollect.UserId = "userId" + FN.PlayletCollect.PlayletId = "playletId" + FN.PlayletCollect.CreatedAt = "createdAt" + FN.PlayletCollect.UpdatedAt = "updatedAt" + + TN.PlayletCollect = "playlet_collect" +} diff --git a/pub/models/playletcollectmod/type.go b/pub/models/playletcollectmod/type.go new file mode 100644 index 0000000..a715685 --- /dev/null +++ b/pub/models/playletcollectmod/type.go @@ -0,0 +1,17 @@ +package playletcollectmod + +import "time" + +// PlayletCollect @table: playlet_collect +// +// PlayletCollect 短剧收藏关系模型 +type PlayletCollect struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 收藏记录ID + 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"` // 修改时间 +} + +// PlayletCollectList 短剧收藏关系列表 +type PlayletCollectList []*PlayletCollect diff --git a/pub/models/playletlikemod/like.go b/pub/models/playletlikemod/like.go new file mode 100644 index 0000000..613e5cb --- /dev/null +++ b/pub/models/playletlikemod/like.go @@ -0,0 +1,130 @@ +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}, + {Key: FN.PlayletLike.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletLike.UserId, Value: 1}, + {Key: FN.PlayletLike.CreatedAt, Value: -1}, + {Key: FN.PlayletLike.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletLike.CreatedAt, Value: 1}, + {Key: FN.PlayletLike.PlayletId, Value: 1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletlikemod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// InsertOnePlayletLike 插入一条短剧点赞关系 +func InsertOnePlayletLike(ctx context.Context, doc *PlayletLike) (bool, *srvdef.SrvErr) { + if doc.Id == 0 { + doc.Id = mongos.Lid.PlayletLike.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) { + return false, nil + } + log.Warn("插入短剧点赞关系失败", log.Any("doc", doc), log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return true, nil +} + +// ExistsPlayletLike 查询短剧点赞关系是否存在 +func ExistsPlayletLike(ctx context.Context, filter bson.M) (bool, *srvdef.SrvErr) { + var doc PlayletLike + if err := coll.FindOneEx(ctx, &doc, filter, options.FindOne().SetProjection(bson.D{ + {Key: FN.PlayletLike.Id, Value: 1}, + })); err != nil { + if err == mongo.ErrNoDocuments { + return false, nil + } + log.Warn("查询短剧点赞关系失败", log.E(err), log.Any("filter", filter)) + return false, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return true, nil +} + +// FindManyPlayletLike 按条件查询短剧点赞关系列表 +func FindManyPlayletLike(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletLikeList, *srvdef.SrvErr) { + list := make(PlayletLikeList, 0) + if err := coll.Find(ctx, &list, filter, opts...); err != nil { + log.Warn("查询短剧点赞关系列表失败", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return list, nil +} + +// CountPlayletLike 统计短剧点赞关系数量 +func CountPlayletLike(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter) + if err != nil { + log.Warn("统计短剧点赞关系数量失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// DeleteOnePlayletLike 删除一条短剧点赞关系 +func DeleteOnePlayletLike(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("删除短剧点赞关系失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + 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("聚合短剧点赞关系失败", log.E(err), log.Any("pipeline", pipeline)) + 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..677e519 --- /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..4b4aad9 --- /dev/null +++ b/pub/models/playletlikemod/type.go @@ -0,0 +1,17 @@ +package playletlikemod + +import "time" + +// PlayletLike @table: playlet_like +// +// PlayletLike 短剧点赞关系模型 +type PlayletLike struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 点赞记录ID + 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..5bfacbf --- /dev/null +++ b/pub/models/playletmod/category_bind.go @@ -0,0 +1,144 @@ +package playletmod + +import "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}, + } +} diff --git a/pub/models/playletmod/playlet.go b/pub/models/playletmod/playlet.go new file mode 100644 index 0000000..158450f --- /dev/null +++ b/pub/models/playletmod/playlet.go @@ -0,0 +1,333 @@ +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.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.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.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}, + }, + }, + { + 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.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.Slug, 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}, + }), + }, + { + Keys: bson.D{ + {Key: FN.Playlet.OperateType, Value: 1}, + {Key: FN.Playlet.Status, Value: 1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.Playlet.ContentType, Value: 1}, + {Key: FN.Playlet.CreatedAt, Value: -1}, + {Key: FN.Playlet.Id, Value: -1}, + }, + Options: options.Index(). + 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 +} + +// DelManyPlaylet 按条件批量删除短剧数据 +func DelManyPlaylet(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteMany(ctx, filter) + if err != nil { + log.Warn("playletmod:DelManyPlaylet 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 +} + +// FindManyPlayletByCategoryRank 按指定分类的排序分页查询短剧 +func FindManyPlayletByCategoryRank(ctx context.Context, filter bson.M, categoryId int64, skip, limit int64, sort bson.D, projection bson.D) (PlayletList, *srvdef.SrvErr) { + if skip < 0 { + skip = 0 + } + if limit <= 0 { + limit = 10 + } + if sort == nil { + sort = CategoryRankSort() + } + pipeline := []bson.M{ + {"$match": filter}, + {"$addFields": CategoryRankAddFields(categoryId)}, + {"$sort": sort}, + {"$skip": skip}, + {"$limit": limit}, + } + pipeline = append(pipeline, bson.M{"$unset": categoryRankField}) + if len(projection) > 0 { + proj := bson.M{} + for _, field := range projection { + proj[field.Key] = field.Value + } + pipeline = append(pipeline, bson.M{"$project": proj}) + } + list := make(PlayletList, 0) + if err := AggregatePlaylet(ctx, &list, pipeline); err != nil { + return nil, err + } + return list, 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 +} + +// 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 +} + +// AggregatePlaylet 聚合查询短剧 +func AggregatePlaylet(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := coll.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("playletmod:AggregatePlaylet failed", log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} diff --git a/pub/models/playletmod/tag.go b/pub/models/playletmod/tag.go new file mode 100644 index 0000000..ac08eba --- /dev/null +++ b/pub/models/playletmod/tag.go @@ -0,0 +1,132 @@ +package playletmod + +// TagPlaylet 保存 playlet 集合字段名 +type TagPlaylet struct { + Id string + MediaCenterId string + MediaSource string + MediaCreatedAt string + MediaUpdatedAt string + MediaSyncTime string + Status string + IsFeatured string + IsOriginal string + ShowOriginal string + MediaBusiness string + IsRecommend string + ShowOnBanner string + Banner string + MobileBanner string + ReviewAt string + OnlineAt string + ThemeIds string + CategoryIds string + CategoryBinds string + Title string + PlayName string + Description string + Slug string + Desc string + Anchor string + Cover string + HorizontalCover string + CompressedCover string + CompressedHorizontalCover string + Preview string + TagIds string + ContentType string + OperateType string + PayType string + SingleCoins string + TotalEpisode string + UpdateStatus string + TopRank string + RecommendRank string + CountComment string + CountBuy string + CountBrowse string + HeatValue 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.MediaSource = "mediaSource" + 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.MobileBanner = "mobileBanner" + FN.Playlet.ReviewAt = "reviewAt" + FN.Playlet.OnlineAt = "onlineAt" + FN.Playlet.ThemeIds = "themeIds" + FN.Playlet.CategoryIds = "categoryIds" + FN.Playlet.CategoryBinds = "categoryBinds" + FN.Playlet.Title = "title" + FN.Playlet.PlayName = "playName" + FN.Playlet.Description = "description" + FN.Playlet.Slug = "slug" + FN.Playlet.Desc = "desc" + FN.Playlet.Anchor = "anchor" + FN.Playlet.Cover = "cover" + FN.Playlet.HorizontalCover = "horizontalCover" + FN.Playlet.CompressedCover = "compressedCover" + FN.Playlet.CompressedHorizontalCover = "compressedHorizontalCover" + FN.Playlet.Preview = "preview" + FN.Playlet.TagIds = "tagIds" + FN.Playlet.ContentType = "contentType" + FN.Playlet.OperateType = "operateType" + FN.Playlet.PayType = "payType" + FN.Playlet.SingleCoins = "singleCoins" + FN.Playlet.TotalEpisode = "totalEpisode" + 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.HeatValue = "heatValue" + 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..f245894 --- /dev/null +++ b/pub/models/playletmod/type.go @@ -0,0 +1,166 @@ +package playletmod + +import ( + "strconv" + "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 +) + +// OperateType 运营类型 +type OperateType int32 + +const ( + OperateTypePlaylet OperateType = 1 // 短剧 + OperateTypeComic OperateType = 2 // 漫剧 +) + +// NormalizeOperateType 历史数据未写入时按短剧处理 +func NormalizeOperateType(t OperateType) OperateType { + if t == OperateTypeComic { + return OperateTypeComic + } + return OperateTypePlaylet +} + +// IsValidOperateType 校验运营类型 +func IsValidOperateType(t OperateType) bool { + return t == OperateTypePlaylet || t == OperateTypeComic +} + +const ( + UpdateStatusSerial = 1 // 连载中 + UpdateStatusDone = 2 // 已完结 +) + +const ( + MediaSourceDefault = "" // 普通(嘉华)媒资 + MediaSourceHG = "hg" // 黄果媒资 +) + +// BuildPlayNameDescription 按剧名和连载状态生成 SEO Description。 +func BuildPlayNameDescription(desc, playName string, updateStatus int, totalEpisode int64) string { + playName = strings.TrimSpace(playName) + if playName == "" { + return "" + } + desc = strings.TrimSpace(desc) + siteName := pubdef.AppName + if updateStatus == UpdateStatusDone { + return desc + siteName + "提供《" + playName + "》全集在线观看、剧情介绍及完整选集,并持续更新相关热门短剧。" + } + return desc + siteName + "提供《" + playName + "》最新剧集、剧情介绍及在线观看,当前更新至第" + strconv.FormatInt(totalEpisode, 10) + "集,后续内容持续更新。" +} + +// Playlet @table: playlet +// +// Playlet 连载短剧集合模型 +type Playlet struct { + Id int64 `json:"id" bson:"_id,omitempty"` + MediaCenterId uint `json:"mediaCenterId" bson:"mediaCenterId"` // 媒资中心资源ID + MediaSource string `json:"mediaSource" bson:"mediaSource"` // 媒资来源,空值为普通媒资 + 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图 + MobileBanner string `json:"mobileBanner" bson:"mobileBanner"` // 移动端banner图 + ReviewAt time.Time `json:"reviewAt" bson:"reviewAt"` // 审核时间 + OnlineAt time.Time `json:"onlineAt" bson:"onlineAt"` // 上架时间 + ThemeIds []int64 `json:"themeIds" bson:"themeIds"` // 题材ID集合 + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类ID集合 + CategoryBinds []PlayletCategoryBind `json:"categoryBinds" bson:"categoryBinds"` // 分类绑定及排序 + Title string `json:"title" bson:"title"` // 标题 + PlayName string `json:"playName" bson:"playName"` // 剧名,后台维护 + Description string `json:"description" bson:"description"` // SEO Description,按剧名规则自动生成 + 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"` // 封面原图 + HorizontalCover string `json:"horizontalCover" bson:"horizontalCover"` // 横版封面 + CompressedCover string `json:"compressedCover" bson:"compressedCover"` // 压缩封面,压缩成功才有值 + CompressedHorizontalCover string `json:"compressedHorizontalCover" bson:"compressedHorizontalCover"` // 压缩横版封面,压缩成功才有值 + Preview string `json:"preview" bson:"preview"` // 预告地址 + TagIds []int64 `json:"tagIds" bson:"tagIds"` // 标签ID + ContentType ContentType `json:"contentType" bson:"contentType"` // 内容类型: 0短剧 1普通短剧/影集 2漫画短剧 3动漫 4漫画 + OperateType OperateType `json:"operateType" bson:"operateType"` // 运营类型: 1短剧 2漫剧 + PayType PayType `json:"payType" bson:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins" bson:"singleCoins"` // 单集金币(支付方式为1-金币时候生效) + TotalEpisode int64 `json:"totalEpisode" bson:"totalEpisode"` // 总集数 + 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"` // 浏览数 + HeatValue int64 `json:"heatValue" bson:"heatValue"` // 热度值 + 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"` // 修改时间 +} + +// PlayletList 短剧列表 +type PlayletList []*Playlet + +// 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) +} + +// DisplayHorizontalCover App 展示用横版封面:压缩图有值则优先,否则回退原图 +func (p *Playlet) DisplayHorizontalCover() string { + if p == nil { + return "" + } + if cover := strings.TrimSpace(p.CompressedHorizontalCover); cover != "" { + return cover + } + return strings.TrimSpace(p.HorizontalCover) +} diff --git a/pub/models/playlettagmod/playlettag.go b/pub/models/playlettagmod/playlettag.go new file mode 100644 index 0000000..7fcf214 --- /dev/null +++ b/pub/models/playlettagmod/playlettag.go @@ -0,0 +1,126 @@ +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.Slug, 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..39bf6ca --- /dev/null +++ b/pub/models/playlettagmod/tag.go @@ -0,0 +1,40 @@ +package playlettagmod + +// TagPlayletTag 保存 playlet_tag 集合字段名 +type TagPlayletTag struct { + Id string + Name string + Slug 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.Slug = "slug" + 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..9e5a3b6 --- /dev/null +++ b/pub/models/playlettagmod/type.go @@ -0,0 +1,23 @@ +package playlettagmod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// PlayletTag @table: playlet_tag +// +// PlayletTag 短剧标签集合模型 +type PlayletTag 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"` // 上架状态 + 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..d8de4ec --- /dev/null +++ b/pub/models/playletthememod/tag.go @@ -0,0 +1,44 @@ +package playletthememod + +// TagPlayletTheme 保存 playlet_theme 集合字段名 +type TagPlayletTheme struct { + Id string + Name string + Slug string + Icon string + Status string + IsRecommend 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.IsRecommend = "isRecommend" + 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..0a39707 --- /dev/null +++ b/pub/models/playletthememod/theme.go @@ -0,0 +1,143 @@ +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.IsRecommend, Value: 1}, + {Key: FN.PlayletTheme.Status, Value: 1}, + {Key: FN.PlayletTheme.Rank, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletTheme.Name, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletTheme.Slug, 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 +} + +// UpdateManyPlayletTheme 批量更新题材数据 +func UpdateManyPlayletTheme(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + _, err := coll.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("playletthememod:UpdateManyPlayletTheme failed", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} diff --git a/pub/models/playletthememod/type.go b/pub/models/playletthememod/type.go new file mode 100644 index 0000000..57c389d --- /dev/null +++ b/pub/models/playletthememod/type.go @@ -0,0 +1,25 @@ +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"` // 上架状态 + IsRecommend bool `json:"isRecommend" bson:"isRecommend"` // 是否推荐 + 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/playlettopicgroupmod/group.go b/pub/models/playlettopicgroupmod/group.go new file mode 100644 index 0000000..9336f53 --- /dev/null +++ b/pub/models/playlettopicgroupmod/group.go @@ -0,0 +1,113 @@ +package playlettopicgroupmod + +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 初始化专题区块集合 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll("playlet_topic_group") + _, err := coll.CreateIndex(ctx, []mongo.IndexModel{{Keys: bson.D{ + {Key: "status", Value: 1}, {Key: "rank", Value: -1}, {Key: "_id", Value: -1}, + }}}) + if err != nil { + panic(fmt.Sprintf("playlettopicgroupmod.Init index failed: %v", err)) + } +} + +// Insert 新增专题区块 +func Insert(ctx context.Context, doc *PlayletTopicGroup) *srvdef.SrvErr { + doc.Id = mongos.Lid.PlayletTopicGroup.Get() + now := time.Now() + doc.CreatedAt, doc.UpdatedAt = now, now + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("playlettopicgroupmod.Insert failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindOne 按ID查询专题区块 +func FindOne(ctx context.Context, id int64) (*PlayletTopicGroup, *srvdef.SrvErr) { + var doc PlayletTopicGroup + if err := coll.FindOneEx(ctx, &doc, bson.M{"_id": id}); err != nil { + if err == mongo.ErrNoDocuments { + return nil, srvdef.New(srvdef.ErrNoData, "专题区块不存在", "") + } + log.Warn("playlettopicgroupmod.FindOne failed", log.E(err)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return &doc, nil +} + +// FindMany 查询专题区块列表 +func FindMany(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletTopicGroupList, *srvdef.SrvErr) { + list := make(PlayletTopicGroupList, 0) + opt := options.Find().SetSort(bson.D{ + {Key: "rank", Value: -1}, {Key: "_id", Value: -1}, + }) + for _, extra := range opts { + if extra == nil { + continue + } + if extra.Skip != nil { + opt.SetSkip(*extra.Skip) + } + if extra.Limit != nil { + opt.SetLimit(*extra.Limit) + } + } + if err := coll.Find(ctx, &list, filter, opt); err != nil { + log.Warn("playlettopicgroupmod.FindMany failed", log.E(err)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return list, nil +} + +// Count 统计符合条件的专题区块数量 +func Count(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter) + if err != nil { + log.Warn("playlettopicgroupmod.Count failed", log.E(err)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// Update 更新专题区块 +func Update(ctx context.Context, id int64, set bson.M) *srvdef.SrvErr { + set["updatedAt"] = time.Now() + result, err := coll.UpdateOne(ctx, bson.M{"_id": id}, bson.M{"$set": set}) + if err != nil { + log.Warn("playlettopicgroupmod.Update failed", log.E(err)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount == 0 { + return srvdef.New(srvdef.ErrNoData, "专题区块不存在", "") + } + return nil +} + +// Delete 删除专题区块 +func Delete(ctx context.Context, id int64) *srvdef.SrvErr { + count, err := coll.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + log.Warn("playlettopicgroupmod.Delete failed", log.E(err)) + return srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "专题区块不存在", "") + } + return nil +} diff --git a/pub/models/playlettopicgroupmod/type.go b/pub/models/playlettopicgroupmod/type.go new file mode 100644 index 0000000..a21a597 --- /dev/null +++ b/pub/models/playlettopicgroupmod/type.go @@ -0,0 +1,17 @@ +package playlettopicgroupmod + +import "time" + +// PlayletTopicGroup 专题页区块 +type PlayletTopicGroup struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 专题区块ID + Name string `json:"name" bson:"name"` // 专题区块名称 + Desc string `json:"desc" bson:"desc"` // 专题区块描述 + TopicIds []int64 `json:"topicIds" bson:"topicIds"` // 关联专题ID,数组顺序即展示顺序 + Rank int64 `json:"rank" bson:"rank"` // 区块排序,越大越靠前 + Status bool `json:"status" bson:"status"` // 是否启用 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} + +type PlayletTopicGroupList []*PlayletTopicGroup diff --git a/pub/models/playletwatchhistorymod/history.go b/pub/models/playletwatchhistorymod/history.go new file mode 100644 index 0000000..58add32 --- /dev/null +++ b/pub/models/playletwatchhistorymod/history.go @@ -0,0 +1,109 @@ +package playletwatchhistorymod + +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 + +const playletWatchHistoryTTLSeconds int32 = 90 * 24 * 60 * 60 + +// Init 初始化 playlet_watch_history 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletWatchHistory) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletWatchHistory.UserId, Value: 1}, + {Key: FN.PlayletWatchHistory.PlayletId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.PlayletWatchHistory.UserId, Value: 1}, + {Key: FN.PlayletWatchHistory.UpdatedAt, Value: -1}, + {Key: FN.PlayletWatchHistory.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletWatchHistory.UpdatedAt, Value: 1}, + }, + Options: options.Index().SetExpireAfterSeconds(playletWatchHistoryTTLSeconds), + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletwatchhistorymod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// FindManyPlayletWatchHistory 按条件查询用户短剧观看历史 +func FindManyPlayletWatchHistory(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletWatchHistoryList, *srvdef.SrvErr) { + data := make(PlayletWatchHistoryList, 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, "", "") + } + return data, nil +} + +// CountPlayletWatchHistory 统计满足条件的短剧观看历史数量 +func CountPlayletWatchHistory(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 +} + +// SavePlayletWatchHistory 保存用户短剧观看历史 +func SavePlayletWatchHistory(ctx context.Context, userId int64, playletId int64, chapterId int64, currentEpisode int64) *srvdef.SrvErr { + now := time.Now() + filter := bson.M{ + FN.PlayletWatchHistory.UserId: userId, + FN.PlayletWatchHistory.PlayletId: playletId, + } + update := bson.M{ + "$set": bson.M{ + FN.PlayletWatchHistory.LastChapterId: chapterId, + FN.PlayletWatchHistory.LastEpisode: currentEpisode, + FN.PlayletWatchHistory.UpdatedAt: now, + }, + "$setOnInsert": bson.M{ + FN.PlayletWatchHistory.Id: mongos.Lid.PlayletWatchHistory.Get(), + FN.PlayletWatchHistory.UserId: userId, + FN.PlayletWatchHistory.PlayletId: playletId, + FN.PlayletWatchHistory.CreatedAt: now, + }, + } + if _, err := coll.UpsertOne(ctx, filter, update); err != nil { + log.Warn("保存短剧观看历史失败", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// DeleteManyPlayletWatchHistory 删除满足条件的短剧观看历史 +func DeleteManyPlayletWatchHistory(ctx context.Context, filter bson.M, opts ...*options.DeleteOptions) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteMany(ctx, filter, opts...) + if err != nil { + log.Warn("删除短剧观看历史失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} diff --git a/pub/models/playletwatchhistorymod/tag.go b/pub/models/playletwatchhistorymod/tag.go new file mode 100644 index 0000000..132d6dc --- /dev/null +++ b/pub/models/playletwatchhistorymod/tag.go @@ -0,0 +1,32 @@ +package playletwatchhistorymod + +// TagPlayletWatchHistory 保存 playlet_watch_history 集合字段名 +type TagPlayletWatchHistory struct { + Id string + UserId string + PlayletId string + LastChapterId string + LastEpisode string + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletWatchHistory TagPlayletWatchHistory +} + +var TN struct { + PlayletWatchHistory string +} + +func init() { + FN.PlayletWatchHistory.Id = "_id" + FN.PlayletWatchHistory.UserId = "userId" + FN.PlayletWatchHistory.PlayletId = "playletId" + FN.PlayletWatchHistory.LastChapterId = "lastChapterId" + FN.PlayletWatchHistory.LastEpisode = "lastEpisode" + FN.PlayletWatchHistory.CreatedAt = "createdAt" + FN.PlayletWatchHistory.UpdatedAt = "updatedAt" + + TN.PlayletWatchHistory = "playlet_watch_history" +} diff --git a/pub/models/playletwatchhistorymod/type.go b/pub/models/playletwatchhistorymod/type.go new file mode 100644 index 0000000..e0bbb8b --- /dev/null +++ b/pub/models/playletwatchhistorymod/type.go @@ -0,0 +1,19 @@ +package playletwatchhistorymod + +import "time" + +// PlayletWatchHistory @table: playlet_watch_history +// +// PlayletWatchHistory 用户短剧观看历史模型,用于观看历史列表和清空历史。 +type PlayletWatchHistory struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 观看历史记录ID + UserId int64 `json:"userId" bson:"userId"` // 用户ID + PlayletId int64 `json:"playletId" bson:"playletId"` // 短剧ID + LastChapterId int64 `json:"lastChapterId" bson:"lastChapterId"` // 最近观看剧集ID + LastEpisode int64 `json:"lastEpisode" bson:"lastEpisode"` // 最近观看集数 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// PlayletWatchHistoryList 用户短剧观看历史列表 +type PlayletWatchHistoryList []*PlayletWatchHistory diff --git a/pub/models/playletwatchprogressmod/progress.go b/pub/models/playletwatchprogressmod/progress.go new file mode 100644 index 0000000..836d5b0 --- /dev/null +++ b/pub/models/playletwatchprogressmod/progress.go @@ -0,0 +1,155 @@ +package playletwatchprogressmod + +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_watch_progress 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.PlayletWatchProgress) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.PlayletWatchProgress.UserId, Value: 1}, + {Key: FN.PlayletWatchProgress.PlayletId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.PlayletWatchProgress.UserId, Value: 1}, + {Key: FN.PlayletWatchProgress.UpdatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletWatchProgress.UserId, Value: 1}, + {Key: FN.PlayletWatchProgress.IsChasing, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.PlayletWatchProgress.PlayletId, Value: 1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(playletwatchprogressmod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// FindManyPlayletWatchProgress 按条件查询用户短剧观看进度 +func FindManyPlayletWatchProgress(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (PlayletWatchProgressList, *srvdef.SrvErr) { + data := make(PlayletWatchProgressList, 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, "", "") + } + return data, nil +} + +// CountPlayletWatchProgress 统计满足条件的短剧观看进度数量 +func CountPlayletWatchProgress(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 +} + +// SavePlayletWatchProgress 保存用户短剧观看进度,并在达到已看条件时维护已看集数集合。 +func SavePlayletWatchProgress(ctx context.Context, userId int64, playletId int64, chapterId int64, currentEpisode int64, totalEpisode int64, markWatched bool) (*PlayletWatchProgress, *srvdef.SrvErr) { + filter := bson.M{ + FN.PlayletWatchProgress.UserId: userId, + FN.PlayletWatchProgress.PlayletId: playletId, + } + doc, err := findOnePlayletWatchProgress(ctx, filter) + if err != nil && err.Code != srvdef.ErrNoData { + return nil, err + } + now := time.Now() + if doc == nil { + doc = &PlayletWatchProgress{ + Id: mongos.Lid.PlayletWatchProgress.Get(), + UserId: userId, + PlayletId: playletId, + WatchedEpisodes: make([]int64, 0), + CreatedAt: now, + } + } + doc.LastChapterId = chapterId + doc.LastEpisode = currentEpisode + doc.UpdatedAt = now + if markWatched && !hasEpisode(doc.WatchedEpisodes, currentEpisode) { + doc.WatchedEpisodes = append(doc.WatchedEpisodes, currentEpisode) + } + doc.WatchedCount = int64(len(doc.WatchedEpisodes)) + doc.TotalEpisode = totalEpisode + doc.IsChasing = totalEpisode > 0 && doc.WatchedCount < totalEpisode + update := bson.M{ + "$set": bson.M{ + FN.PlayletWatchProgress.LastChapterId: doc.LastChapterId, + FN.PlayletWatchProgress.LastEpisode: doc.LastEpisode, + FN.PlayletWatchProgress.WatchedEpisodes: doc.WatchedEpisodes, + FN.PlayletWatchProgress.WatchedCount: doc.WatchedCount, + FN.PlayletWatchProgress.TotalEpisode: doc.TotalEpisode, + FN.PlayletWatchProgress.IsChasing: doc.IsChasing, + FN.PlayletWatchProgress.UpdatedAt: doc.UpdatedAt, + }, + "$setOnInsert": bson.M{ + FN.PlayletWatchProgress.Id: doc.Id, + FN.PlayletWatchProgress.UserId: doc.UserId, + FN.PlayletWatchProgress.PlayletId: doc.PlayletId, + FN.PlayletWatchProgress.CreatedAt: doc.CreatedAt, + }, + } + if _, e := coll.UpsertOne(ctx, filter, update); e != nil { + log.Warn("保存短剧观看进度失败", log.E(e), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return doc, nil +} + +func findOnePlayletWatchProgress(ctx context.Context, filter bson.M) (*PlayletWatchProgress, *srvdef.SrvErr) { + var doc PlayletWatchProgress + if err := coll.FindOneEx(ctx, &doc, filter, options.FindOne().SetProjection(bson.D{ + {Key: FN.PlayletWatchProgress.Id, Value: 1}, + {Key: FN.PlayletWatchProgress.UserId, Value: 1}, + {Key: FN.PlayletWatchProgress.PlayletId, Value: 1}, + {Key: FN.PlayletWatchProgress.WatchedEpisodes, Value: 1}, + {Key: FN.PlayletWatchProgress.WatchedCount, Value: 1}, + {Key: FN.PlayletWatchProgress.CreatedAt, Value: 1}, + })); err != nil { + if err == mongo.ErrNoDocuments { + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + log.Warn("查询短剧观看进度失败", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return &doc, nil +} + +func hasEpisode(episodes []int64, target int64) bool { + for _, episode := range episodes { + if episode == target { + return true + } + } + return false +} diff --git a/pub/models/playletwatchprogressmod/tag.go b/pub/models/playletwatchprogressmod/tag.go new file mode 100644 index 0000000..f759da2 --- /dev/null +++ b/pub/models/playletwatchprogressmod/tag.go @@ -0,0 +1,40 @@ +package playletwatchprogressmod + +// TagPlayletWatchProgress 保存 playlet_watch_progress 集合字段名 +type TagPlayletWatchProgress struct { + Id string + UserId string + PlayletId string + LastChapterId string + LastEpisode string + WatchedEpisodes string + WatchedCount string + TotalEpisode string + IsChasing string + CreatedAt string + UpdatedAt string +} + +var FN struct { + PlayletWatchProgress TagPlayletWatchProgress +} + +var TN struct { + PlayletWatchProgress string +} + +func init() { + FN.PlayletWatchProgress.Id = "_id" + FN.PlayletWatchProgress.UserId = "userId" + FN.PlayletWatchProgress.PlayletId = "playletId" + FN.PlayletWatchProgress.LastChapterId = "lastChapterId" + FN.PlayletWatchProgress.LastEpisode = "lastEpisode" + FN.PlayletWatchProgress.WatchedEpisodes = "watchedEpisodes" + FN.PlayletWatchProgress.WatchedCount = "watchedCount" + FN.PlayletWatchProgress.TotalEpisode = "totalEpisode" + FN.PlayletWatchProgress.IsChasing = "isChasing" + FN.PlayletWatchProgress.CreatedAt = "createdAt" + FN.PlayletWatchProgress.UpdatedAt = "updatedAt" + + TN.PlayletWatchProgress = "playlet_watch_progress" +} diff --git a/pub/models/playletwatchprogressmod/type.go b/pub/models/playletwatchprogressmod/type.go new file mode 100644 index 0000000..f048151 --- /dev/null +++ b/pub/models/playletwatchprogressmod/type.go @@ -0,0 +1,23 @@ +package playletwatchprogressmod + +import "time" + +// PlayletWatchProgress @table: playlet_watch_progress +// +// PlayletWatchProgress 用户短剧观看进度模型,用于收藏列表计算未看集数和恢复上次观看位置。 +type PlayletWatchProgress struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 观看进度记录ID + UserId int64 `json:"userId" bson:"userId"` // 用户ID + PlayletId int64 `json:"playletId" bson:"playletId"` // 短剧ID + LastChapterId int64 `json:"lastChapterId" bson:"lastChapterId"` // 最近观看剧集ID + LastEpisode int64 `json:"lastEpisode" bson:"lastEpisode"` // 最近观看集数 + WatchedEpisodes []int64 `json:"watchedEpisodes" bson:"watchedEpisodes"` // 已看集数集合 + WatchedCount int64 `json:"watchedCount" bson:"watchedCount"` // 已看集数冗余值 + TotalEpisode int64 `json:"totalEpisode" bson:"totalEpisode"` // 短剧总集数快照 + IsChasing bool `json:"isChasing" bson:"isChasing"` // 是否追剧中 true-未看完 false-已看完 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 修改时间 +} + +// PlayletWatchProgressList 用户短剧观看进度列表 +type PlayletWatchProgressList []*PlayletWatchProgress diff --git a/pub/models/rechargepackagemod/package.go b/pub/models/rechargepackagemod/package.go new file mode 100644 index 0000000..258eacb --- /dev/null +++ b/pub/models/rechargepackagemod/package.go @@ -0,0 +1,128 @@ +package rechargepackagemod + +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 初始化 recharge_package 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.RechargePackage) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.RechargePackage.PackageType, Value: 1}, + {Key: FN.RechargePackage.Status, Value: 1}, + {Key: FN.RechargePackage.Sort, Value: 1}, + {Key: FN.RechargePackage.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.RechargePackage.CreatedAt, Value: -1}, + {Key: FN.RechargePackage.Id, Value: -1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(rechargepackagemod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +func InsertOneRechargePackage(ctx context.Context, doc *RechargePackage) *srvdef.SrvErr { + if doc.Id == 0 { + doc.Id = mongos.Lid.RechargePackage.Get() + } + now := time.Now() + if doc.CreatedAt.IsZero() { + doc.CreatedAt = now + } + doc.UpdatedAt = now + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Warn("购买套餐插入失败", log.Any("func", "InsertOneRechargePackage"), log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindOneRechargePackage 按条件查询单条购买套餐。 +func FindOneRechargePackage(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*RechargePackage, *srvdef.SrvErr) { + var doc RechargePackage + if err := coll.FindOneEx(ctx, &doc, filter, opts...); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("购买套餐单条查询失败", log.Any("func", "FindOneRechargePackage"), log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +// FindManyRechargePackage 按条件查询购买套餐列表。 +func FindManyRechargePackage(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (RechargePackageList, *srvdef.SrvErr) { + data := make(RechargePackageList, 0) + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("购买套餐列表查询失败", log.Any("func", "FindManyRechargePackage"), log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// CountRechargePackage 统计购买套餐数量。 +func CountRechargePackage(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.Any("func", "CountRechargePackage"), log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneRechargePackage 更新单条购买套餐。 +func UpdateOneRechargePackage(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := coll.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("购买套餐单条更新失败", log.Any("func", "UpdateOneRechargePackage"), log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateManyRechargePackage 批量更新购买套餐。 +func UpdateManyRechargePackage(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + if _, err := coll.UpdateMany(ctx, filter, update); err != nil { + log.Warn("购买套餐批量更新失败", log.Any("func", "UpdateManyRechargePackage"), log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// DeleteOneRechargePackage 删除单条购买套餐。 +func DeleteOneRechargePackage(ctx context.Context, filter bson.M) *srvdef.SrvErr { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("购买套餐删除失败", log.Any("func", "DeleteOneRechargePackage"), log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + if count <= 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} diff --git a/pub/models/rechargepackagemod/tag.go b/pub/models/rechargepackagemod/tag.go new file mode 100644 index 0000000..aeab656 --- /dev/null +++ b/pub/models/rechargepackagemod/tag.go @@ -0,0 +1,47 @@ +package rechargepackagemod + +type TagRechargePackage struct { + Id string // 套餐ID + PackageType string // 套餐类型 + Name string // 展示标题 + SubTitle string // 展示副标题 + Price string // 售价,单位分 + OriginalPrice string // 原价,单位分 + DurationDays string // 会员有效天数 + BaseCoin string // 基础金币数 + GiftCoin string // 赠送金币数 + Sort string // 排序 + Status string // 状态 + Remark string // 后台备注 + CreatedAt string // 创建时间 + UpdatedAt string // 更新时间 +} + +// FN recharge_package 集合字段名,统一用于构造 Mongo 查询,避免手写字段名。 +var FN struct { + RechargePackage TagRechargePackage +} + +// TN recharge_package 集合名称。 +var TN struct { + RechargePackage string +} + +func init() { + FN.RechargePackage.Id = "_id" + FN.RechargePackage.PackageType = "packageType" + FN.RechargePackage.Name = "name" + FN.RechargePackage.SubTitle = "subTitle" + FN.RechargePackage.Price = "price" + FN.RechargePackage.OriginalPrice = "originalPrice" + FN.RechargePackage.DurationDays = "durationDays" + FN.RechargePackage.BaseCoin = "baseCoin" + FN.RechargePackage.GiftCoin = "giftCoin" + FN.RechargePackage.Sort = "sort" + FN.RechargePackage.Status = "status" + FN.RechargePackage.Remark = "remark" + FN.RechargePackage.CreatedAt = "createdAt" + FN.RechargePackage.UpdatedAt = "updatedAt" + + TN.RechargePackage = "recharge_package" +} diff --git a/pub/models/rechargepackagemod/type.go b/pub/models/rechargepackagemod/type.go new file mode 100644 index 0000000..0a8d824 --- /dev/null +++ b/pub/models/rechargepackagemod/type.go @@ -0,0 +1,40 @@ +package rechargepackagemod + +import "time" + +// PackageType 购买套餐类型。 +type PackageType int32 + +const ( + PackageTypeVip PackageType = 1 // 会员畅享 + PackageTypeCoin PackageType = 2 // 金币充值 +) + +// Status 购买套餐状态。 +type Status int32 + +const ( + StatusEnabled Status = 1 // 启用 + StatusDisabled Status = 2 // 停用 +) + +// RechargePackage 购买套餐表,用于配置购买套餐页的会员畅享和金币充值列表 +type RechargePackage struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 套餐ID + PackageType PackageType `json:"packageType" bson:"packageType"` // 套餐类型:1-会员畅享 2-金币充值 + Name string `json:"name" bson:"name"` // 展示标题,如:1个月会员、660金币 + SubTitle string `json:"subTitle" bson:"subTitle"` // 展示副标题,如:30 × 24 小时会员、基础600 + 赠送60 + Price int64 `json:"price" bson:"price"` // 售价,单位分 + OriginalPrice int64 `json:"originalPrice" bson:"originalPrice"` // 原价,单位分 + DurationDays int64 `json:"durationDays" bson:"durationDays"` // 会员有效天数,金币套餐为0 + BaseCoin int64 `json:"baseCoin" bson:"baseCoin"` // 基础金币数,会员套餐为0 + GiftCoin int64 `json:"giftCoin" bson:"giftCoin"` // 赠送金币数 + Sort int32 `json:"sort" bson:"sort"` // 排序,越小越靠前 + Status Status `json:"status" bson:"status"` // 状态:1-启用 2-停用 + Remark string `json:"remark" bson:"remark"` // 后台备注 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} + +// RechargePackageList 购买套餐列表 +type RechargePackageList []*RechargePackage diff --git a/pub/models/richtextcontentmod/richtextcontent.go b/pub/models/richtextcontentmod/richtextcontent.go new file mode 100644 index 0000000..cd03ff0 --- /dev/null +++ b/pub/models/richtextcontentmod/richtextcontent.go @@ -0,0 +1,153 @@ +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}, + }, + }, + } + 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, err.Error(), "") + } + 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..ad75278 --- /dev/null +++ b/pub/models/skdrecord/type.go @@ -0,0 +1,26 @@ +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" // 按天统计访问信息 +) + +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..dd3864f --- /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" +} diff --git a/pub/models/syncfilemod/type.go b/pub/models/syncfilemod/type.go new file mode 100644 index 0000000..7a1dad2 --- /dev/null +++ b/pub/models/syncfilemod/type.go @@ -0,0 +1,32 @@ +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{ + VideoJob: "视频同步", + PlayletWatermarkJob: "短剧水印任务完成同步", + SitemapDirtyJob: "sitemap内容变更标记", +} diff --git a/pub/models/translationcategorymod/category.go b/pub/models/translationcategorymod/category.go new file mode 100644 index 0000000..3e6895c --- /dev/null +++ b/pub/models/translationcategorymod/category.go @@ -0,0 +1,112 @@ +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}, + }, + }, + } + 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..2e69619 --- /dev/null +++ b/pub/models/translationcategorymod/tag.go @@ -0,0 +1,42 @@ +package translationcategorymod + +type TagTranslationCategory struct { + CategoryType string + CreatedAt string + Id string + Name string + Rank string + Status string + UpdatedAt string + UrlValue string + Icon string + SeoInfo struct { + Description string + Keyworld string + Title 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" + FN.TranslationCategory.SeoInfo.Description = "seoInfo.webDescription" + FN.TranslationCategory.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.TranslationCategory.SeoInfo.Title = "seoInfo.webTitle" + TN.TranslationCategory = "translation_category" +} diff --git a/pub/models/translationcategorymod/type.go b/pub/models/translationcategorymod/type.go new file mode 100644 index 0000000..187e70a --- /dev/null +++ b/pub/models/translationcategorymod/type.go @@ -0,0 +1,21 @@ +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"` // 排序 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + 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..807c0c7 --- /dev/null +++ b/pub/models/translationmod/translation.go @@ -0,0 +1,112 @@ +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}, + }, + }, + } + 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..ec215e1 --- /dev/null +++ b/pub/models/usermod/tag.go @@ -0,0 +1,125 @@ +package usermod + +type TagUser struct { + Id string // ID + Type string // 类型 + VipType string // Vip类型 + 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版本 + SessionID string // 会话ID + DeviceBrand string // 设备品牌 + DeviceModel string // 设备型号 + SystemName string // 系统名称 + SystemVersion string // 系统版本 + Invites string // 我邀请的人的个数(我邀请他人) + AgentInviteCode string // 被邀请的inviteCode + Password string // 登陆密码 + Recharge string // 当前充值余额,金币个数 + RechargeTotal string // 累计充值额,金币个数 + ConsumptionTotal string // 金币总消费数量 + ModifyUserNameAt string // 上一次修改用户时间 + Auth string // 是否认证 + ConsecutiveLogin string // 连续登陆天数 + DistrictCode string // 商区码 + IsDirect string // 是否是直推 true:是直推用户 + IsRegister string // 是否已注册 + Score string // 积分 + ScoreTotal string // 累计积分 + ScoreLvName string // 积分等级名称 + ScoreInvites string // 邀请人获得的积分累计 + RegisterTime string // + CreatedAt string // 创建时间 + UpdatedAt string // 更新时间 + CurrentVipExpire string // 当前vip过期时间 + SysMsgAt string // 获取系统消息时间 + ShowCardId string // 最新会员卡id + ShowCardName string // 最新会员卡名称 + OriVer string // 注册时的app版本 + AccountNo string // 账号 + Email string // 邮箱 + MerchantExpiredAt string // 历史遗留字段 + PrivacyMode string // 隐私模式 +} + +var FN struct { + User TagUser +} +var TN struct { + User string +} + +func init() { + FN.User.Id = "_id" + FN.User.Type = "type" + FN.User.VipType = "VipType" + 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.SessionID = "sessionId" + FN.User.DeviceBrand = "deviceBrand" + FN.User.DeviceModel = "deviceModel" + FN.User.SystemName = "systemName" + FN.User.SystemVersion = "systemVersion" + 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.IsDirect = "isDirect" + 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.ShowCardId = "showCardId" + FN.User.ShowCardName = "showCardName" + FN.User.OriVer = "oriVer" + FN.User.AccountNo = "accountNo" + FN.User.Email = "email" + FN.User.MerchantExpiredAt = "merchantExpiredAt" + FN.User.PrivacyMode = "privacyMode" + TN.User = "user" +} diff --git a/pub/models/usermod/type.go b/pub/models/usermod/type.go new file mode 100644 index 0000000..3b36708 --- /dev/null +++ b/pub/models/usermod/type.go @@ -0,0 +1,95 @@ +//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"` // 账号 + Email string `json:"email" bson:"email"` // 邮箱 + Type pubdef.UserType `json:"type" bson:"type"` // 类型 + VipType pubdef.VipType `json:"vipType" bson:"VipType"` // Vip类型 + 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版本 + SessionID string `json:"sessionId" bson:"sessionId"` // 会话ID + DeviceBrand string `json:"deviceBrand" bson:"deviceBrand"` // 设备品牌 + DeviceModel string `json:"deviceModel" bson:"deviceModel"` // 设备型号 + SystemName string `json:"systemName" bson:"systemName"` // 系统名称 + SystemVersion string `json:"systemVersion" bson:"systemVersion"` // 系统版本 + 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"` // 商区码 + IsDirect bool `json:"isDirect" bson:"isDirect"` // 是否是直推 true:是直推用户 + 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"` // 获取系统消息时间 + ShowCardId int64 `json:"showCardId" bson:"showCardId"` // 最新会员卡id + ShowCardName string `json:"showCardName" bson:"showCardName"` // 最新会员卡名称 + OriVer string `json:"oriVer" bson:"oriVer"` // 注册时的app版本 + MerchantExpiredAt time.Time `json:"merchantExpiredAt" bson:"merchantExpiredAt"` // 历史遗留字段 + PrivacyMode bool `json:"privacyMode" bson:"privacyMode"` // 隐私模式:true-开启,false-关闭(默认) + +} + +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..cf48f8a --- /dev/null +++ b/pub/models/usermod/type_update.go @@ -0,0 +1,62 @@ +// Code generated by gen g update. DO NOT EDIT. +package usermod + +import ( + "time" + "videoweb/pub/pubdef" +) + +type UserUpdate struct { + Type *pubdef.UserType `json:"type" bson:"type,omitempty"` // 类型 + VipType *pubdef.VipType `json:"vipType" bson:"VipType,omitempty"` // Vip类型 + 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版本 + SessionID *string `json:"sessionId" bson:"sessionId,omitempty"` // 会话ID + DeviceBrand *string `json:"deviceBrand" bson:"deviceBrand,omitempty"` // 设备品牌 + DeviceModel *string `json:"deviceModel" bson:"deviceModel,omitempty"` // 设备型号 + SystemName *string `json:"systemName" bson:"systemName,omitempty"` // 系统名称 + SystemVersion *string `json:"systemVersion" bson:"systemVersion,omitempty"` // 系统版本 + 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"` // 商区码 + IsDirect *bool `json:"isDirect" bson:"isDirect,omitempty"` // 是否是直推 true:是直推用户 + 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"` // 最新会员卡名称 + 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..bf323d8 --- /dev/null +++ b/pub/models/usermod/user.go @@ -0,0 +1,427 @@ +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.Type, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.NickName, Value: "text"}, + }, + }, + { + Keys: bson.D{ + {Key: FN.User.Status, Value: 1}, + {Key: FN.User.Type, Value: 1}, + }, + }, + // statcenter USER_BINDING:isRegister + updatedAt 范围 + sort,避免全表扫描 + { + Keys: bson.D{ + {Key: FN.User.IsRegister, Value: 1}, + {Key: FN.User.UpdatedAt, Value: 1}, + }, + Options: options.Index(). + SetName("isRegister_1_updatedAt_1_bindingSync"). + SetPartialFilterExpression(bson.M{ + FN.User.IsRegister: true, + FN.User.Email: bson.M{"$exists": true, "$gt": ""}, + }), + }, + } + 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 查找一个用户;opts 可用于调用方按需设置 projection,避免读取完整用户文档。 +func FindOneUser(ctx context.Context, filter bson.M, opts ...*options.FindOneOptions) (*User, *srvdef.SrvErr) { + var userInfo User + if err := db.FindOneEx(ctx, &userInfo, filter, opts...); 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 统计用户数量;无筛选条件时用 EstimatedCount,避免 user 全表 count 慢查 +func CountUsers(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + if len(filter) == 0 { + count, err := db.EstimatedCount(ctx) + if err != nil { + log.Warn("User:CountUsers db.EstimatedCount failed", log.E(err)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil + } + 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 +} + +// StatcenterSyncListByUserID 按 UserID 游标拉取注册用户(新统计中心) +func StatcenterSyncListByUserID(ctx context.Context, userID int64, size int64) (UserList, error) { + opt := (&options.FindOptions{}) + opt.SetLimit(size) + opt.SetSort(bson.D{{Key: FN.User.Id, Value: 1}}) + filter := bson.M{ + FN.User.Id: bson.M{"$gt": userID}, + } + userList := UserList{} + err := db.Find(ctx, &userList, filter, opt) + if err != nil { + return nil, err + } + return userList, nil +} + +// StatcenterSyncBindUserList 增量拉取已正式注册(绑定邮箱)用户 +func StatcenterSyncBindUserList(ctx context.Context, bindAt time.Time, size int64) (UserList, error) { + opt := (&options.FindOptions{}).SetLimit(size).SetSort(bson.M{FN.User.UpdatedAt: 1}) + filter := bson.M{ + FN.User.IsRegister: true, + FN.User.Email: bson.M{"$exists": true, "$gt": ""}, + FN.User.UpdatedAt: bson.M{"$gt": bindAt}, + } + userList := UserList{} + if err := db.Find(ctx, &userList, filter, opt); err != nil { + return nil, err + } + return userList, nil +} + +// StatcenterSyncInviteList 增量拉取带上级邀请码的用户 +func StatcenterSyncInviteList(ctx context.Context, inviteAt time.Time, size int64) (UserList, error) { + opt := (&options.FindOptions{}).SetLimit(size).SetSort(bson.M{FN.User.CreatedAt: 1}) + filter := bson.M{ + FN.User.AgentInviteCode: bson.M{"$nin": []interface{}{"", nil}}, + FN.User.CreatedAt: bson.M{"$gt": inviteAt}, + } + userList := UserList{} + if err := db.Find(ctx, &userList, filter, opt); 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/uservisitlog/dbhelper.go b/pub/models/uservisitlog/dbhelper.go new file mode 100644 index 0000000..9584dc3 --- /dev/null +++ b/pub/models/uservisitlog/dbhelper.go @@ -0,0 +1,5 @@ +package uservisitlog + +import "videoweb/pub/mongos" + +var visitColl *mongos.Collection diff --git a/pub/models/uservisitlog/dbop.go b/pub/models/uservisitlog/dbop.go new file mode 100644 index 0000000..293a329 --- /dev/null +++ b/pub/models/uservisitlog/dbop.go @@ -0,0 +1,77 @@ +package uservisitlog + +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" +) + +// Init 初始化访问日志表 +func Init(ctx context.Context) { + visitColl = mongos.Shop.Coll(TN.UserVisitLog) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.UserVisitLog.UserId, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.UserVisitLog.VisitAt, Value: -1}, + }, + Options: options.Index().SetExpireAfterSeconds(5 * 24 * 60 * 60), + }, + } + if _, err := visitColl.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(uservisitlog:initIndex) Create uservisitlog Index failed! err:%+v:", err)) + } +} + +// InsertOneVisitLog 插入一条访问记录 +func InsertOneVisitLog(ctx context.Context, data *UserVisitLog) *srvdef.SrvErr { + if _, err := visitColl.InsertOne(ctx, data); err != nil { + log.Info("visitColl:InsertOneVisitLog db.InsertOne failed", + log.E(err), log.Any("data", data)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// FindManyVisitLogs 查找一批访问记录 +func FindManyVisitLogs(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VisitLogList, *srvdef.SrvErr) { + data := make(VisitLogList, 0) + if err := visitColl.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("visitColl:FindManyVisitLogs db.Find failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +// AccessSyncById 按 ObjectId 增量拉取访问数据(统计中心同步) +func AccessSyncById(ctx context.Context, id string, size int64) (data VisitLogList, err error) { + oid, err := primitive.ObjectIDFromHex(id) + if err != nil { + return + } + endTime := time.Now().Add(-time.Minute * 1) + visitFilter := bson.M{ + FN.UserVisitLog.Id: bson.M{"$gt": oid}, + FN.UserVisitLog.VisitAt: bson.M{"$lt": endTime}, + } + visitOpt := (&options.FindOptions{}).SetLimit(size).SetSort(bson.M{FN.UserVisitLog.Id: 1}) + err = visitColl.Find(ctx, &data, visitFilter, visitOpt) + return data, err +} diff --git a/pub/models/uservisitlog/tag.go b/pub/models/uservisitlog/tag.go new file mode 100644 index 0000000..5d7958a --- /dev/null +++ b/pub/models/uservisitlog/tag.go @@ -0,0 +1,32 @@ +package uservisitlog + +type TagUserVisitLog struct { + DevID string + DevType string + IP string + Id string + SysType string + UserId string + Version string + VisitAt string +} + +var FN struct { + UserVisitLog TagUserVisitLog +} + +var TN struct { + UserVisitLog string +} + +func init() { + FN.UserVisitLog.DevID = "devID" + FN.UserVisitLog.DevType = "devType" + FN.UserVisitLog.IP = "ip" + FN.UserVisitLog.Id = "_id" + FN.UserVisitLog.SysType = "sysType" + FN.UserVisitLog.UserId = "userId" + FN.UserVisitLog.Version = "version" + FN.UserVisitLog.VisitAt = "visitAt" + TN.UserVisitLog = "user_visit_log" +} diff --git a/pub/models/uservisitlog/type.go b/pub/models/uservisitlog/type.go new file mode 100644 index 0000000..2eb3ce7 --- /dev/null +++ b/pub/models/uservisitlog/type.go @@ -0,0 +1,21 @@ +package uservisitlog + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// @table:user_visit_log +type UserVisitLog struct { + Id primitive.ObjectID `json:"id" bson:"_id"` // 唯一ID + UserId int64 `json:"userId" bson:"userId"` // 用户ID + SysType string `json:"sysType" bson:"sysType"` // 系统类型 + DevType string `json:"devType" bson:"devType"` // 设备类型 + DevID string `json:"devID" bson:"devID"` // 设备ID + Version string `json:"version" bson:"version"` // 版本 + IP string `json:"ip" bson:"ip"` // ip + VisitAt time.Time `json:"visitAt" bson:"visitAt"` // 访问时间 +} + +type VisitLogList []*UserVisitLog 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..280360c --- /dev/null +++ b/pub/models/videocategorymod/category.go @@ -0,0 +1,137 @@ +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.ContentType, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCategory.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCategory.Slug, 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 { + // 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..e057d00 --- /dev/null +++ b/pub/models/videocategorymod/tag.go @@ -0,0 +1,48 @@ +package videocategorymod + +type TagVideoCategory struct { + ContentType string + CreatedAt string + Desc string + Id string + IndexNow string + Name string + Rank string + Slug string + SeoInfo struct { + Description string + Keyworld string + Title string + } + SortType string + Status string + Title string + UpdatedAt string +} + +var FN struct { + VideoCategory TagVideoCategory +} + +var TN struct { + VideoCategory string +} + +func init() { + FN.VideoCategory.ContentType = "contentType" + FN.VideoCategory.CreatedAt = "createdAt" + FN.VideoCategory.Desc = "desc" + FN.VideoCategory.Id = "_id" + FN.VideoCategory.IndexNow = "indexNow" + FN.VideoCategory.Name = "name" + FN.VideoCategory.Rank = "rank" + FN.VideoCategory.Slug = "slug" + FN.VideoCategory.SeoInfo.Description = "seoInfo.webDescription" + FN.VideoCategory.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.VideoCategory.SeoInfo.Title = "seoInfo.webTitle" + FN.VideoCategory.SortType = "sortType" + FN.VideoCategory.Status = "status" + FN.VideoCategory.Title = "title" + FN.VideoCategory.UpdatedAt = "updatedAt" + TN.VideoCategory = "video_category" +} diff --git a/pub/models/videocategorymod/type.go b/pub/models/videocategorymod/type.go new file mode 100644 index 0000000..7a5ddab --- /dev/null +++ b/pub/models/videocategorymod/type.go @@ -0,0 +1,31 @@ +package videocategorymod + +import ( + "time" + "videoweb/pub/pubdef" +) + +// @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"` // 上架状态 + ContentType int64 `json:"contentType" bson:"contentType"` // 内容:0: 默认 1:首页 2:今日 3:合集 + SortType int64 `json:"sortType" bson:"sortType"` // 排序方式 0:上线时间 1:观看次数 2排序 + Rank int64 `json:"rank" bson:"rank"` // 排序 + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type VideoCategoryList []*VideoCategory +type VideoSortType int32 + +const ( + VideoSortTypeAddTime VideoSortType = 0 // 上线时间 + VideoSortTypeWatchs VideoSortType = 1 // 观看次数 + VideoSortTypeRank VideoSortType = 2 // 视频排序 +) diff --git a/pub/models/videocollectmod/collect.go b/pub/models/videocollectmod/collect.go new file mode 100644 index 0000000..288f044 --- /dev/null +++ b/pub/models/videocollectmod/collect.go @@ -0,0 +1,121 @@ +package videocollectmod + +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_collect 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.VideoCollect) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoCollect.UserId, Value: 1}, + {Key: FN.VideoCollect.VideoId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.VideoCollect.VideoId, Value: 1}, + {Key: FN.VideoCollect.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCollect.UserId, Value: 1}, + {Key: FN.VideoCollect.CreatedAt, Value: -1}, + {Key: FN.VideoCollect.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCollect.CreatedAt, Value: 1}, + {Key: FN.VideoCollect.VideoId, Value: 1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(videocollectmod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// InsertOneVideoCollect 插入一条视频收藏关系 +func InsertOneVideoCollect(ctx context.Context, doc *VideoCollect) (bool, *srvdef.SrvErr) { + if doc.Id == 0 { + doc.Id = mongos.Lid.VideoCollect.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) { + return false, nil + } + log.Warn("插入视频收藏关系失败", log.Any("doc", doc), log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return true, nil +} + +// ExistsVideoCollect 查询视频收藏关系是否存在 +func ExistsVideoCollect(ctx context.Context, filter bson.M) (bool, *srvdef.SrvErr) { + var doc VideoCollect + if err := coll.FindOneEx(ctx, &doc, filter, options.FindOne().SetProjection(bson.D{ + {Key: FN.VideoCollect.Id, Value: 1}, + })); err != nil { + if err == mongo.ErrNoDocuments { + return false, nil + } + log.Warn("查询视频收藏关系失败", log.E(err), log.Any("filter", filter)) + return false, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return true, nil +} + +// DeleteOneVideoCollect 删除一条视频收藏关系 +func DeleteOneVideoCollect(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("删除视频收藏关系失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindManyVideoCollect 按条件查询视频收藏关系列表 +func FindManyVideoCollect(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoCollectList, *srvdef.SrvErr) { + data := make(VideoCollectList, 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, "", "") + } + return data, nil +} + +// CountVideoCollect 统计满足条件的视频收藏关系数量 +func CountVideoCollect(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 +} diff --git a/pub/models/videocollectmod/tag.go b/pub/models/videocollectmod/tag.go new file mode 100644 index 0000000..6038ce0 --- /dev/null +++ b/pub/models/videocollectmod/tag.go @@ -0,0 +1,28 @@ +package videocollectmod + +// TagVideoCollect 保存 video_collect 集合字段名 +type TagVideoCollect struct { + Id string + UserId string + VideoId string + CreatedAt string + UpdatedAt string +} + +var FN struct { + VideoCollect TagVideoCollect +} + +var TN struct { + VideoCollect string +} + +func init() { + FN.VideoCollect.Id = "_id" + FN.VideoCollect.UserId = "userId" + FN.VideoCollect.VideoId = "videoId" + FN.VideoCollect.CreatedAt = "createdAt" + FN.VideoCollect.UpdatedAt = "updatedAt" + + TN.VideoCollect = "video_collect" +} diff --git a/pub/models/videocollectmod/type.go b/pub/models/videocollectmod/type.go new file mode 100644 index 0000000..9e1e96d --- /dev/null +++ b/pub/models/videocollectmod/type.go @@ -0,0 +1,17 @@ +package videocollectmod + +import "time" + +// VideoCollect @table: video_collect +// +// VideoCollect 视频收藏关系模型 +type VideoCollect struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 收藏记录ID + 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"` // 修改时间 +} + +// VideoCollectList 视频收藏关系列表 +type VideoCollectList []*VideoCollect diff --git a/pub/models/videocreatermod/creater.go b/pub/models/videocreatermod/creater.go new file mode 100644 index 0000000..885563c --- /dev/null +++ b/pub/models/videocreatermod/creater.go @@ -0,0 +1,132 @@ +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}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoCreaterInfo.Slug, 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..18444a5 --- /dev/null +++ b/pub/models/videocreatermod/tag.go @@ -0,0 +1,40 @@ +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 +} + +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" + TN.VideoCreaterInfo = "video_creater" +} diff --git a/pub/models/videocreatermod/type.go b/pub/models/videocreatermod/type.go new file mode 100644 index 0000000..d160916 --- /dev/null +++ b/pub/models/videocreatermod/type.go @@ -0,0 +1,20 @@ +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相关信息 + 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/videoinformationmod/information.go b/pub/models/videoinformationmod/information.go new file mode 100644 index 0000000..d74f961 --- /dev/null +++ b/pub/models/videoinformationmod/information.go @@ -0,0 +1,320 @@ +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.CategoryIds, 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.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.CategoryIds, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Video.FileId, Value: 1}, + }, + }, + { + Keys: bson.D{ + {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.Sort, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + // /api/app/video/list 按 status 过滤,并按 addedTime/_id 倒序游标分页。 + Keys: bson.D{ + {Key: FN.VideoInformation.Status, 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.MediaType, 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.CategoryIds, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CreatorId, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "nodes.nodeId", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Sort, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.CreatorId, Value: 1}, + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Tags, Value: 1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Status, Value: 1}, + {Key: FN.VideoInformation.Tags, 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.AddedTime, Value: -1}, + {Key: FN.VideoInformation.IsTop, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.IsTop, Value: -1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Sort, Value: -1}, + {Key: FN.VideoInformation.AddedTime, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.HevcStatus, Value: 1}, + {Key: FN.VideoInformation.HevcFailCount, Value: 1}, + {Key: FN.VideoInformation.Real.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.HevcStatus, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoInformation.Slug, 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) ( + *VideoInformation, *srvdef.SrvErr) { + var VideoInformation VideoInformation + if err := db.FindOneEx(ctx, &VideoInformation, filter); 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 +} + +// FindOneVideoInformationWithOptions 按条件查询单条视频信息,支持projection等查询选项。 +func FindOneVideoInformationWithOptions(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 { + log.Warn("VideoInformationmod:FindOneVideoInformationWithOptions db.FindOne failed", + log.E(err), log.Any("filter", filter)) + 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, opts ...*options.CountOptions) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter, opts...) + 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 +} + +// AggregateVideoInformations 聚合查询视频信息 +func AggregateVideoInformations(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := db.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("videoinformationmod:AggregateVideoInformations failed", log.E(err)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + 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 +} + +// BulkWriteVideoInformation 批量写入或更新视频信息 +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("视频信息批量写入或更新失败", log.E(err)) + return nil, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result, nil +} diff --git a/pub/models/videoinformationmod/tag.go b/pub/models/videoinformationmod/tag.go new file mode 100755 index 0000000..f7de318 --- /dev/null +++ b/pub/models/videoinformationmod/tag.go @@ -0,0 +1,136 @@ +package videoinformationmod + +type TagVideoInformation struct { + AddedTime string + CategoryIds string + CoverImg string + CreatedAt string + CreatorId string + CreatorName string + Desc string + FirstTag string + Id string + IndexNow string + IsHot string + IsTop string + Keyworld string + MediaType string + Nodes string + Real struct { + Collects string + Comments string + Likes string + MonthWatchs string + ShowTimes string + TodayWatchs string + WatchTimes string + WeekWatchs string + } + Fake struct { + WatchTimes string + } + Remark string + SecondTags string + Slug string + Sort string + Status string + Tags string + Title string + UpdatedAt string + Video struct { + Cover string + FileId string + Height string + PreMp4FileNameFs string + TaskId string + Url string + H265Url string + VideoTime string + Width string + UnencryptedPreMp4 string + } + VideoCount string + UploadUserName string + OptionAccount string + PageUpdatedAt string + UnencryptedImage string + Content string + SecondUpdatedAt string + TopicIds 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.AddedTime = "addedTime" + FN.VideoInformation.CategoryIds = "categoryIds" + 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.Keyworld = "keyworld" + FN.VideoInformation.MediaType = "mediaType" + FN.VideoInformation.Nodes = "nodes" + FN.VideoInformation.Real.Collects = "real.collects" + 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.Fake.WatchTimes = "fake.watchTimes" + FN.VideoInformation.Remark = "remark" + FN.VideoInformation.SecondTags = "secondTags" + FN.VideoInformation.Slug = "slug" + FN.VideoInformation.Sort = "sort" + FN.VideoInformation.Status = "status" + FN.VideoInformation.Tags = "tags" + FN.VideoInformation.Title = "title" + FN.VideoInformation.UpdatedAt = "updatedAt" + FN.VideoInformation.Video.Cover = "video.cover" + FN.VideoInformation.Video.FileId = "video.fileId" + FN.VideoInformation.Video.Height = "video.height" + FN.VideoInformation.Video.PreMp4FileNameFs = "video.preMp4FileNameFs" + FN.VideoInformation.Video.TaskId = "video.taskId" + FN.VideoInformation.Video.Url = "video.url" + FN.VideoInformation.Video.H265Url = "video.h265Url" + FN.VideoInformation.Video.VideoTime = "video.videoTime" + FN.VideoInformation.Video.Width = "video.width" + FN.VideoInformation.Video.UnencryptedPreMp4 = "video.unencryptedPreMp4" + FN.VideoInformation.VideoCount = "videoCount" + FN.VideoInformation.OptionAccount = "optionAccount" + FN.VideoInformation.UploadUserName = "uploadUserName" + FN.VideoInformation.PageUpdatedAt = "pageUpdatedAt" + FN.VideoInformation.UnencryptedImage = "unencryptedImage" + FN.VideoInformation.Content = "content" + FN.VideoInformation.SecondUpdatedAt = "secondUpdatedAt" + FN.VideoInformation.TopicIds = "topicIds" + FN.VideoInformation.SyncId = "syncId" + 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..0d3e810 --- /dev/null +++ b/pub/models/videoinformationmod/type.go @@ -0,0 +1,187 @@ +package videoinformationmod + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson" +) + +// @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 + MediaType 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"` // 封面 + 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 InformationSatus `json:"status" bson:"status"` // 帖子上架状态 + Video VideoInfo `json:"video" bson:"video"` // 视频 + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类ID集合,可绑定首页分类配置ID + TopicIds []int64 `json:"topicIds" bson:"topicIds"` // 专题ID(视频用) + Tags []int64 `json:"tags" bson:"tags"` // 标签 + IsHot bool `json:"isHot" bson:"isHot"` // 是否热门 + Sort int64 `json:"sort" bson:"sort"` // 排序 + Real Flow `json:"real" bson:"real"` // 真实流量 + Fake FakeFlow `json:"fake" bson:"fake"` // 伪造流量 + VideoCount int64 `json:"videoCount" bson:"videoCount"` // 合集视频数量 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + PageUpdatedAt time.Time `json:"pageUpdatedAt" bson:"pageUpdatedAt"` // 页面展示更新时间 + IsTop bool `json:"isTop" bson:"isTop"` // 是否置顶 + UploadUserName string `bson:"uploadUserName" json:"uploadUserName,omitempty"` // 上传用户名 + OptionAccount string `bson:"optionAccount,omitempty" json:"optionAccount"` // 最后操作人 + Content string `json:"content" bson:"content"` // 富文本 + 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 视频元信息 +} + +// 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 VideoInformationList []*VideoInformation + +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"` // 视频URl + H265Url string `json:"h265Url" bson:"h265Url,omitempty"` // H265 视频地址 + 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 InformationSatus int32 + +const ( + InformationSatusWaiting InformationSatus = 0 // 初始化 + InformationSatusVerifyOK InformationSatus = 1 // 上架 + InformationSatusVerifyCancel InformationSatus = 2 // 下架 +) + +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"` // 点赞数 + Collects int64 `json:"collects" bson:"collects"` // 收藏数 + WeekWatchs int64 `json:"weekWatchs" bson:"weekWatchs"` // 本周观看次数 + MonthWatchs int64 `json:"monthWatchs" bson:"monthWatchs"` // 本月观看次数 + TodayWatchs int64 `json:"todayWatchs" bson:"todayWatchs"` // 当日观看次数 +} + +type FakeFlow struct { + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 假观看次数 +} + +type MediaType int64 + +const ( + MediaType_Video MediaType = 0 // 视频(帖子) + MediaType_Collection MediaType = 1 // 合集 + MediaType_Advitise MediaType = 2 // 广告 + MediaType_VideoV2 MediaType = 3 // 视频 +) + +type VideoSortItem struct { + Key VideoSortType `json:"key" form:"key"` // 传递值 + Value string `json:"value" form:"value"` // 显示名字 +} + +// 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) 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] + } +} +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}} + } +} diff --git a/pub/models/videolikemod/like.go b/pub/models/videolikemod/like.go new file mode 100644 index 0000000..39521e2 --- /dev/null +++ b/pub/models/videolikemod/like.go @@ -0,0 +1,94 @@ +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}, + {Key: FN.VideoLike.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoLike.CreatedAt, Value: 1}, + {Key: FN.VideoLike.VideoId, Value: 1}, + }, + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(videolikemod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// InsertOneVideoLike 插入一条视频点赞关系 +func InsertOneVideoLike(ctx context.Context, doc *VideoLike) (bool, *srvdef.SrvErr) { + if doc.Id == 0 { + doc.Id = mongos.Lid.VideoLike.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) { + return false, nil + } + log.Warn("插入视频点赞关系失败", log.Any("doc", doc), log.E(err)) + return false, srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return true, nil +} + +// ExistsVideoLike 查询视频点赞关系是否存在 +func ExistsVideoLike(ctx context.Context, filter bson.M) (bool, *srvdef.SrvErr) { + var doc VideoLike + if err := coll.FindOneEx(ctx, &doc, filter, options.FindOne().SetProjection(bson.D{ + {Key: FN.VideoLike.Id, Value: 1}, + })); err != nil { + if err == mongo.ErrNoDocuments { + return false, nil + } + log.Warn("查询视频点赞关系失败", log.E(err), log.Any("filter", filter)) + return false, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return true, nil +} + +// DeleteOneVideoLike 删除一条视频点赞关系 +func DeleteOneVideoLike(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteOne(ctx, filter) + if err != nil { + log.Warn("删除视频点赞关系失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} diff --git a/pub/models/videolikemod/tag.go b/pub/models/videolikemod/tag.go new file mode 100644 index 0000000..2c55d16 --- /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..c1b7ae8 --- /dev/null +++ b/pub/models/videolikemod/type.go @@ -0,0 +1,17 @@ +package videolikemod + +import "time" + +// VideoLike @table: video_like +// +// VideoLike 视频点赞关系模型 +type VideoLike struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 点赞记录ID + 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/videotagmod/tag.go b/pub/models/videotagmod/tag.go new file mode 100755 index 0000000..3e0855d --- /dev/null +++ b/pub/models/videotagmod/tag.go @@ -0,0 +1,42 @@ +package videotagmod + +type TagVideoTagInfo struct { + CreatedAt string + Id string + IndexNow string + SeoInfo struct { + Description string + Keyworld string + Title string + } + Slug string + Sort string + Status string + Title string + UpdatedAt string + WatchTimes 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.Slug = "slug" + FN.VideoTagInfo.Sort = "sort" + FN.VideoTagInfo.Status = "status" + FN.VideoTagInfo.Title = "title" + FN.VideoTagInfo.UpdatedAt = "updatedAt" + FN.VideoTagInfo.WatchTimes = "watchTimes" + TN.VideoTagInfo = "video_taginfo" +} diff --git a/pub/models/videotagmod/taginfo.go b/pub/models/videotagmod/taginfo.go new file mode 100644 index 0000000..9c02cbc --- /dev/null +++ b/pub/models/videotagmod/taginfo.go @@ -0,0 +1,143 @@ +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.Status, Value: 1}, + {Key: FN.VideoTagInfo.Sort, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTagInfo.Status, Value: 1}, + {Key: FN.VideoTagInfo.WatchTimes, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTagInfo.Slug, 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 +} +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..8ec4169 --- /dev/null +++ b/pub/models/videotagmod/type.go @@ -0,0 +1,21 @@ +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"` // 帖子标题 + 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相关信息 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + WatchTimes int64 `json:"watchTimes" bson:"watchTimes"` // 预计算:该标签下所有视频的累计播放量 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type VideoTagInfoList []*VideoTagInfo diff --git a/pub/models/videotopicmod/tag.go b/pub/models/videotopicmod/tag.go new file mode 100755 index 0000000..09eba23 --- /dev/null +++ b/pub/models/videotopicmod/tag.go @@ -0,0 +1,52 @@ +package videotopicmod + +type TagVideoTopicModel struct { + Id string + Name string + Title string + Desc string + Status string + Rank string + Slug string + Image string + VideoCategoryId string + SeoInfo struct { + Description string + Keyworld string + Title string + } + Works string + IndexNow string + UpdatedAt string + CreatedAt string + VideoTopicType string +} + +var FN struct { + VideoTopicModel TagVideoTopicModel +} + +var TN struct { + VideoTopicModel string +} + +func init() { + FN.VideoTopicModel.CreatedAt = "createdAt" + FN.VideoTopicModel.Desc = "desc" + FN.VideoTopicModel.Id = "_id" + FN.VideoTopicModel.IndexNow = "indexNow" + FN.VideoTopicModel.Name = "name" + FN.VideoTopicModel.Rank = "rank" + FN.VideoTopicModel.Slug = "slug" + FN.VideoTopicModel.SeoInfo.Description = "seoInfo.webDescription" + FN.VideoTopicModel.SeoInfo.Keyworld = "seoInfo.webKeyworld" + FN.VideoTopicModel.SeoInfo.Title = "seoInfo.webTitle" + FN.VideoTopicModel.Status = "status" + FN.VideoTopicModel.Title = "title" + FN.VideoTopicModel.UpdatedAt = "updatedAt" + FN.VideoTopicModel.Works = "works" + FN.VideoTopicModel.Image = "image" + FN.VideoTopicModel.VideoCategoryId = "videoCategoryId" + FN.VideoTopicModel.VideoTopicType = "videoTopicType" + TN.VideoTopicModel = "video_topic" +} diff --git a/pub/models/videotopicmod/type.go b/pub/models/videotopicmod/type.go new file mode 100644 index 0000000..4801c3c --- /dev/null +++ b/pub/models/videotopicmod/type.go @@ -0,0 +1,27 @@ +package videotopicmod + +import ( + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" +) + +// @table:video_topic +type VideoTopicModel 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"` // 上架状态 + Rank int64 `json:"rank" bson:"rank"` // 排序 + Image string `json:"image" bson:"image"` // 背景 + VideoTopicType videoinformationmod.MediaType `json:"videoTopicType" bson:"videoTopicType"` // 视频类型 + VideoCategoryId int64 `json:"videoCategoryId" bson:"videoCategoryId"` // 视频分类id + SeoInfo pubdef.SeoValue `json:"seoInfo" bson:"seoInfo"` // Seo相关信息 + Works int64 `json:"works" bson:"works"` // 作品数 + IndexNow int64 `json:"indexNow" bson:"indexNow"` // 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 +} +type VideoTopicModelList []*VideoTopicModel diff --git a/pub/models/videotopicmod/videotopic.go b/pub/models/videotopicmod/videotopic.go new file mode 100644 index 0000000..8cb0efa --- /dev/null +++ b/pub/models/videotopicmod/videotopic.go @@ -0,0 +1,144 @@ +package videotopicmod + +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.VideoTopicModel) + // 获取数据库表 + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoTopicModel.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTopicModel.Status, Value: 1}, + {Key: FN.VideoTopicModel.Rank, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTopicModel.VideoCategoryId, Value: 1}, + {Key: FN.VideoTopicModel.Status, Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTopicModel.CreatedAt, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoTopicModel.Slug, Value: 1}, + }, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(channelmod:initVideoTopicModelIndex) Create VideoTopicModel Index failed! err:%+v:", err)) + } + +} + +// InsertOneVideoTopicModel 插入一个渠道统计 +func InsertOneVideoTopicModel(ctx context.Context, qa *VideoTopicModel) *srvdef.SrvErr { + if _, err := db.InsertOne(ctx, qa); err != nil { + // log.Warn("VideoTopicModelmod:InsertOneVideoTopicModel db.InsertOne failed", log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +// DelOneVideoTopicModel 删除一个渠道统计 +func DelOneVideoTopicModel(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("VideoTopicModelmod:DelOneVideoTopicModel db.DeleteOne failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} + +// FindOneVideoTopicModel 查找一个渠道统计 +func FindOneVideoTopicModel(ctx context.Context, filter bson.M) ( + *VideoTopicModel, *srvdef.SrvErr) { + var VideoTopicModel VideoTopicModel + if err := db.FindOneEx(ctx, &VideoTopicModel, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("VideoTopicModelmod:FindOneVideoTopicModel db.FindOne failed", + log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &VideoTopicModel, nil +} + +func FindManyVideoTopicModels(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoTopicModelList, *srvdef.SrvErr) { + data := make(VideoTopicModelList, 0) + if err := db.Find(ctx, &data, filter, opts...); nil != err { + log.Warn("faq:FindManyVideoTopicModels", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQuery.Code, "", "") + } + return data, nil +} + +// CountVideoTopicModels 统计渠道统计数量 +func CountVideoTopicModels(ctx context.Context, filter bson.M) ( + int64, *srvdef.SrvErr) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("faqmod:CountVideoTopicModels db.Count failed", + log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// UpdateOneVideoTopicModel 更新一个渠道统计 +func UpdateOneVideoTopicModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateOne(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateOneVideoTopicModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount != 1 { + log.Warn("post:UpdateOneVideoTopicModel db.UpdateOne failed", + log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +func UpdateManyVideoTopicModel(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr { + result, err := db.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("post:UpdateManyVideoTopicModel 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/videowatchhistorymod/history.go b/pub/models/videowatchhistorymod/history.go new file mode 100644 index 0000000..ec10e1c --- /dev/null +++ b/pub/models/videowatchhistorymod/history.go @@ -0,0 +1,107 @@ +package videowatchhistorymod + +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 + +const videoWatchHistoryTTLSeconds int32 = 90 * 24 * 60 * 60 + +// Init 初始化 video_watch_history 集合和常用查询索引 +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.VideoWatchHistory) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.VideoWatchHistory.UserId, Value: 1}, + {Key: FN.VideoWatchHistory.VideoId, Value: 1}, + }, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{ + {Key: FN.VideoWatchHistory.UserId, Value: 1}, + {Key: FN.VideoWatchHistory.UpdatedAt, Value: -1}, + {Key: FN.VideoWatchHistory.Id, Value: -1}, + }, + }, + { + Keys: bson.D{ + {Key: FN.VideoWatchHistory.UpdatedAt, Value: 1}, + }, + Options: options.Index().SetExpireAfterSeconds(videoWatchHistoryTTLSeconds), + }, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(videowatchhistorymod:initIndex) 创建索引失败 err:%+v:", err)) + } +} + +// FindManyVideoWatchHistory 按条件查询用户视频观看历史 +func FindManyVideoWatchHistory(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VideoWatchHistoryList, *srvdef.SrvErr) { + data := make(VideoWatchHistoryList, 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, "", "") + } + return data, nil +} + +// CountVideoWatchHistory 统计满足条件的视频观看历史数量 +func CountVideoWatchHistory(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 +} + +// SaveVideoWatchHistory 保存用户视频观看历史 +func SaveVideoWatchHistory(ctx context.Context, userId int64, videoId int64) *srvdef.SrvErr { + now := time.Now() + filter := bson.M{ + FN.VideoWatchHistory.UserId: userId, + FN.VideoWatchHistory.VideoId: videoId, + } + update := bson.M{ + "$set": bson.M{ + FN.VideoWatchHistory.UpdatedAt: now, + }, + "$setOnInsert": bson.M{ + FN.VideoWatchHistory.Id: mongos.Lid.VideoWatchHistory.Get(), + FN.VideoWatchHistory.UserId: userId, + FN.VideoWatchHistory.VideoId: videoId, + FN.VideoWatchHistory.CreatedAt: now, + }, + } + if _, err := coll.UpsertOne(ctx, filter, update); err != nil { + log.Warn("保存视频观看历史失败", log.E(err), log.Any("filter", filter)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return nil +} + +// DeleteManyVideoWatchHistory 删除满足条件的视频观看历史 +func DeleteManyVideoWatchHistory(ctx context.Context, filter bson.M, opts ...*options.DeleteOptions) (int64, *srvdef.SrvErr) { + count, err := coll.DeleteMany(ctx, filter, opts...) + if err != nil { + log.Warn("删除视频观看历史失败", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbDeleteError, "", "") + } + return count, nil +} diff --git a/pub/models/videowatchhistorymod/tag.go b/pub/models/videowatchhistorymod/tag.go new file mode 100644 index 0000000..3e91dc2 --- /dev/null +++ b/pub/models/videowatchhistorymod/tag.go @@ -0,0 +1,28 @@ +package videowatchhistorymod + +// TagVideoWatchHistory 保存 video_watch_history 集合字段名 +type TagVideoWatchHistory struct { + Id string + UserId string + VideoId string + CreatedAt string + UpdatedAt string +} + +var FN struct { + VideoWatchHistory TagVideoWatchHistory +} + +var TN struct { + VideoWatchHistory string +} + +func init() { + FN.VideoWatchHistory.Id = "_id" + FN.VideoWatchHistory.UserId = "userId" + FN.VideoWatchHistory.VideoId = "videoId" + FN.VideoWatchHistory.CreatedAt = "createdAt" + FN.VideoWatchHistory.UpdatedAt = "updatedAt" + + TN.VideoWatchHistory = "video_watch_history" +} diff --git a/pub/models/videowatchhistorymod/type.go b/pub/models/videowatchhistorymod/type.go new file mode 100644 index 0000000..2422563 --- /dev/null +++ b/pub/models/videowatchhistorymod/type.go @@ -0,0 +1,17 @@ +package videowatchhistorymod + +import "time" + +// VideoWatchHistory @table: video_watch_history +// +// VideoWatchHistory 用户视频观看历史模型 +type VideoWatchHistory struct { + Id int64 `json:"id" bson:"_id,omitempty"` // 观看历史记录ID + 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"` // 修改时间 +} + +// VideoWatchHistoryList 用户视频观看历史列表 +type VideoWatchHistoryList []*VideoWatchHistory diff --git a/pub/models/vipordermod/order.go b/pub/models/vipordermod/order.go new file mode 100644 index 0000000..4f8d1ce --- /dev/null +++ b/pub/models/vipordermod/order.go @@ -0,0 +1,171 @@ +package vipordermod + +import ( + "context" + "fmt" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/mongos" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var coll *mongos.Collection + +func Init(ctx context.Context) { + coll = mongos.Shop.Coll(TN.VipOrder) + initIndex(ctx) +} + +func initIndex(ctx context.Context) { + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: FN.VipOrder.UserId, Value: -1}}}, + {Keys: bson.D{{Key: FN.VipOrder.TradeNo, Value: 1}}, Options: options.Index().SetUnique(true)}, + {Keys: bson.D{{Key: FN.VipOrder.Status, Value: 1}}}, + {Keys: bson.D{{Key: FN.VipOrder.DistrictCode, Value: 1}}}, + {Keys: bson.D{{Key: FN.VipOrder.CreatedAt, Value: -1}}}, + } + if _, err := coll.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(vipordermod:initIndex) Create Index failed! err:%+v:", err)) + } +} + +func InsertOneVipOrder(ctx context.Context, doc *VipOrder) *srvdef.SrvErr { + if _, err := coll.InsertOne(ctx, doc); err != nil { + log.Error("vipordermod:InsertOneVipOrder failed", log.Any("doc", doc), log.E(err)) + return srvdef.New(srvdef.ErrDbInsertError, "", "") + } + return nil +} + +func FindOneVipOrder(ctx context.Context, filter bson.M) (*VipOrder, *srvdef.SrvErr) { + var doc VipOrder + if err := coll.FindOneEx(ctx, &doc, filter); err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("vipordermod:FindOneVipOrder failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &doc, nil +} + +func FindManyVipOrder(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (VipOrderList, *srvdef.SrvErr) { + var data VipOrderList + if err := coll.Find(ctx, &data, filter, opts...); err != nil { + log.Warn("vipordermod:FindManyVipOrder failed", log.E(err), log.Any("filter", filter)) + return nil, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return data, nil +} + +func CountVipOrder(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) { + count, err := coll.Count(ctx, filter) + if err != nil { + log.Warn("vipordermod:CountVipOrder failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return count, nil +} + +// StatSyncSuccessOrderList 增量拉取支付成功订单(统计中心同步) +func StatSyncSuccessOrderList(ctx context.Context, successTime time.Time, size int64) (VipOrderList, error) { + opt := (&options.FindOptions{}).SetLimit(size).SetSort(bson.M{FN.VipOrder.FinishedAt: 1}) + // 预留足够时间让注册数据先到达统计中心,避免充值记录找不到对应注册用户 + endTime := time.Now().Add(-time.Minute * 4) + filter := bson.M{ + FN.VipOrder.FinishedAt: bson.M{"$gt": successTime, "$lt": endTime}, + FN.VipOrder.Status: pubdef.VipOrderSuccess, + } + list := VipOrderList{} + if err := coll.Find(ctx, &list, filter, opt); err != nil { + return nil, err + } + return list, nil +} + +// StatSyncAllOrderList 增量拉取非失败订单(统计中心同步) +func StatSyncAllOrderList(ctx context.Context, createTime time.Time, size int64) (VipOrderList, error) { + opt := (&options.FindOptions{}).SetLimit(size).SetSort(bson.M{FN.VipOrder.CreatedAt: 1}) + endTime := time.Now().Add(-time.Minute * 1) + filter := bson.M{ + FN.VipOrder.CreatedAt: bson.M{"$gt": createTime, "$lt": endTime}, + FN.VipOrder.Status: bson.M{"$ne": pubdef.VipOrderFailed}, + } + list := VipOrderList{} + if err := coll.Find(ctx, &list, filter, opt); err != nil { + return nil, err + } + return list, nil +} + +// StatSyncVipOrderList 增量拉取会员购买成功订单(统计中心同步) +func StatSyncVipOrderList(ctx context.Context, successTime time.Time, size int64) (VipOrderList, error) { + opt := (&options.FindOptions{}).SetLimit(size).SetSort(bson.M{FN.VipOrder.FinishedAt: 1}) + endTime := time.Now().Add(-time.Minute * 1) + filter := bson.M{ + FN.VipOrder.FinishedAt: bson.M{"$gt": successTime, "$lt": endTime}, + FN.VipOrder.Status: pubdef.VipOrderSuccess, + FN.VipOrder.OrderType: OrderTypeVip, + } + list := VipOrderList{} + if err := coll.Find(ctx, &list, filter, opt); err != nil { + return nil, err + } + return list, nil +} + +func EmptyDistrictFilter() bson.M { + return bson.M{ + "$or": []bson.M{ + {FN.VipOrder.DistrictCode: bson.M{"$exists": false}}, + {FN.VipOrder.DistrictCode: ""}, + {FN.VipOrder.DistrictCode: nil}, + }, + } +} + +// AggregateVipOrder 聚合查询充值订单 +func AggregateVipOrder(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr { + if err := coll.Aggregate(ctx, bind, pipeline); err != nil { + log.Warn("vipordermod:AggregateVipOrder failed", log.E(err), log.Any("pipeline", pipeline)) + return srvdef.New(srvdef.ErrDbQueryError, "", "") + } + return nil +} + +func UpdateManyVipOrder(ctx context.Context, filter bson.M, update bson.M) (int64, *srvdef.SrvErr) { + result, err := coll.UpdateMany(ctx, filter, update) + if err != nil { + log.Warn("vipordermod:UpdateManyVipOrder failed", log.E(err), log.Any("filter", filter)) + return 0, srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + return result.ModifiedCount, nil +} + +// UpdateOrderSuccess 将订单标记为支付成功 +func UpdateOrderSuccess(ctx context.Context, tradeNo string, oid string, realAmount int64, paymentAt time.Time) *srvdef.SrvErr { + now := time.Now() + result, err := coll.UpdateOne(ctx, + bson.M{FN.VipOrder.TradeNo: tradeNo}, + bson.M{"$set": bson.M{ + FN.VipOrder.Status: pubdef.VipOrderSuccess, + FN.VipOrder.Oid: oid, + FN.VipOrder.Amount: realAmount, + FN.VipOrder.FinishedAt: paymentAt, + FN.VipOrder.UpdatedAt: now, + }}, + ) + if err != nil { + log.Warn("vipordermod:UpdateOrderSuccess failed", log.E(err), log.Any("tradeNo", tradeNo)) + return srvdef.New(srvdef.ErrDbUpdateError, "", "") + } + if result.MatchedCount == 0 { + return srvdef.New(srvdef.ErrNoData, "订单不存在", "") + } + return nil +} diff --git a/pub/models/vipordermod/tag.go b/pub/models/vipordermod/tag.go new file mode 100644 index 0000000..90cd54c --- /dev/null +++ b/pub/models/vipordermod/tag.go @@ -0,0 +1,64 @@ +package vipordermod + +type TagVipOrder struct { + Id string + OrderType string + UserId string + CardId string + CardName string + CoinCfgId string + Coin string + BaseCoin string + GiftCoin string + Amount string + PayMode string + TradeNo string + Oid string + Status string + PayUrl string + PayUrlMode string + PayChannel string + PayRate string + IPAddr string + SysType string + DistrictCode string + CreatedAt string + UpdatedAt string + FinishedAt string +} + +var FN struct { + VipOrder TagVipOrder +} +var TN struct { + VipOrder string +} + +func init() { + FN.VipOrder.Id = "_id" + FN.VipOrder.OrderType = "orderType" + FN.VipOrder.UserId = "userId" + FN.VipOrder.CardId = "cardId" + FN.VipOrder.CardName = "cardName" + FN.VipOrder.CoinCfgId = "coinCfgId" + FN.VipOrder.Coin = "coin" + FN.VipOrder.BaseCoin = "baseCoin" + FN.VipOrder.GiftCoin = "giftCoin" + FN.VipOrder.Amount = "amount" + FN.VipOrder.PayMode = "payMode" + FN.VipOrder.TradeNo = "tradeNo" + FN.VipOrder.Oid = "oid" + FN.VipOrder.Status = "status" + FN.VipOrder.PayUrl = "payUrl" + FN.VipOrder.PayUrlMode = "payUrlMode" + FN.VipOrder.PayChannel = "payChannel" + FN.VipOrder.PayRate = "payRate" + FN.VipOrder.IPAddr = "ipAddr" + FN.VipOrder.SysType = "sysType" + FN.VipOrder.DistrictCode = "districtCode" + FN.VipOrder.CreatedAt = "createdAt" + FN.VipOrder.UpdatedAt = "updatedAt" + FN.VipOrder.FinishedAt = "finishedAt" + + TN.VipOrder = "vip_order" +} diff --git a/pub/models/vipordermod/type.go b/pub/models/vipordermod/type.go new file mode 100644 index 0000000..8995535 --- /dev/null +++ b/pub/models/vipordermod/type.go @@ -0,0 +1,130 @@ +package vipordermod + +import ( + "strings" + "time" + "videoweb/pub/common/useragent" + "videoweb/pub/pubdef" +) + +// OrderType 订单类型 +type OrderType int8 + +const ( + OrderTypeVip OrderType = 1 // 会员购买 + OrderTypeCoin OrderType = 2 // 金币购买 +) + +// @table: vip_order +type VipOrder struct { + Id int64 `json:"id" bson:"_id"` // 唯一ID + OrderType OrderType `json:"orderType" bson:"orderType"` // 订单类型:1会员 2金币 + UserId int64 `json:"userId" bson:"userId"` // 用户ID + // ── VIP 专用 ── + CardId int64 `json:"cardId" bson:"cardId"` // 购买套餐ID(会员畅享) + CardName string `json:"cardName" bson:"cardName"` // 套餐名快照 + // ── 金币专用 ── + CoinCfgId int64 `json:"coinCfgId" bson:"coinCfgId"` // 购买套餐ID(金币充值) + Coin int64 `json:"coin" bson:"coin"` // 到账金币数(快照) + BaseCoin int64 `json:"baseCoin" bson:"baseCoin"` // 基础金币数(快照) + GiftCoin int64 `json:"giftCoin" bson:"giftCoin"` // 赠送金币数(快照) + Amount int64 `json:"amount" bson:"amount"` // 支付金额(分) + PayMode string `json:"payMode" bson:"payMode"` // 支付方式:overage/alipay/wechat + TradeNo string `json:"tradeNo" bson:"tradeNo"` // 本地订单号 + Oid string `json:"oid" bson:"oid"` // 第三方订单号 + Status pubdef.VipOrderStatus `json:"status" bson:"status"` // 订单状态 + PayUrl string `json:"payUrl" bson:"payUrl"` // 支付链接 + PayUrlMode string `json:"payUrlMode" bson:"payUrlMode"` // 链接打开方式 + PayChannel string `json:"payChannel" bson:"payChannel"` // 支付渠道 + PayRate string `json:"payRate" bson:"payRate"` // 费率 + IPAddr string `json:"ipAddr" bson:"ipAddr"` // 客户端 IP + SysType string `json:"sysType" bson:"sysType"` // ios/android + DistrictCode string `json:"districtCode" bson:"districtCode"` // 下单时用户渠道码 + Client *OrderClient `json:"client,omitempty" bson:"client,omitempty"` // 下单时的设备与来源页,支付回调没有浏览器请求 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 + FinishedAt time.Time `json:"finishedAt" bson:"finishedAt"` // 完成时间 +} + +type VipOrderList []*VipOrder + +// OrderClient 保存下单请求里的设备和来源页。支付成功回调没有浏览器请求,用这份快照上报。 +type OrderClient struct { + Device string `json:"device" bson:"device"` + DeviceID string `json:"deviceId" bson:"deviceId"` + DeviceFingerprint string `json:"deviceFingerprint" bson:"deviceFingerprint"` + FPVersion string `json:"fpVersion" bson:"fpVersion"` + SID string `json:"sid" bson:"sid"` + AppVersion string `json:"appVersion" bson:"appVersion"` + UserAgent string `json:"userAgent" bson:"userAgent"` + DeviceBrand string `json:"deviceBrand" bson:"deviceBrand"` + DeviceModel string `json:"deviceModel" bson:"deviceModel"` + SystemName string `json:"systemName" bson:"systemName"` + SystemVersion string `json:"systemVersion" bson:"systemVersion"` + SourcePageKey string `json:"sourcePageKey" bson:"sourcePageKey"` + SourcePageName string `json:"sourcePageName" bson:"sourcePageName"` +} + +// NewOrderClient 从当次请求复制设备和来源页。 +func NewOrderClient(ua useragent.UA, sourcePageKey, sourcePageName string) *OrderClient { + client := &OrderClient{ + UserAgent: strings.TrimSpace(ua.UserAgent), + DeviceBrand: strings.TrimSpace(ua.DeviceBrand), + DeviceModel: strings.TrimSpace(ua.DeviceModel), + SystemName: strings.TrimSpace(ua.SystemName), + SystemVersion: strings.TrimSpace(ua.SystemVersion), + SourcePageKey: trimOrderText(sourcePageKey, 64), + SourcePageName: trimOrderText(sourcePageName, 128), + SID: strings.TrimSpace(ua.SessionID), + DeviceID: strings.TrimSpace(ua.DevID), + } + if ua.SDKCommon != nil { + sdk := ua.SDKCommon.Clone() + if device := strings.TrimSpace(sdk.Device); device != "" { + client.Device = device + } + if deviceID := strings.TrimSpace(sdk.DeviceID); deviceID != "" { + client.DeviceID = deviceID + } + client.DeviceFingerprint = strings.TrimSpace(sdk.DeviceFingerprint) + client.FPVersion = strings.TrimSpace(sdk.FPVersion) + if sid := strings.TrimSpace(sdk.SID); sid != "" { + client.SID = sid + } + client.AppVersion = strings.TrimSpace(sdk.AppVersion) + } + return client +} + +// UA 还原下单时的设备信息。系统类型仍用订单上的 SysType,给设备类型兜底。 +func (c *OrderClient) UA(sysType string) useragent.UA { + ua := useragent.UA{SysType: strings.TrimSpace(sysType)} + if c == nil { + return ua + } + ua.UserAgent = c.UserAgent + ua.DevID = c.DeviceID + ua.DeviceBrand = c.DeviceBrand + ua.DeviceModel = c.DeviceModel + ua.SystemName = c.SystemName + ua.SystemVersion = c.SystemVersion + ua.SessionID = c.SID + ua.SDKCommon = &useragent.SDKCommonFields{ + SID: c.SID, + DeviceID: c.DeviceID, + Device: c.Device, + DeviceFingerprint: c.DeviceFingerprint, + FPVersion: c.FPVersion, + AppVersion: c.AppVersion, + } + return ua +} + +func trimOrderText(value string, limit int) string { + value = strings.TrimSpace(value) + runes := []rune(value) + if limit <= 0 || len(runes) <= limit { + return value + } + return string(runes[:limit]) +} 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..d8224bf --- /dev/null +++ b/pub/mongos/generate_id.go @@ -0,0 +1,142 @@ +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 + VideoTopic *lid + VideoInfo *lid + VideoLike *lid + VideoCollect *lid + VideoWatchHistory *lid + TransCategory *lid + Translation *lid + VideoCreator *lid + Comment *lid + HtmlText *lid + Actress *lid + Post *lid + PostCategory *lid + Card *lid + CoinRecharge *lid + RechargePackage *lid + ContactUs *lid + Feedback *lid + VipOrder *lid + CoinTrans *lid + Playlet *lid + PlayletChapter *lid + PlayletChapterLike *lid + PlayletChapterCollect *lid + PlayletChapterUnlock *lid + PlayletTheme *lid + PlayletTopicGroup *lid + PlayletTag *lid + PlayletLike *lid + PlayletCollect *lid + PlayletWatchProgress *lid + PlayletWatchHistory *lid + CommentLike *lid + Announcement *lid + HotSearch *lid + HomeBanner *lid + HomeConfig *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.VideoLike = createLid(coll, "videoLike", 10) + Lid.VideoCollect = createLid(coll, "videoCollect", 10) + Lid.VideoWatchHistory = createLid(coll, "videoWatchHistory", 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.Actress = createLid(coll, "Actress", 10) + Lid.Post = createLid(coll, "Post", 10) + Lid.VideoTopic = createLid(coll, "VideoTopic", 10) + Lid.PostCategory = createLid(coll, "PostCategory", 10) + Lid.Card = createLid(coll, "card", 10) + Lid.CoinRecharge = createLid(coll, "coinRecharge", 10) + Lid.RechargePackage = createLid(coll, "rechargePackage", 10) + Lid.ContactUs = createLid(coll, "contactus", 10) + Lid.VipOrder = createLid(coll, "vipOrder", 10) + Lid.CoinTrans = createLid(coll, "coinTrans", 10) + Lid.Feedback = createLid(coll, "feedback", 10) + Lid.Playlet = createLid(coll, "playlet", 10) + Lid.PlayletChapter = createLid(coll, "playletChapter", 10) + Lid.PlayletChapterLike = createLid(coll, "playletChapterLike", 10) + Lid.PlayletChapterCollect = createLid(coll, "playletChapterCollect", 10) + Lid.PlayletChapterUnlock = createLid(coll, "playletChapterUnlock", 10) + Lid.PlayletTheme = createLid(coll, "playletTheme", 10) + Lid.PlayletTopicGroup = createLid(coll, "playletTopicGroup", 10) + Lid.PlayletTag = createLid(coll, "playletTag", 10) + Lid.PlayletLike = createLid(coll, "playletLike", 10) + Lid.PlayletCollect = createLid(coll, "playletCollect", 10) + Lid.PlayletWatchProgress = createLid(coll, "playletWatchProgress", 10) + Lid.PlayletWatchHistory = createLid(coll, "playletWatchHistory", 10) + Lid.CommentLike = createLid(coll, "commentLike", 10) + Lid.Announcement = createLid(coll, "announcement", 10) + Lid.HotSearch = createLid(coll, "hotSearch", 10) + Lid.HomeBanner = createLid(coll, "homeBanner", 10) + Lid.HomeConfig = createLid(coll, "homeConfig", 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/payCenter/config.go b/pub/payCenter/config.go new file mode 100644 index 0000000..8196d6e --- /dev/null +++ b/pub/payCenter/config.go @@ -0,0 +1,63 @@ +package payCenter + +import ( + "crypto/md5" + "crypto/subtle" + "encoding/hex" + "errors" + "net/url" + "strconv" + "strings" + "time" +) + +// Conf 与 girl 的 newPayCenter 配置保持一致,App 和 Web 各自加载本进程配置。 +type Conf struct { + AppName string `yaml:"appName"` + CallbackUrl string `yaml:"callbackUrl"` + ApiUrl string `yaml:"apiUrl"` + MercID string `yaml:"mercID"` + MercSecret string `yaml:"mercSecret"` +} + +var payCenterConf Conf + +func Init(c Conf) { + c.ApiUrl = strings.TrimRight(strings.TrimSpace(c.ApiUrl), "/") + c.CallbackUrl = strings.TrimRight(strings.TrimSpace(c.CallbackUrl), "/") + payCenterConf = c +} + +// validateConfig 仅在支付请求时校验,配置暂未填写不影响其他业务启动。 +func validateConfig(requireCallback bool) error { + if payCenterConf.MercID == "" || payCenterConf.MercSecret == "" || !validHTTPURL(payCenterConf.ApiUrl) { + return errors.New("新支付中心配置不完整:请配置 newPayCenter.apiUrl、mercID、mercSecret") + } + if requireCallback && (payCenterConf.AppName == "" || !validHTTPURL(payCenterConf.CallbackUrl)) { + return errors.New("新支付中心配置不完整:请配置 newPayCenter.appName、callbackUrl") + } + return nil +} + +func validHTTPURL(value string) bool { + u, err := url.Parse(value) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.User == nil && u.RawQuery == "" && u.Fragment == "" +} + +func Generate13DigitString() string { + return strconv.FormatInt(time.Now().UnixMilli(), 10) +} + +func md5Sign(value string) string { + hash := md5.Sum([]byte(value)) + return hex.EncodeToString(hash[:]) +} + +func VerifySign(sign, value string) bool { + return subtle.ConstantTimeCompare([]byte(md5Sign(value)), []byte(sign)) == 1 +} + +func RChgIDDisassemble(transNo string) string { + parts := strings.Split(transNo, "-") + return parts[len(parts)-1] +} diff --git a/pub/payCenter/money.go b/pub/payCenter/money.go new file mode 100644 index 0000000..2161c4b --- /dev/null +++ b/pub/payCenter/money.go @@ -0,0 +1,103 @@ +package payCenter + +import ( + "context" + "errors" + "fmt" + "math/big" + "strconv" + "strings" + + "videoweb/pub/common/log" +) + +// FenToYuan 用整数拆分分/元,避免浮点运算改变下单金额及签名原文。 +func FenToYuan(price int64) string { + amount := big.NewInt(price) + negative := amount.Sign() < 0 + amount.Abs(amount) + yuan := new(big.Int) + fen := new(big.Int) + yuan.QuoRem(amount, big.NewInt(100), fen) + result := yuan.String() + if fen.Sign() != 0 { + result += fmt.Sprintf(".%02d", fen.Int64()) + } + if negative { + return "-" + result + } + return result +} + +func YuanToFen(ctx context.Context, money string) int64 { + result, err := ParseYuanToFen(money) + if err != nil { + log.Error("【新支付中心金额转换】元转分失败", log.E(err)) + } + return result +} + +// ParseYuanToFen 只接受十进制金额和最多两位小数,拒绝指数、内嵌符号和溢出。 +func ParseYuanToFen(money string) (int64, error) { + money = strings.TrimSpace(money) + if money == "" { + return 0, errors.New("money is empty") + } + + negative := strings.HasPrefix(money, "-") + if negative { + money = money[1:] + if money == "" { + return 0, errors.New("money is empty") + } + } + parts := strings.Split(money, ".") + if len(parts) > 2 { + return 0, fmt.Errorf("invalid money format %q", money) + } + if strings.ReplaceAll(money, ".", "") == "" { + return 0, errors.New("money has no digits") + } + for _, part := range parts { + for _, digit := range part { + if digit < '0' || digit > '9' { + return 0, errors.New("money must contain only decimal digits") + } + } + } + if parts[0] == "" { + parts[0] = "0" + } + + yuan, ok := new(big.Int).SetString(parts[0], 10) + if !ok { + return 0, fmt.Errorf("invalid yuan amount %q", money) + } + + decimalPart := "00" + if len(parts) == 2 { + decimalPart = parts[1] + if len(decimalPart) > 2 { + return 0, fmt.Errorf("money has more than two decimal places: %q", money) + } + if len(decimalPart) == 0 { + decimalPart = "00" + } else if len(decimalPart) == 1 { + decimalPart += "0" + } + } + cents, err := strconv.ParseInt(decimalPart, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid fen amount %q: %w", money, err) + } + + total := new(big.Int).Mul(yuan, big.NewInt(100)) + total.Add(total, big.NewInt(cents)) + if negative { + total.Neg(total) + } + if !total.IsInt64() { + return 0, fmt.Errorf("money overflows int64 fen: %q", money) + } + return total.Int64(), nil +} diff --git a/pub/payCenter/paytype.go b/pub/payCenter/paytype.go new file mode 100644 index 0000000..f55db84 --- /dev/null +++ b/pub/payCenter/paytype.go @@ -0,0 +1,173 @@ +package payCenter + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "videoweb/pub/common/httputil" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" +) + +// PayTypeInfo 支付中心返回的支付方式,名称优先使用本地配置。 +type PayTypeInfo struct { + Type string + TypeName string +} + +const payCenterErrorBodyLogLimit = 4096 + +type GainPayTypeReq struct { + Ctx context.Context `json:"-"` + Time string `json:"time"` + Sign string `json:"sign"` + MercID string `json:"mercId"` + Money []string `json:"money"` + Mode string `json:"mode,omitempty"` + Payload string `json:"payload,omitempty"` +} + +type AllPayType struct { + Money string `json:"money"` + Types []mercPayTypeInfo `json:"types"` +} + +type mercPayTypeInfo struct { + Name string `json:"name"` + Type string `json:"type"` +} + +type GainPayTypeResp struct { + Code int `json:"code"` + Info string `json:"info"` + Err string `json:"err"` + Msg []AllPayType `json:"msg"` + Tip string `json:"tip"` +} + +type AllPayTypeMgr []AllPayType + +// GetPayType 保留支付中心返回的全部支付类型和名称,不再限定为支付宝/微信。 +func (a AllPayTypeMgr) GetPayType(money int64) []PayTypeInfo { + result := make([]PayTypeInfo, 0) + for _, item := range a { + itemMoney, err := ParseYuanToFen(item.Money) + if err != nil || itemMoney != money { + continue + } + for _, payType := range item.Types { + result = append(result, PayTypeInfo{ + Type: payType.Type, + TypeName: payTypeDisplayName(payType.Type, payType.Name), + }) + } + } + return result +} + +func NewPayTypeReq(ctx context.Context, moneys []int64) *GainPayTypeReq { + moneyStrings := make([]string, 0, len(moneys)) + for _, money := range moneys { + moneyStrings = append(moneyStrings, FenToYuan(money)) + } + return &GainPayTypeReq{Ctx: ctx, Money: moneyStrings} +} + +func (in *GainPayTypeReq) GetPayType() (AllPayTypeMgr, error) { + if in == nil { + return nil, errors.New("pay center get pay type: nil request") + } + if err := validateConfig(false); err != nil { + return nil, err + } + if len(in.Money) == 0 { + return AllPayTypeMgr{}, nil + } + return in.getPayTypeWithCache() +} + +// GetPayChannelByMoney 供会员卡、金币、钻石和促销入口共用,统一转换业务错误。 +func GetPayChannelByMoney(ctx context.Context, moneys []int64) (AllPayTypeMgr, *srvdef.SrvErr) { + channels, err := NewPayTypeReq(ctx, moneys).GetPayType() + if err != nil { + log.Error("【新支付中心 /api/shark/getPayType】查询支付通道失败", log.Any("amounts", moneys), log.E(err)) + return nil, srvdef.New(srvdef.Err, "支付通道暂不可用", err.Error()) + } + return channels, nil +} + +func (in *GainPayTypeReq) getPayTypeFromProvider() (AllPayTypeMgr, error) { + in.fill() + resp, err := httputil.NewCtx(in.Ctx).POSTJson(in.GetURL(), nil, in) + if err != nil { + log.Error("【新支付中心 /api/shark/getPayType】请求失败", log.E(err)) + return nil, fmt.Errorf("get pay type: %w", err) + } + defer resp.Body.Body.Close() + + body, err := resp.Body.ReadAll() + if err != nil { + log.Error("【新支付中心 /api/shark/getPayType】读取响应失败", + log.Any("httpStatus", resp.StatusCode), + log.Any("contentType", resp.Body.Header.Get("Content-Type")), + log.E(err), + ) + return nil, fmt.Errorf("get pay type: read response: %w", err) + } + bodyForLog := body + truncated := false + if len(bodyForLog) > payCenterErrorBodyLogLimit { + bodyForLog = bodyForLog[:payCenterErrorBodyLogLimit] + truncated = true + } + + responseMeta := func() string { + return fmt.Sprintf("HTTP status %d, content-type %q, body %q, body-truncated %t", + resp.StatusCode, + resp.Body.Header.Get("Content-Type"), + string(bodyForLog), + truncated, + ) + } + logInvalidResponse := func(message string, responseErr error) { + fields := []log.Field{ + log.Any("httpStatus", resp.StatusCode), + log.Any("contentType", resp.Body.Header.Get("Content-Type")), + log.Any("responseBody", string(bodyForLog)), + log.Any("bodyTruncated", truncated), + } + if responseErr != nil { + fields = append(fields, log.E(responseErr)) + } + log.Error(message, fields...) + } + if resp.StatusCode != http.StatusOK { + logInvalidResponse("【新支付中心 /api/shark/getPayType】HTTP状态异常", nil) + return nil, fmt.Errorf("get pay type: %s", responseMeta()) + } + + var response GainPayTypeResp + if err := json.Unmarshal(body, &response); err != nil { + logInvalidResponse("【新支付中心 /api/shark/getPayType】响应JSON解析失败", err) + return nil, fmt.Errorf("get pay type: decode response: %w; %s", err, responseMeta()) + } + if response.Code != http.StatusOK || response.Err != "" { + logInvalidResponse("【新支付中心 /api/shark/getPayType】支付中心返回失败", nil) + return nil, fmt.Errorf("get pay type: provider code %d; %s", response.Code, responseMeta()) + } + return AllPayTypeMgr(response.Msg), nil +} + +func (in *GainPayTypeReq) fill() { + in.MercID = payCenterConf.MercID + in.Time = Generate13DigitString() + in.Sign = md5Sign(in.MercID + strings.Join(in.Money, "") + payCenterConf.MercSecret) +} + +func (in *GainPayTypeReq) GetURL() string { + return payCenterConf.ApiUrl + "/api/shark/getPayType" +} diff --git a/pub/payCenter/paytype_cache.go b/pub/payCenter/paytype_cache.go new file mode 100644 index 0000000..75991ec --- /dev/null +++ b/pub/payCenter/paytype_cache.go @@ -0,0 +1,108 @@ +package payCenter + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + "time" + + "videoweb/pub/common/log" + "videoweb/pub/redis" + + "golang.org/x/sync/singleflight" +) + +var payTypeRequests singleflight.Group + +// 通道按商户、支付中心和金额组合缓存一分钟;缓存异常直接查询支付中心。 +const payTypesCacheExpire = time.Minute + +func (in *GainPayTypeReq) getPayTypeWithCache() (AllPayTypeMgr, error) { + if in.Ctx == nil || redis.Client() == nil { + return in.getPayTypeFromProvider() + } + + cacheKey := in.payTypeCacheKey() + if payTypes, found := loadCachedPayTypes(in, cacheKey); found { + return payTypes, nil + } + + resultCh := payTypeRequests.DoChan(cacheKey, func() (any, error) { + if payTypes, found := loadCachedPayTypes(in, cacheKey); found { + return payTypes, nil + } + + payTypes, err := in.getPayTypeFromProvider() + if err != nil { + return nil, err + } + storeCachedPayTypes(in, cacheKey, payTypes) + return payTypes, nil + }) + + select { + case <-in.Ctx.Done(): + return nil, in.Ctx.Err() + case result := <-resultCh: + if result.Err != nil { + if result.Shared { + return in.getPayTypeFromProvider() + } + return nil, result.Err + } + payTypes, ok := result.Val.(AllPayTypeMgr) + if !ok { + return nil, fmt.Errorf("pay center cached pay type has unexpected result type %T", result.Val) + } + return payTypes, nil + } +} + +func (in *GainPayTypeReq) payTypeCacheKey() string { + moneys := slices.Clone(in.Money) + sort.Strings(moneys) + moneys = slices.Compact(moneys) + + fingerprint := strings.Join([]string{ + payCenterConf.ApiUrl, + payCenterConf.MercID, + in.Mode, + in.Payload, + strings.Join(moneys, ","), + }, "\x00") + digest := sha256.Sum256([]byte(fingerprint)) + return "videoweb:newpaycenter:pay-types:v1:" + hex.EncodeToString(digest[:]) +} + +func loadCachedPayTypes(in *GainPayTypeReq, cacheKey string) (AllPayTypeMgr, bool) { + payload, err := redis.Get(cacheKey) + if err != nil { + log.Error("【新支付中心 /api/shark/getPayType】读取通道缓存失败", log.E(err)) + return nil, false + } + if payload == nil { + return nil, false + } + + var payTypes AllPayTypeMgr + if err := json.Unmarshal([]byte(*payload), &payTypes); err != nil { + log.Error("【新支付中心 /api/shark/getPayType】解析通道缓存失败", log.E(err)) + return nil, false + } + return payTypes, true +} + +func storeCachedPayTypes(in *GainPayTypeReq, cacheKey string, payTypes AllPayTypeMgr) { + payload, err := json.Marshal(payTypes) + if err != nil { + log.Error("【新支付中心 /api/shark/getPayType】编码通道缓存失败", log.E(err)) + return + } + if err := redis.Set(cacheKey, payload, payTypesCacheExpire); err != nil { + log.Error("【新支付中心 /api/shark/getPayType】写入通道缓存失败", log.E(err)) + } +} diff --git a/pub/payCenter/paytype_names.go b/pub/payCenter/paytype_names.go new file mode 100644 index 0000000..6152238 --- /dev/null +++ b/pub/payCenter/paytype_names.go @@ -0,0 +1,32 @@ +package payCenter + +import ( + "strings" + "sync" +) + +var payTypeNames struct { + sync.RWMutex + alipay string + wechat string +} + +// SetPayTypeNames 保留 KS 后台已配置的支付名称;未配置时采用新支付中心返回的名称。 +func SetPayTypeNames(alipay, wechat string) { + payTypeNames.Lock() + defer payTypeNames.Unlock() + payTypeNames.alipay = strings.TrimSpace(alipay) + payTypeNames.wechat = strings.TrimSpace(wechat) +} + +func payTypeDisplayName(kind, providerName string) string { + payTypeNames.RLock() + defer payTypeNames.RUnlock() + if kind == "alipay" && payTypeNames.alipay != "" { + return payTypeNames.alipay + } + if kind == "wechat" && payTypeNames.wechat != "" { + return payTypeNames.wechat + } + return providerName +} diff --git a/pub/payCenter/query.go b/pub/payCenter/query.go new file mode 100644 index 0000000..eb9ca67 --- /dev/null +++ b/pub/payCenter/query.go @@ -0,0 +1,58 @@ +package payCenter + +import ( + "context" + "errors" + "fmt" + + "videoweb/pub/common/httputil" + "videoweb/pub/common/log" +) + +type RechargeQueryResp struct { + Code int `json:"code"` + Msg QueryOrderInfo `json:"msg"` +} + +type QueryOrderInfo struct { + MercID string `json:"mercID"` + TradeNo string `json:"tradeNo"` + Money float32 `json:"money"` + PayMoney float32 `json:"payMoney"` + PayTime string `json:"payTime"` + PayStatus string `json:"payStatus"` + NotifyStatus string `json:"notifyStatus"` +} + +func (in *Recharge) GetQueryURL() string { + return payCenterConf.ApiUrl + "/api/shark/order/queryOrder" +} + +func (in *Recharge) QueryOrder(ctx context.Context) (QueryOrderInfo, error) { + if in == nil { + return QueryOrderInfo{}, errors.New("query pay center order: nil recharge") + } + if err := validateConfig(false); err != nil { + return QueryOrderInfo{}, err + } + if in.TradeNo == "" { + return QueryOrderInfo{}, errors.New("新支付中心查单缺少订单号") + } + var response RechargeQueryResp + body := map[string]string{ + "mercId": in.GetMercID(), + "tradeNo": in.TradeNo, + } + code, err := httputil.NewCtx(ctx).GetWithJResp(&response, in.GetQueryURL(), nil, body) + if err != nil { + log.Error("【新支付中心 /api/shark/order/queryOrder】查单失败", log.Any("tradeNo", in.TradeNo), log.E(err)) + return QueryOrderInfo{}, fmt.Errorf("query pay center order: %w", err) + } + if code != 200 { + return QueryOrderInfo{}, fmt.Errorf("query pay center order: HTTP status %d", code) + } + if response.Code != 200 { + return QueryOrderInfo{}, fmt.Errorf("query pay center order: provider code %d", response.Code) + } + return response.Msg, nil +} diff --git a/pub/payCenter/recharge.go b/pub/payCenter/recharge.go new file mode 100644 index 0000000..9800ef1 --- /dev/null +++ b/pub/payCenter/recharge.go @@ -0,0 +1,181 @@ +package payCenter + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + + "videoweb/pub/common/httputil" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" +) + +// Recharge 使用新支付中心的元字符串金额和商户 MD5 签名协议。 +type Recharge struct { + Ctx context.Context `json:"-"` + Time string `json:"time"` + Sign string `json:"sign"` + MercID string `json:"mercId"` + Type string `json:"type"` + Money string `json:"money"` + TradeNo string `json:"tradeNo"` + NotifyUrl string `json:"notifyUrl"` + CancelNotifyUrl string `json:"cancelNotifyUrl,omitempty"` + Info PayInfo `json:"info"` + SignType string `json:"signType"` + Mode string `json:"mode"` + Payload string `json:"payload"` +} + +type PayInfo struct { + App string `json:"app"` + PlayerId string `json:"playerId"` + PlayerIp string `json:"playerIp"` + DeviceType string `json:"deviceType"` + DeviceId string `json:"deviceId"` + Name string `json:"name"` + Tel string `json:"tel"` + AlipayAct string `json:"alipayAct"` +} + +type PayResp struct { + Code srvdef.CodeType `json:"code"` + OID string `json:"oid"` + PayUrl string `json:"payUrl"` + Mode string `json:"mode"` + Rebate string `json:"rebate"` + Sign string `json:"sign"` +} + +type RechargeResp struct { + Code int `json:"code"` + Info string `json:"info"` + Err string `json:"err"` + Msg PayResp `json:"msg"` + Tip string `json:"tip"` +} + +type RechargeCallbackResp struct { + Code int `json:"code" binding:"required"` + MercID string `json:"mercId" binding:"required"` + OID string `json:"oid" binding:"required"` + PayMoney string `json:"payMoney" binding:"required"` + TradeNo string `json:"tradeNo" binding:"required"` + Sign string `json:"sign" binding:"required"` +} + +type RefundCallbackResp struct { + Code int `json:"code"` + MercID string `json:"mercId"` + OID string `json:"oid"` + TradeNo string `json:"tradeNo"` + Sign string `json:"sign"` +} + +type rechargePostFunc func(context.Context, *Recharge, *RechargeResp) (int, error) + +func (in *Recharge) ToPayNew() (PayResp, error) { + return in.toPayNew(func(ctx context.Context, request *Recharge, response *RechargeResp) (int, error) { + return httputil.NewCtx(ctx).POSTJsonWithJResp(response, request.GetURL(), nil, request) + }) +} + +func (in *Recharge) toPayNew(post rechargePostFunc) (result PayResp, err error) { + defer func() { + if recovered := recover(); recovered != nil { + result = PayResp{} + err = fmt.Errorf("pay center request panic: %v", recovered) + if in != nil { + log.Error("【新支付中心 /api/shark/topay】请求异常", log.Any("tradeNo", in.TradeNo), log.E(err)) + } + } + if in != nil { + if err != nil { + log.Error("【新支付中心 /api/shark/topay】下单失败", log.Any("tradeNo", in.TradeNo), log.E(err)) + } else { + log.Info("【新支付中心 /api/shark/topay】下单成功", log.Any("tradeNo", in.TradeNo), log.Any("oid", result.OID)) + } + } + }() + if in == nil { + return result, errors.New("pay center request: nil recharge") + } + if post == nil { + return result, errors.New("pay center request: nil poster") + } + if err := validateConfig(true); err != nil { + return result, err + } + amount, err := ParseYuanToFen(in.Money) + if err != nil || amount <= 0 || in.TradeNo == "" || in.Type == "" { + return result, errors.New("新支付中心下单参数错误:订单号、支付方式和正数金额不能为空") + } + in.fill() + + var response RechargeResp + code, requestErr := post(in.Ctx, in, &response) + if requestErr != nil { + return result, fmt.Errorf("post pay center order: %w", requestErr) + } + if code != http.StatusOK { + return result, fmt.Errorf("pay center returned HTTP status %d", code) + } + if response.Code != http.StatusOK { + return result, fmt.Errorf("pay center returned provider code %d", response.Code) + } + if response.Err != "" { + return result, fmt.Errorf("pay center rejected order: %s", response.Err) + } + // 外层成功但没有订单号/支付链接不能落为处理中订单。 + if response.Msg.OID == "" || response.Msg.PayUrl == "" { + return result, errors.New("新支付中心下单结果缺少订单号或支付链接") + } + return response.Msg, nil +} + +func (in *Recharge) fill() { + in.MercID = in.GetMercID() + in.Time = Generate13DigitString() + in.Info.App = payCenterConf.AppName + in.NotifyUrl = in.GetNotifyURL() + in.SignType = "1" + in.Sign = in.sign() +} + +func (in *Recharge) GetNotifyURL() string { + return payCenterConf.CallbackUrl + "/api/web/third/newpaycenter/recharge/callback" +} + +func (in *Recharge) GetCancelNotifyURL() string { + return payCenterConf.CallbackUrl + "/api/web/third/newpaycenter/refund/callback" +} + +func (in *Recharge) sign() string { + // 字段顺序必须与 girl 及支付中心约定一致,金额使用原始元字符串。 + var body bytes.Buffer + body.WriteString(in.MercID) + body.WriteString(in.Money) + body.WriteString(in.NotifyUrl) + body.WriteString(in.TradeNo) + body.WriteString(in.Type) + body.WriteString(in.GetAppSecret()) + return md5Sign(body.String()) +} + +func (in *Recharge) GetURL() string { + return payCenterConf.ApiUrl + "/api/shark/topay" +} + +func (in *Recharge) Success() string { + return "success" +} + +func (in *Recharge) GetAppSecret() string { + return payCenterConf.MercSecret +} + +func (in *Recharge) GetMercID() string { + return payCenterConf.MercID +} diff --git a/pub/payment/trans.go b/pub/payment/trans.go new file mode 100644 index 0000000..48b9840 --- /dev/null +++ b/pub/payment/trans.go @@ -0,0 +1,96 @@ +package payment + +import ( + "context" + "fmt" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/cointransmod" + "videoweb/pub/models/usermod" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" +) + +// WriteCoinTransOpt 写流水所需参数 +type WriteCoinTransOpt struct { + UserId int64 + UserName string // 可为空,自动查询 + FlowDir cointransmod.FlowDir // FlowIn / FlowOut + TranType cointransmod.TranType // 交易类型 + Amount int64 // 本次交易金币数(正整数) + OrderNo string // 关联订单号(可为空) + RelatedId int64 // 关联内容ID(可为空) + Remark string // 备注 +} + +// WriteCoinTrans 写一条金币流水记录 +// balanceBefore 和 balanceAfter 从用户当前余额自动推算: +// - 若传入 balanceAfter(>= 0),直接使用; +// - 否则查库获取用户当前余额作为 balanceAfter,再反推 balanceBefore。 +func WriteCoinTrans(ctx context.Context, opt WriteCoinTransOpt, balanceAfter int64) *srvdef.SrvErr { + userName := opt.UserName + if userName == "" { + if u, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: opt.UserId}); u != nil { + userName = u.NickName + } + } + + var balanceBefore int64 + if opt.FlowDir == cointransmod.FlowIn { + balanceBefore = balanceAfter - opt.Amount + } else { + balanceBefore = balanceAfter + opt.Amount + } + if balanceBefore < 0 { + balanceBefore = 0 + } + + remark := opt.Remark + if remark == "" { + remark = opt.TranType.Desc() + } + + doc := &cointransmod.CoinTrans{ + Id: mongos.Lid.CoinTrans.Get(), + UserId: opt.UserId, + UserName: userName, + FlowDir: opt.FlowDir, + TranType: opt.TranType, + Amount: opt.Amount, + BalanceBefore: balanceBefore, + BalanceAfter: balanceAfter, + OrderNo: opt.OrderNo, + RelatedId: opt.RelatedId, + Remark: remark, + CreatedAt: utils.Now(), + } + if serr := cointransmod.InsertOneCoinTrans(ctx, doc); serr != nil { + log.Error("WriteCoinTrans InsertOneCoinTrans failed", + log.Any("userId", opt.UserId), log.Any("tranType", opt.TranType), log.E(serr)) + return serr + } + return nil +} + +// WriteCoinTransByUser 查询用户当前余额后自动写流水(适合已更新余额后调用) +func WriteCoinTransByUser(ctx context.Context, opt WriteCoinTransOpt) *srvdef.SrvErr { + u, serr := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: opt.UserId}) + if serr != nil || u == nil { + return srvdef.New(srvdef.ErrNoData, "用户不存在", "") + } + if opt.UserName == "" { + opt.UserName = u.NickName + } + return WriteCoinTrans(ctx, opt, u.Recharge) +} + +// CoinTransRemark 生成标准备注 +func CoinTransRemark(tranType cointransmod.TranType, extra string) string { + name := tranType.Desc() + if extra != "" { + return fmt.Sprintf("%s:%s", name, extra) + } + return name +} diff --git a/pub/payment/vip.go b/pub/payment/vip.go new file mode 100644 index 0000000..8fab675 --- /dev/null +++ b/pub/payment/vip.go @@ -0,0 +1,73 @@ +package payment + +import ( + "context" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/rechargepackagemod" + "videoweb/pub/models/usermod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" +) + +// ActivateVip 支付成功后为用户开通/续期会员。 +// 若用户已有未过期会员,则从当前到期时间向后顺延;否则从当前时刻起算。 +// 同时将赠送金币(pkg.GiftCoin)计入用户余额。 +func ActivateVip(ctx context.Context, userId int64, pkg *rechargepackagemod.RechargePackage) (time.Time, *srvdef.SrvErr) { + if pkg == nil { + return time.Time{}, srvdef.New(srvdef.ErrParamError, "会员套餐不能为空", "") + } + user, serr := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) + if serr != nil { + log.Error("payment:ActivateVip FindOneUser failed", log.Any("userId", userId), log.E(serr)) + return time.Time{}, serr + } + + now := time.Now() + duration := time.Duration(pkg.DurationDays) * 24 * time.Hour + vipType := vipTypeFromDurationDays(pkg.DurationDays) + + baseTime := now + if user.CurrentVipExpire.After(now) { + baseTime = user.CurrentVipExpire + } + newExpire := baseTime.Add(duration) + + setDoc := bson.M{ + usermod.FN.User.VipType: vipType, + usermod.FN.User.CurrentVipExpire: newExpire, + usermod.FN.User.ShowCardId: pkg.Id, + usermod.FN.User.ShowCardName: pkg.Name, + usermod.FN.User.UpdatedAt: now, + } + updateDoc := bson.M{"$set": setDoc} + if pkg.GiftCoin > 0 { + updateDoc["$inc"] = bson.M{usermod.FN.User.Recharge: pkg.GiftCoin} + } + + if serr = usermod.UpdateOneUser(ctx, bson.M{usermod.FN.User.Id: userId}, updateDoc); serr != nil { + log.Error("payment:ActivateVip UpdateOneUser failed", + log.Any("userId", userId), log.Any("packageId", pkg.Id), log.E(serr)) + return time.Time{}, serr + } + + log.Info("payment:ActivateVip success", + log.Any("userId", userId), + log.Any("packageId", pkg.Id), + log.Any("vipType", vipType), + log.Any("newExpire", newExpire), + ) + return newExpire, nil +} + +func vipTypeFromDurationDays(days int64) pubdef.VipType { + if days >= 365 { + return pubdef.VipTypeYear + } + if days > 0 { + return pubdef.VipTypeMonth + } + return pubdef.VipTypeNone +} diff --git a/pub/pubdef/card.go b/pub/pubdef/card.go new file mode 100644 index 0000000..91c1aa0 --- /dev/null +++ b/pub/pubdef/card.go @@ -0,0 +1,26 @@ +package pubdef + +// 卡类型 +type CardTypeInt int32 + +// vip类型 +type VipType int32 + +const ( + VipTypeNone VipType = 0 + iota // 没卡 + VipTypeMonth VipType = 1 // 月卡 + VipTypeYear VipType = 2 // 年卡 +) + +var VipTypeNamedMap = map[VipType]string{ + VipTypeNone: "无", + VipTypeMonth: "月卡", + VipTypeYear: "年卡", +} + +type BannerInfo struct { + Img string `json:"img" bson:"img"` // banner图片 + Text string `json:"text" bson:"text"` // banner文字类 + BackgroundColor string `json:"backgroundColor" bson:"backgroundColor"` // banner背景图 + SkipUrl string `json:"skipUrl" bson:"skipUrl"` // banner跳转地址 +} diff --git a/pub/pubdef/media.go b/pub/pubdef/media.go new file mode 100644 index 0000000..0e55aff --- /dev/null +++ b/pub/pubdef/media.go @@ -0,0 +1,125 @@ +package pubdef + +import ( + "slices" +) + +type IMedia interface { + GetID() int64 + GetTitle() string + GetPrice() int64 + GetCover() string + GetPublishID() int64 + GetLocationType() LocationType + GetIsVIP() bool + GetIsDark() bool + GetCollectionIds() []int64 + GetEpisodeIds() []int64 +} + +type DateCount struct { + Date string `json:"date" bson:"date"` // 日期: yyyy-MM-dd + Count int64 `json:"count" bson:"count"` // 数量 +} + +type DateCountList []DateCount + +type MediaType = LocationType + +type ContentType int32 + +const ( + ContentTypeVideoLong ContentType = 1 // 长视频 + ContentTypeVideoDsp ContentType = 2 // 短视频 +) + +type LocationType int32 + +const ( + LocationTypeNone LocationType = 0 // 占位符,无意义 + LocationTypeVideo LocationType = 1 // 视频 + LocationTypeVideoLLD LocationType = 2 // 萝莉岛 + LocationTypeCartoon LocationType = 3 // 动漫 + LocationTypePost LocationType = 4 // 帖子 + LocationTypeComment LocationType = 5 // 评论 + LocationTypeCollection LocationType = 6 // 合集 + LocationTypeComics LocationType = 7 // 漫画 + LocationTypeNovel LocationType = 8 // 小说 + LocationTypeVoiceNovel LocationType = 9 // 有声小说 + LocationTypePhoto LocationType = 10 // 图集 + LocationTypeGame LocationType = 11 // 黄游 + LocationTypeShort LocationType = 12 // 短视频 +) + +func (s LocationType) String() string { + switch s { + case LocationTypeVideo: + return "长视频" + case LocationTypeVideoLLD: + return "萝莉岛" + case LocationTypeCartoon: + return "动漫" + case LocationTypePost: + return "动态" + case LocationTypeComment: + return "评论" + case LocationTypeCollection: + return "合集" + case LocationTypeComics: + return "漫画" + case LocationTypeNovel: + return "小说" + case LocationTypeVoiceNovel: + return "有声小说" + case LocationTypePhoto: + return "图集" + case LocationTypeGame: + return "黄游" + case LocationTypeShort: + return "短视频" + default: + return "未知" + } +} +func (s LocationType) HasIntersection(other ...LocationType) bool { + for _, v := range other { + if s == v { + return true + } + } + return false +} + +// 哪些类型可以分配创作者收益, 目前是所有类型 +func (s LocationType) CanDistributionWorkerIncome() bool { + return slices.Contains([]LocationType{ + LocationTypeVideo, + LocationTypeVideoLLD, + LocationTypeCartoon, + LocationTypePost, + LocationTypeCollection, + LocationTypeComics, + LocationTypeNovel, + LocationTypePhoto, + LocationTypeGame, + }, s) +} + +type MediaTagInfo struct { + Id int64 `json:"id"` + Name string `json:"name"` +} + +type MediaTagInfoList []MediaTagInfo + +func (s MediaTagInfoList) ToUniqueList() MediaTagInfoList { + var ret MediaTagInfoList + dict := make(map[int64]struct{}) + for _, v := range s { + if _, ok := dict[v.Id]; !ok { + dict[v.Id] = struct{}{} + ret = append(ret, v) + } + } + return ret +} diff --git a/pub/pubdef/paydef.go b/pub/pubdef/paydef.go new file mode 100644 index 0000000..4338d58 --- /dev/null +++ b/pub/pubdef/paydef.go @@ -0,0 +1,52 @@ +package pubdef + +import "videoweb/pub/common/maths" + +// VipOrderStatus 订单状态 +type VipOrderStatus int32 + +const ( + VipOrderPending VipOrderStatus = 1 // 待支付 + VipOrderSuccess VipOrderStatus = 2 // 支付成功 + VipOrderFailed VipOrderStatus = 3 // 支付失败 +) + +func (s VipOrderStatus) Desc() string { + switch s { + case VipOrderPending: + return "待支付" + case VipOrderSuccess: + return "支付成功" + case VipOrderFailed: + return "支付失败" + } + return "未知" +} + +// PayMode 支付方式 +const ( + PayModeAlipay = "alipay" // 支付宝 + PayModeWechat = "wechat" // 微信 +) + +// SubmitVipOrderInfo 第三方下单成功后的返回信息 +type SubmitVipOrderInfo struct { + TradeNo string + Oid string // 第三方订单号 + PayUrl string // 支付链接 + PayUrlMode string // 支付链接模式(webview/scheme) + PayChannel string // 支付渠道 + PayRate string // 费率 +} + +// VipOrderCallbackParam 支付回调参数(标准化后) +type VipOrderCallbackParam struct { + TradeNo string // 本地订单号 + Oid string // 第三方订单号 + RealAmount int64 // 实际支付金额(分) + PaymentAt interface{} // 支付时间(time.Time) +} + +func GetTrandNoWithUUID() string { + return OrderPrefix + maths.GenUUID() +} diff --git a/pub/pubdef/pubdef.go b/pub/pubdef/pubdef.go new file mode 100644 index 0000000..a965351 --- /dev/null +++ b/pub/pubdef/pubdef.go @@ -0,0 +1,60 @@ +package pubdef + +import "fmt" + +type Page struct { + PageNum int64 `form:"pageNum" json:"pageNum,omitempty"` + PageSize int64 `form:"pageSize" json:"pageSize,omitempty"` +} + +const ( + OutputDir = "../h5/templates/page/" + DateISO = "2006-01-02" + TimeISO = "2006-01-02 15:04:05" + AppId = 1049 + AppName = "熟果短剧WEB" + AppNameZhifu = "熟果短剧web" + OrderPrefix = "CZ-SGDJWEB-" + DefaultChannelCode = "" + + // CompanyPlatform 是内容中台授权的产品名,对应 company_platform 字段 + CompanyPlatform = "熟果短剧WEB" + // seoPushEnabled 对外推送总开关。需要推送时改为 true 后重新发版。 + // false 时不登记任务,开启后再按当前页面地址生成并推送。 + // 真正提交还要求 runMod=prod,测试环境即使打开开关也只 dry-run。 + seoPushEnabled = false +) + +// TrackerAppId 数据上报 SDK / 服务端埋点共用的应用 ID +func TrackerAppId() string { + return fmt.Sprintf("JHA-%d", AppId) +} + +// Enabled 业务开关是否打开(不含环境判断)。 +func Enabled() bool { + return seoPushEnabled +} + +const PageSize int64 = 36 + +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描述 +} + +type TranslationType int64 + +const ( + TranslationTypeUrl TranslationType = 1 // URL模式 + TranslationTypeSub TranslationType = 2 // 二级导航 + TranslationTypeBottom TranslationType = 3 // 底部导航 +) diff --git a/pub/pubdef/systype.go b/pub/pubdef/systype.go new file mode 100644 index 0000000..786578d --- /dev/null +++ b/pub/pubdef/systype.go @@ -0,0 +1,132 @@ +package pubdef + +import "strings" + +type OSType string + +const ( + OSTypeNone OSType = "" + OSTypeIOS OSType = "ios" + OSTypeAndroid OSType = "android" +) + +func (o OSType) String() string { + switch o { + case OSTypeIOS: + return string(OSTypeIOS) + case OSTypeAndroid: + return string(OSTypeAndroid) + default: + return string(OSTypeNone) + } +} + +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" +) + +func (s SysType) String() string { + return string(s) +} + +func SysTypeFromString(s string) SysType { + s = strings.ToLower(strings.TrimSpace(s)) + switch s { + case string(ST_IOS): + return ST_IOS + case string(ST_Android): + return ST_Android + case string(ST_H5): + return ST_H5 + case string(ST_PC): + return ST_PC + case string(ST_H5IOS): + return ST_H5IOS + case string(ST_H5Android): + return ST_H5Android + case string(ST_H5PC): + return ST_H5PC + case string(ST_Light): + return ST_Light + case string(ST_TG): + return ST_TG + default: + return ST_None + } +} + +func ParseOSType(sysType SysType, device string) OSType { + device = strings.ToLower(device) + switch sysType { + case ST_IOS, ST_H5IOS, ST_Light: + return OSTypeIOS + case ST_Android, ST_H5Android, ST_H5PC, ST_PC: + return OSTypeAndroid + case ST_H5, ST_TG: + if strings.Contains(device, "ios") || strings.Contains(device, "iphone") || strings.Contains(device, "apple") { + return OSTypeIOS + } + return OSTypeAndroid + default: + return OSTypeNone + } +} + +// GetCenterType 获取统计中心系统类型和设备类型。 +// sysType: android / ios;devType: h5 / ios / tg 或原始 device。 +func GetCenterType(sysType SysType, device string) (sType string, dType string) { + sType = "ios" + dType = "h5" + //osType := ParseOSType(sysType, device) + //sType = osType.String() + //switch sysType { + //case ST_IOS: + // dType = "ios" + // if sType == "" { + // sType = "ios" + // } + //case ST_Android: + // dType = device + // if sType == "" { + // sType = "android" + // } + //case ST_H5IOS, ST_Light: + // dType = "h5" + // if sType == "" { + // sType = "ios" + // } + //case ST_H5Android, ST_H5PC, ST_H5, ST_PC: + // dType = "h5" + // if sType == "" { + // sType = "android" + // } + //case ST_TG: + // dType = "tg" + // if sType == "" { + // if strings.Contains(strings.ToLower(device), "ios") || + // strings.Contains(strings.ToLower(device), "iphone") || + // strings.Contains(strings.ToLower(device), "apple") { + // sType = "ios" + // } else { + // sType = "android" + // } + // } + //default: + // dType = device + // if sType == "" { + // sType = "android" + // } + //} + return +} diff --git a/pub/pubdef/user.go b/pub/pubdef/user.go new file mode 100644 index 0000000..60d78c1 --- /dev/null +++ b/pub/pubdef/user.go @@ -0,0 +1,99 @@ +package pubdef + +import ( + "time" +) + +// 机器人 +const ( + RobotId_Min = 9000 + RobotId_Max = 10000 +) + +// CrawlerUserId 爬虫观看会话的虚拟用户 ID。 +// 不对应任何真实用户;业务侧按 userId<=0 当作未登录,免费内容仍可看。 +const CrawlerUserId int64 = -1 + +func IsCrawlerUser(userId int64) bool { + return userId == CrawlerUserId +} + +// 性别 +type Gender int32 + +const ( + Gender_male Gender = 0 // 男 + Gender_female Gender = 1 // 女 +) + +// 用户类型(身份) +type UserType int32 + +const ( + UserTypeGuest UserType = 0 // 游客用户 + UserTypeNormal UserType = 1 // 普通用户(已注册) +) + +// UserTypeNone 兼容旧命名,等同游客 +const UserTypeNone = UserTypeGuest + +var UserTypeMap = map[UserType]string{ + UserTypeGuest: "游客用户", + UserTypeNormal: "普通用户", +} + +func (s UserType) String() string { + return UserTypeMap[s] +} + +// 用户状态 +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 // 设备登陆 + LoginTypeAccount // 账号登陆 + LoginTypeEmail // 邮箱登陆 +) + +type VipInfo struct { + CardId int64 `json:"cardId" bson:"cardId,omitempty"` // 会员卡ID + VipType VipType `json:"vipType" bson:"vipType,omitempty"` // VIP类型 0:没卡、1:月卡、2:年卡 + VipExpire time.Time `json:"vipExpire" bson:"vipExpire,omitempty"` // 会员过期时间 + CardName string `json:"cardName" bson:"cardName,omitempty"` // 会员卡名字 + VipExpireInt int64 `json:"vipExpireInt" bson:"-"` // 会员过期时间 + VipExpireStr string `json:"vipExpireStr" bson:"-"` // 会员过期时间 + IsUpgrade bool `json:"isUpgrade" bson:"-"` // 是否可升级 +} + +func (vi *VipInfo) IsValid() bool { + return vi.VipType > VipTypeNone && vi.VipExpire.After(time.Now()) +} + +func (vi *VipInfo) ReSet() { + vi.CardId = 0 + vi.VipType = VipTypeNone + vi.VipExpire = time.Now().AddDate(0, 0, -1) + vi.CardName = "" +} +func (vi *VipInfo) ToJson() VipInfo { + if !vi.IsValid() { + vi.ReSet() + } + vi.VipExpireInt = vi.VipExpire.Unix() + vi.VipExpireStr = vi.VipExpire.Format(TimeISO) + return *vi +} 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..21197fe --- /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..742a735 --- /dev/null +++ b/pub/redis/redis.go @@ -0,0 +1,279 @@ +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 SAddCount(key string, members ...interface{}) (int64, error) { + return redisCli.SAdd(key, members...).Result() +} + +// 无序集合操作 随机删除集合中某一元素 +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/h5path/path.go b/pub/seo/h5path/path.go new file mode 100644 index 0000000..6161306 --- /dev/null +++ b/pub/seo/h5path/path.go @@ -0,0 +1,132 @@ +// Package h5path 统一拼装熟果短剧 H5 的页面路径。 +// Abs 与 SitemapAbs 都与线上路由一致:首页保留尾斜杠,其余页面不带尾斜杠。 +package h5path + +import ( + "strconv" + "strings" + "videoweb/pub/common/utils" +) + +func Segment(slug string, id int64) string { + return utils.PathSegment(slug, id) +} + +// Entity 拼 /{prefix}/{slug|id},无有效段时只返回 prefix。 +func Entity(prefix, slug string, id int64) string { + prefix = "/" + strings.Trim(prefix, "/") + if segment := Segment(slug, id); segment != "" { + return prefix + "/" + segment + } + return prefix +} + +func Playlet(slug string, id int64) string { + if Segment(slug, id) == "" { + return "" + } + return Entity("/play", slug, id) +} + +// Drama 短剧详情页:/drama/{slug|id}。 +func Drama(slug string, id int64) string { + if Segment(slug, id) == "" { + return "" + } + return Entity("/drama", slug, id) +} + +func PlayletChapter(slug string, playletID, chapterID int64) string { + base := Playlet(slug, playletID) + if base == "" || chapterID <= 0 { + return "" + } + return base + "/" + strconv.FormatInt(chapterID, 10) +} + +func TopicDetail(slug string, id int64) string { + if Segment(slug, id) == "" { + return "/topic" + } + return Entity("/topic-detail", slug, id) +} + +func Tag(slug string, id int64) string { + if Segment(slug, id) == "" { + return "" + } + return Entity("/tags", slug, id) +} + +func ListTheme(slug string, id int64) string { + if Segment(slug, id) == "" { + return "/list" + } + return Entity("/list", slug, id) +} + +func VideoPlay(slug string, id int64) string { + if Segment(slug, id) == "" { + return "" + } + return Entity("/series-play", slug, id) +} + +func MoreList(slug string, id int64) string { + if Segment(slug, id) == "" { + return "" + } + return Entity("/more-list", slug, id) +} + +func MoreSeriesList(slug string, id int64) string { + if Segment(slug, id) == "" { + return "" + } + return Entity("/more-series-list", slug, id) +} + +// Topic 专题详情:/topics/{slug|id}。 +func Topic(slug string, id int64) string { + if Segment(slug, id) == "" { + return "" + } + return Entity("/topics", slug, id) +} + +// TopicTag 标签列表:/topics/tag/{slug|id}。 +func TopicTag(slug string, id int64) string { + if Segment(slug, id) == "" { + return "" + } + return Entity("/topics/tag", slug, id) +} + +// ShortsMore 首页模块更多列表:/shorts/more/{slug|id}。 +func ShortsMore(slug string, id int64) string { + if Segment(slug, id) == "" { + return "" + } + return Entity("/shorts/more", slug, id) +} + +func SeriesTopic(topicID int64) string { + if topicID <= 0 { + return "/series" + } + return "/series/all/" + strconv.FormatInt(topicID, 10) + "/1" +} + +// Abs 拼绝对地址。首页保留尾斜杠,其余页面与 H5 路由一致、不带尾斜杠。 +func Abs(webAddr, path string) string { + path = strings.TrimSpace(path) + if path == "" || path == "/" { + return utils.JoinUrlPathKeepSlash(webAddr, "/") + } + return utils.JoinUrlPathKeepSlash(webAddr, "/"+strings.Trim(path, "/")) +} + +// SitemapAbs 拼 sitemap 页面绝对地址,与线上 H5 路由一致:首页保留尾斜杠,其余不带。 +func SitemapAbs(webAddr, path string) string { + return Abs(webAddr, path) +} diff --git a/pub/seo/h5path/path_test.go b/pub/seo/h5path/path_test.go new file mode 100644 index 0000000..ab2bb2f --- /dev/null +++ b/pub/seo/h5path/path_test.go @@ -0,0 +1,31 @@ +package h5path + +import "testing" + +func TestSitemapAbsMatchesSiteRoutes(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {name: "home", path: "/", want: "https://sgdj-h5.80sygtm.com/"}, + {name: "shorts", path: "/shorts", want: "https://sgdj-h5.80sygtm.com/shorts"}, + {name: "drama", path: "/drama/demo", want: "https://sgdj-h5.80sygtm.com/drama/demo"}, + {name: "already_slash", path: "/topics/demo/", want: "https://sgdj-h5.80sygtm.com/topics/demo"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SitemapAbs("https://sgdj-h5.80sygtm.com", tt.path) + if got != tt.want { + t.Fatalf("SitemapAbs() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAbsKeepsExistingBehavior(t *testing.T) { + got := Abs("https://huangguo.net", "/list") + if got != "https://huangguo.net/list" { + t.Fatalf("Abs() = %q, want %q", got, "https://huangguo.net/list") + } +} 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/autopublish_image.go b/pub/storage/proxy/autopublish_image.go new file mode 100644 index 0000000..284127e --- /dev/null +++ b/pub/storage/proxy/autopublish_image.go @@ -0,0 +1,131 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "path/filepath" + "strconv" + "strings" + "time" + "videoweb/pub/common/log" + "videoweb/pub/pubdef" +) + +const mediaAutopublishImagePath = "/media/web/autopublish/image" + +type mediaImageUploadResp struct { + Code int `json:"code"` + Data struct { + Id string `json:"id"` + SourceNameFs string `json:"sourceNameFs"` + } `json:"data"` + Msg string `json:"msg"` +} + +// UploadAutopublishImage 按 App /api/app/upload/image 同样方式上传到 media +// POST {media}/media/web/autopublish/image,表单字段 file + appId,不加盖水印。 +func UploadAutopublishImage(fileName string, data []byte) (string, error) { + if len(data) == 0 { + return "", fmt.Errorf("图片内容为空") + } + fileName = sanitizeAutopublishFileName(fileName) + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + if err := mw.WriteField("appId", strconv.Itoa(pubdef.AppId)); err != nil { + return "", err + } + if err := writeAutopublishImageFile(mw, fileName, data); err != nil { + return "", err + } + if err := mw.Close(); err != nil { + return "", err + } + + base := strings.TrimRight(DomainAddressRepair("", BucketVideoAddressV2), "/") + if base == "" || (!strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://")) { + return "", fmt.Errorf("media地址未配置") + } + url := base + mediaAutopublishImagePath + req, err := http.NewRequest(http.MethodPost, url, &buf) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + + cli := &http.Client{Timeout: 60 * time.Second} + resp, err := cli.Do(req) + if err != nil { + log.Error("上传media图片失败", log.Any("url", url), log.E(err)) + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("media上传图片失败,状态码 %d,响应 %s", resp.StatusCode, trimRespBody(body)) + } + + var result mediaImageUploadResp + if err = json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("解析media上传图片响应失败: %w,响应 %s", err, trimRespBody(body)) + } + if result.Code != Success { + msg := strings.TrimSpace(result.Msg) + if msg == "" { + msg = "unknown" + } + return "", fmt.Errorf("media上传图片失败: %s,响应 %s", msg, trimRespBody(body)) + } + pathStr := strings.TrimSpace(result.Data.SourceNameFs) + if pathStr == "" { + return "", fmt.Errorf("media上传图片返回空路径") + } + return pathStr, nil +} + +func writeAutopublishImageFile(mw *multipart.Writer, fileName string, data []byte) error { + h := make(textproto.MIMEHeader) + contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName))) + if contentType == "" { + contentType = "image/jpeg" + } + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, fileName)) + h.Set("Content-Type", contentType) + part, err := mw.CreatePart(h) + if err != nil { + return err + } + _, err = part.Write(data) + return err +} + +func sanitizeAutopublishFileName(fileName string) string { + fileName = filepath.Base(strings.TrimSpace(fileName)) + fileName = strings.ReplaceAll(fileName, `"`, "") + if fileName == "" || fileName == "." || !strings.Contains(fileName, ".") { + return "playlet_cover.jpg" + } + for _, r := range fileName { + if r > 127 || r < 32 || r == '/' || r == '\\' { + return "playlet_cover.jpg" + } + } + return fileName +} + +func trimRespBody(body []byte) string { + s := strings.TrimSpace(string(body)) + if len(s) > 300 { + return s[:300] + } + return s +} diff --git a/pub/storage/proxy/object.go b/pub/storage/proxy/object.go new file mode 100644 index 0000000..40b87ef --- /dev/null +++ b/pub/storage/proxy/object.go @@ -0,0 +1,509 @@ +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 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 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 +} +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 +} + +// SyncMediaFile 将相对路径 m3u8 登记到 media 切片服(status=MediaFileSyncing) +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..627864d --- /dev/null +++ b/pub/storage/proxy/types.go @@ -0,0 +1,529 @@ +package proxy + +import ( + "strings" + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/mongos" + + "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"` // 文件名称 + 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 + IsPlaylet bool `bson:"isPlaylet,omitempty" json:"isPlaylet,omitempty"` // 是否短剧视频 + 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 { + 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_VideoV2, + Status: videoinformationmod.InformationSatusWaiting, + CategoryIds: make([]int64, 0), + Tags: make([]int64, 0), + Real: videoinformationmod.Flow{}, + UpdatedAt: time.Now(), + CreatedAt: time.Now(), + OptionAccount: info.OptionAccount, + UploadUserName: info.UploadUserName, + UnencryptedImage: info.UnencryptedImage, + SyncId: info.Id.Hex(), + } + 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: info.Id.Hex(), + 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"` // 封面 + 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 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名称) + FriendList []AdvitiseInfo `json:"friendList" bson:"friendList"` // 友链 + NineGridList []AdvitiseInfo `json:"nineGridList" bson:"nineGridList"` // 9宫格 + VideoList []AdvitiseInfo `json:"videoList" bson:"videoList"` // 视频列表 + StopList []AdvitiseInfo `json:"stopList" bson:"stopList"` // 暂停广告 + ForcedJumpList []AdvitiseInfo `json:"forcedJumpList" bson:"forcedJumpList"` // 强制跳转广告 + AppInfoAList []AdvitiseInfo `json:"appInfoAList" bson:"appInfoAList"` // 应用管理广告A + AppInfoBList []AdvitiseInfo `json:"appInfoBList" bson:"appInfoBList"` // 应用管理广告B +} +type AdvitiseQuerysResult struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` // + Data AdvitiseData `json:"data,omitempty"` +} + +// 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..519e179 --- /dev/null +++ b/pub/storage/proxy/utils.go @@ -0,0 +1,139 @@ +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" //正式 +) + +func InitBucketVideoAddress(addressV2 string) { + 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..5b7a39a --- /dev/null +++ b/pub/storage/upload.go @@ -0,0 +1,34 @@ +package storage + +import ( + "bytes" + "context" + "errors" + "mime/multipart" + "videoweb/pub/storage/proxy" +) + +// 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 QueryMedias(ctx context.Context, in *proxy.MediaQueryesReq, domain string) (*proxy.VideoQuerysResult, error) { + return proxy.QueryMedias(in, domain) +} 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..2d0a21c --- /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_sgdj_web + env GOOS=linux GOARCH=amd64 go build $(WEB_BUILD_FLAG) -o web_sgdj_web + tar czf web_sgdj_web.tar.gz web_sgdj_web + rm -f web_sgdj_web +test: + go clean + rm -f web_sgdj_web + GOOS=linux GOARCH=amd64 go build $(WEB_BUILD_FLAG) -o web_sgdj_web + tar czf web_sgdj_web.tar.gz web_sgdj_web docs + rm -f web_sgdj_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/contactus.go b/web/api/proto/contactus.go new file mode 100644 index 0000000..947ae21 --- /dev/null +++ b/web/api/proto/contactus.go @@ -0,0 +1,76 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/pubdef" +) + +// ContactUsListReq web端联系我们分页列表请求 +type ContactUsListReq struct { + // 页码 + pubdef.Page + // 用户ID + UserId *int64 `form:"userId" json:"userId"` + // 邮箱 + Email string `form:"email" json:"email"` + // 问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他 + Type *int32 `form:"type" json:"type"` + // 处理状态,0-待处理、1-已处理 + Status *int32 `form:"status" json:"status"` + // 创建开始时间,格式:2026-06-08 00:00:00 + CreateStartTime string `form:"createStartTime" json:"createStartTime"` + // 创建结束时间,格式:2026-06-09 00:00:00 + CreateEndTime string `form:"createEndTime" json:"createEndTime"` +} + +// ContactUsListResp web端联系我们分页列表响应 +type ContactUsListResp struct { + // ID + Id int64 `json:"id"` + // 用户ID + UserId int64 `json:"userId"` + // 用户名称 + UserName string `json:"userName"` + // 邮箱 + Email string `json:"email"` + // 问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他 + Type int32 `json:"type"` + // 标题 + Title string `json:"title"` + // 内容 + Content string `json:"content"` + // 附件地址 + Attachments []string `json:"attachments"` + // 处理状态,0-待处理、1-已处理 + Status int32 `json:"status"` + // 处理备注 + Remark string `json:"remark"` + // 创建时间 + CreatedAt time.Time `json:"createdAt"` + // 更新时间 + UpdatedAt time.Time `json:"updatedAt"` +} + +// ContactUsListPageResp web端联系我们分页列表响应 +type ContactUsListPageResp struct { + response.BasePageResp +} + +// ContactUsHandleReq web端批量处理联系我们请求 +type ContactUsHandleReq struct { + // 联系我们ID集合 + Ids []int64 `json:"ids" binding:"required"` + // 处理状态,0-待处理、1-已处理 + Status int32 `json:"status" binding:"required"` + // 处理备注 + Remark string `json:"remark"` +} + +// ContactUsHandleResp web端批量处理联系我们响应 +type ContactUsHandleResp struct { + // 命中数量 + MatchedCount int64 `json:"matchedCount"` + // 修改数量 + ModifiedCount int64 `json:"modifiedCount"` +} diff --git a/web/api/proto/feedback.go b/web/api/proto/feedback.go new file mode 100644 index 0000000..e175ed3 --- /dev/null +++ b/web/api/proto/feedback.go @@ -0,0 +1,72 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/pubdef" +) + +// FeedbackListReq web端意见反馈分页列表请求 +type FeedbackListReq struct { + // 页码 + pubdef.Page + // 用户ID + UserId *int64 `form:"userId" json:"userId"` + // 问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他 + Type *int32 `form:"type" json:"type"` + // 处理状态,0-待处理、1-已处理 + Status *int32 `form:"status" json:"status"` + // 创建开始时间,格式:2026-06-08 00:00:00 + CreateStartTime string `form:"createStartTime" json:"createStartTime"` + // 创建结束时间,格式:2026-06-09 00:00:00 + CreateEndTime string `form:"createEndTime" json:"createEndTime"` +} + +// FeedbackListResp web端意见反馈分页列表响应 +type FeedbackListResp struct { + // ID + Id int64 `json:"id"` + // 用户ID + UserId int64 `json:"userId"` + // 用户名称 + UserName string `json:"userName"` + // 问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他 + Type int32 `json:"type"` + // 描述内容 + Content string `json:"content"` + // 问题截图 + Screenshots []string `json:"screenshots"` + // 联系方式 + Contact string `json:"contact"` + // 处理状态,0-待处理、1-已处理 + Status int32 `json:"status"` + // 处理备注 + Remark string `json:"remark"` + // 创建时间 + CreatedAt time.Time `json:"createdAt"` + // 更新时间 + UpdatedAt time.Time `json:"updatedAt"` +} + +// FeedbackListPageResp web端意见反馈分页列表响应 +type FeedbackListPageResp struct { + response.BasePageResp +} + +// FeedbackHandleReq web端批量处理意见反馈请求 +type FeedbackHandleReq struct { + // 意见反馈ID集合 + Ids []int64 `json:"ids" binding:"required"` + // 处理状态,0-待处理、1-已处理 + Status int32 `json:"status" binding:"required"` + // 处理备注 + Remark string `json:"remark"` +} + +// FeedbackHandleResp web端批量处理意见反馈响应 +type FeedbackHandleResp struct { + // 命中数量 + MatchedCount int64 `json:"matchedCount"` + // 修改数量 + ModifiedCount int64 `json:"modifiedCount"` +} 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/home_config.go b/web/api/proto/home_config.go new file mode 100644 index 0000000..ac52654 --- /dev/null +++ b/web/api/proto/home_config.go @@ -0,0 +1,121 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/pubdef" +) + +// HomeConfigListReq web端首页配置分页列表请求 +type HomeConfigListReq struct { + PageNum int64 `form:"pageNum" json:"pageNum"` // 页码,默认1 + PageSize int64 `form:"pageSize" json:"pageSize"` // 每页数量,默认10,最大100 + Id *int64 `form:"id" json:"id"` // 首页配置ID + Name string `form:"name" json:"name"` // 名称,支持模糊查询 + Slug string `form:"slug" json:"slug"` // slug,精确匹配 + Type *int32 `form:"type" json:"type"` // 类型 1-短剧 3-题材 + SortType *int32 `form:"sortType" json:"sortType"` // 短剧模块排序 1-推荐 2-热播 3-最近更新 4-上架时间 5-分类,题材模块预览按专题详情默认排序 + Status *bool `form:"status" json:"status"` // 上下架状态 true-上架 false-下架 +} + +// HomeConfigListResp web端首页配置列表项 +type HomeConfigListResp struct { + Id int64 `json:"id"` // ID + Name string `json:"name"` // 名称 + Slug string `json:"slug"` // slug + Desc string `json:"desc"` // 描述 + Keyword []string `json:"keyword"` // 页面关键字 + Label string `json:"label"` + Style string `json:"style"` + CardBackgroundImage string `json:"cardBackgroundImage"` + TopicIds []int64 `json:"topicIds"` + CategoryId int64 `json:"categoryId"` + RecommendType int64 `json:"recommendType"` + Type int32 `json:"type"` // 类型 1-短剧 3-题材 + Rank int64 `json:"rank"` // 排序,越大越靠前 + Limit int64 `json:"limit"` // 首页展示数量,0 时短剧默认12条、题材默认8个 + SortType int32 `json:"sortType"` // 短剧模块排序 1-推荐 2-热播 3-最近更新 4-上架时间 5-分类,题材模块预览按专题详情默认排序 + Status bool `json:"status"` // 上下架状态 true-上架 false-下架 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 修改时间 +} + +// HomeConfigListPageResp web端首页配置分页列表响应 +type HomeConfigListPageResp struct { + response.BasePageResp +} + +// HomeConfigAddReq web端新增首页配置请求 +type HomeConfigAddReq struct { + Name string `json:"name" binding:"required"` // 名称 + Slug string `json:"slug"` // slug,不传则按 SEO 关键词或名称自动生成 + Desc string `json:"desc"` // 描述 + Keyword []string `json:"keyword"` // 页面关键字,可不传 + Label string `json:"label"` + Style string `json:"style"` + CardBackgroundImage string `json:"cardBackgroundImage"` + TopicIds []int64 `json:"topicIds"` + CategoryId int64 `json:"categoryId"` + RecommendType int64 `json:"recommendType"` + Type int32 `json:"type" binding:"required"` // 类型 1-短剧 3-题材 + Rank int64 `json:"rank"` // 排序,越大越靠前 + Limit int64 `json:"limit"` // 首页展示数量,0 时短剧默认12条、题材默认8个 + SortType int32 `json:"sortType" binding:"required"` // 短剧模块排序 1-推荐 2-热播 3-最近更新 4-上架时间 5-分类,题材模块预览按专题详情默认排序 + Status *bool `json:"status" binding:"required"` // 上下架状态 true-上架 false-下架 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// HomeConfigAddResp web端新增首页配置响应 +type HomeConfigAddResp struct { + Id int64 `json:"id"` // ID +} + +// HomeConfigUpdateReq web端更新首页配置请求 +type HomeConfigUpdateReq struct { + Id int64 `json:"id" binding:"required"` // ID + Name *string `json:"name"` // 名称 + Slug *string `json:"slug"` // slug,历史数据为空时更新会自动生成 + Desc *string `json:"desc"` // 描述 + Keyword *[]string `json:"keyword"` // 不传保持原值,传空数组清空 + Label *string `json:"label"` + Style *string `json:"style"` + CardBackgroundImage *string `json:"cardBackgroundImage"` + TopicIds *[]int64 `json:"topicIds"` + CategoryId *int64 `json:"categoryId"` + RecommendType *int64 `json:"recommendType"` + Type *int32 `json:"type"` // 类型 1-短剧 3-题材 + Rank *int64 `json:"rank"` // 排序,越大越靠前 + Limit *int64 `json:"limit"` // 首页展示数量,0 时短剧默认12条、题材默认8个 + SortType *int32 `json:"sortType"` // 短剧模块排序 1-推荐 2-热播 3-最近更新 4-上架时间 5-分类,题材模块预览按专题详情默认排序 + Status *bool `json:"status"` // 上下架状态 true-上架 false-下架 + SeoInfo *pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// HomeConfigDelReq web端删除首页配置请求 +type HomeConfigDelReq struct { + Id int64 `json:"id" binding:"required"` // ID +} + +// HomeTopicOptionsReq 首页短剧题材选项分页请求 +type HomeTopicOptionsReq struct { + PageNum int64 `form:"pageNum"` // 页码,默认 1 + PageSize int64 `form:"pageSize"` // 每页数量,默认 10,最大 100 + ConfigId int64 `form:"configId"` // 编辑时传当前首页配置 ID;不传则只查询可选题材 +} + +// HomeTopicOption 首页短剧题材选项 +type HomeTopicOption struct { + Id int64 `json:"id"` // 题材 ID + Name string `json:"name"` // 题材名称 + Status bool `json:"status"` // 是否上架;已选题材下架后仍可回显 +} + +// HomeTopicOptionsResp 首页短剧题材选项分页响应 +type HomeTopicOptionsResp struct { + List []*HomeTopicOption `json:"list"` // 当前页可选题材,排除其他启用模块占用的题材 + SelectedList []*HomeTopicOption `json:"selectedList"` // 当前配置已选题材,不受分页限制 + TotalCount int64 `json:"totalCount"` // 可选题材总数 + PageNum int64 `json:"pageNum"` // 当前页码 + PageSize int64 `json:"pageSize"` // 每页数量 +} diff --git a/web/api/proto/playlet.go b/web/api/proto/playlet.go new file mode 100644 index 0000000..89889f0 --- /dev/null +++ b/web/api/proto/playlet.go @@ -0,0 +1,708 @@ +package proto + +import ( + "time" + "videoweb/pub/common/response" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/pubdef" +) + +// 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-增量同步 + IsAdd *bool `form:"isAdd" json:"isAdd,omitempty"` // 是否已加入后台数据:true只查已加入,false只查未加入,不传查全部 + PageNumber int `form:"pageNumber" json:"pageNumber"` // 页码 + PageSize int `form:"pageSize" json:"pageSize"` // 个数 +} + +// PlayletResourceJHListReq web端嘉华短剧媒资列表请求 +type PlayletResourceJHListReq struct { + Title *string `form:"title"` // 标题 + Tag *string `form:"tag"` // 标签 + SyncType *int `form:"sync_type"` // 同步类型 1-全量同步 2-增量同步 + IsAdd *bool `form:"isAdd" json:"isAdd,omitempty"` // 是否已入库:true只查已入库,false只查未入库,不传查全部 + PageNumber int `form:"pageNumber"` // 页码 + PageSize int `form:"pageSize"` // 个数 +} + +// PlayletResourceMediaActor web端短剧媒资演员 +type PlayletResourceMediaActor struct { + Name string `json:"name"` // 名称 + Avatar string `json:"avatar"` // 头像 + Gender int `json:"gender"` // 性别 +} + +// 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"` // 是否已加入后台数据 +} + +// PlayletResourceJHListItem web端嘉华短剧媒资列表项 +type PlayletResourceJHListItem 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"` // 竖版封面 + HorizontalCoverAlt string `json:"horizontalCover"` // 兼容驼峰 + VerticalCoverAlt string `json:"verticalCover"` // 兼容驼峰 + Summary string `json:"summary"` // 简介 + TotalEpisode uint `json:"total_episode"` // 总集数 + UpdateStatus int `json:"update_status"` // 更新状态 + MediaType int `json:"media_type"` // 媒体类型 + Anchor string `json:"anchor"` // 作者 + Actors []PlayletResourceMediaActor `json:"actors"` // 演员集合 + 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"` +} + +// PlayletResourceJHListResp web端嘉华短剧媒资列表响应 +type PlayletResourceJHListResp struct { + List []*PlayletResourceJHListItem `json:"list"` + Total uint `json:"total"` +} + +// PlayletResourceUseReq web端短剧媒资入库请求 +type PlayletResourceUseReq struct { + Ids []uint `json:"ids"` // 媒资中心资源ID集合-多个用逗号隔开,请求示例[28830,28829,28828,28827,28826,28825] +} + +// 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"` +} + +// PlayletListSortType web端短剧列表排序类型,均为降序 +type PlayletListSortType int + +const ( + PlayletListSortCreatedAt PlayletListSortType = 0 // 创建时间(默认) + PlayletListSortRecommendRank PlayletListSortType = 1 // 推荐排序 + PlayletListSortUpdatedAt PlayletListSortType = 2 // 修改时间 + PlayletListSortCountLike PlayletListSortType = 3 // 点赞数 + PlayletListSortCountBrowse PlayletListSortType = 4 // 浏览数 + PlayletListSortCountCollect PlayletListSortType = 5 // 收藏数 + PlayletListSortOnlineAt PlayletListSortType = 6 // 上架时间 +) + +// PlayletListReq web端短剧分页列表请求 +type PlayletListReq struct { + pubdef.Page + Id *int64 `form:"id" json:"id"` + ThemeId *int64 `form:"themeId" json:"themeId"` // 题材id + CategoryId *int64 `form:"categoryId" json:"categoryId"` // 分类ID + TagId *int64 `form:"tagId" json:"tagId"` // 标签ID + ContentType *playletmod.ContentType `form:"contentType" json:"contentType"` // 0短剧 1普通短剧/影集 2漫画短剧 3动漫 4漫画 + OperateType *playletmod.OperateType `form:"operateType" json:"operateType"` // 运营类型 1-短剧 2-漫剧 + 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"` // 是否原创(推荐) + ShowOnBanner *bool `form:"showOnBanner" json:"showOnBanner"` + Title string `form:"title" json:"title"` + Slug string `form:"slug" json:"slug"` // slug,精确匹配 + OperatorName string `form:"operatorName" json:"operatorName"` // 最后操作人名称 + MediaSource *string `form:"mediaSource" json:"mediaSource"` // 媒资来源:不传查全部;空字符串查普通媒资;hg 查黄果媒资 + SortType PlayletListSortType `form:"sortType" json:"sortType"` // 排序:0创建时间(默认) 1推荐排序(按分类查询时为该分类排序) 2修改时间 3点赞数 4浏览数 5收藏数 6上架时间,均为降序 +} + +// PlayletListResp web端短剧列表项 +type PlayletListResp struct { + Id int64 `json:"id"` + MediaSource string `json:"mediaSource"` // 媒资来源,空为普通媒资,hg 为黄果媒资 + Status playletmod.Status `json:"status"` + IsFeatured bool `json:"isFeatured"` // 是否爆剧(true-是,false-否) + IsOriginal bool `json:"isOriginal"` // 是否原创 + ShowOriginal bool `json:"showOriginal"` // 页面是否展示原创标识 + MediaBusiness string `json:"mediaBusiness"` // 媒资业务来源,按媒资标题前缀记录,如 CGDJ;空表示未标记 + IsRecommend bool `json:"isRecommend"` + ShowOnBanner bool `json:"showOnBanner"` + Banner string `json:"banner"` + MobileBanner string `json:"mobileBanner"` // 移动端banner图 + ReviewAt time.Time `json:"reviewAt"` + OnlineAt time.Time `json:"onlineAt"` + ThemeIds []int64 `json:"themeIds"` + ThemeNames []string `json:"themeNames"` + CategoryIds []int64 `json:"categoryIds"` // 分类ID集合 + CategoryBinds []PlayletCategoryBind `json:"categoryBinds"` // 分类绑定及排序 + CategoryRank int64 `json:"categoryRank"` // 当前筛选分类下的排序 + Title string `json:"title"` + PlayName string `json:"playName"` // 剧名 + Description string `json:"description"` // SEO Description + Slug string `json:"slug"` // slug + Desc string `json:"desc"` + Anchor string `json:"anchor"` + Cover string `json:"cover"` + HorizontalCover string `json:"horizontalCover"` // 横版封面 + CompressedHorizontalCover string `json:"compressedHorizontalCover"` // 压缩横版封面 + Preview string `json:"preview"` + TagIds []int64 `json:"tagIds"` + TagNames []string `json:"tagNames"` + ContentType playletmod.ContentType `json:"contentType"` + OperateType playletmod.OperateType `json:"operateType"` // 运营类型 1-短剧 2-漫剧 + PayType playletmod.PayType `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币(支付方式为1-金币时候生效) + TotalEpisode int64 `json:"totalEpisode"` + 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"` + HeatValue int64 `json:"heatValue"` + 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"` + CompressedCover string `json:"compressedCover"` +} + +// PlayletListPageResp web端短剧分页列表响应 +type PlayletListPageResp struct { + response.BasePageResp +} + +// PlayletCategoryBind 短剧绑定的分类及该分类下的排序 +type PlayletCategoryBind struct { + Id int64 `json:"id"` // 分类ID + Rank int64 `json:"rank"` // 该分类下的排序,越大越靠前 +} + +// PlayletAddReq web端新增短剧请求 +type PlayletAddReq struct { + ThemeIds []int64 `json:"themeIds"` + CategoryIds []int64 `json:"categoryIds"` // 分类ID集合,可为空;不传或空数组表示清空 + CategoryBinds []PlayletCategoryBind `json:"categoryBinds"` // 分类绑定及排序,优先于categoryIds + Title string `json:"title" binding:"required"` + PlayName string `json:"playName"` // 剧名 + Slug string `json:"slug"` // slug,不传则按 SEO 关键词或标题自动生成 + Desc string `json:"desc"` + Anchor string `json:"anchor"` + Cover string `json:"cover"` + HorizontalCover string `json:"horizontalCover"` // 横版封面 + Preview string `json:"preview"` + TagIds []int64 `json:"tagIds"` + ContentType playletmod.ContentType `json:"contentType"` + OperateType playletmod.OperateType `json:"operateType"` // 运营类型 1-短剧 2-漫剧,不传默认短剧 + PayType playletmod.PayType `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins int64 `json:"singleCoins"` // 单集金币(支付方式为1-金币时候生效) + TotalEpisode int64 `json:"totalEpisode"` + TopRank int64 `json:"topRank"` + RecommendRank int64 `json:"recommendRank"` + Status playletmod.Status `json:"status"` + IsFeatured bool `json:"isFeatured"` // 是否爆剧(true-是,false-否) + ShowOriginal bool `json:"showOriginal"` // 页面是否展示原创标识 + IsRecommend bool `json:"isRecommend"` + ShowOnBanner bool `json:"showOnBanner"` + Banner string `json:"banner"` + MobileBanner string `json:"mobileBanner"` // 移动端banner图 + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 + OnlineAt *time.Time `json:"onlineAt"` // 上架时间,上架时不传则按当前时间 +} + +// PlayletAddResp web端新增短剧响应 +type PlayletAddResp struct { + Id int64 `json:"id"` +} + +// PlayletUpdateReq web端更新短剧请求 +type PlayletUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Status *playletmod.Status `json:"status"` + IsFeatured *bool `json:"isFeatured"` // 是否爆剧(true-是,false-否) + ShowOriginal *bool `json:"showOriginal"` // 页面是否展示原创标识,不传保持原值 + ShowOnBanner *bool `json:"showOnBanner"` + Banner *string `json:"banner"` + MobileBanner *string `json:"mobileBanner"` // 移动端banner图,传空字符串可清空 + ThemeIds *[]int64 `json:"themeIds"` + CategoryIds *[]int64 `json:"categoryIds"` // 分类ID集合,可为空;未传categoryBinds时使用 + CategoryBinds *[]PlayletCategoryBind `json:"categoryBinds"` // 分类绑定及排序,优先于categoryIds + Title *string `json:"title"` + PlayName *string `json:"playName"` // 剧名 + Slug *string `json:"slug"` // slug,历史数据为空时更新会自动生成 + Desc *string `json:"desc"` + Anchor *string `json:"anchor"` + Cover *string `json:"cover"` + HorizontalCover *string `json:"horizontalCover"` // 横版封面 + Preview *string `json:"preview"` + TagIds *[]int64 `json:"tagIds"` + ContentType *playletmod.ContentType `json:"contentType"` + OperateType *playletmod.OperateType `json:"operateType"` // 运营类型 1-短剧 2-漫剧 + PayType *playletmod.PayType `json:"payType"` // 支付方式 0-免费 1-金币 2-VIP + SingleCoins *int64 `json:"singleCoins"` // 单集金币(支付方式为1-金币时候生效) + TotalEpisode *int64 `json:"totalEpisode"` + TopRank *int64 `json:"topRank"` + RecommendRank *int64 `json:"recommendRank"` + IsRecommend *bool `json:"isRecommend"` + SeoInfo *pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 + OnlineAt *time.Time `json:"onlineAt"` // 上架时间,新上架时不传则按当前时间 +} + +// PlayletDelReq web端单/批量删除短剧请求 +type PlayletDelReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条删除,传多个表示批量删除 +} + +// 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个表示单条,传多个表示批量;会重置其下全部剧集水印 +} + +// PlayletStatusSetReq web端单/批量设置短剧上下架请求 +type PlayletStatusSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + Status playletmod.Status `json:"status"` // 上架状态 0-下架 1-上架 + OnlineAt *time.Time `json:"onlineAt"` // 上架时间;传入则使用该时间,未传且从未设置过则用当前时间 +} + +// PlayletOnlineAtFillResp 回填已上架但上架时间为空的短剧 +type PlayletOnlineAtFillResp struct { + UpdatedCount int64 `json:"updatedCount"` // 本次回填数量 +} + +// 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-否) +} + +// PlayletOperateTypeSetReq web端单/批量设置短剧运营类型请求 +type PlayletOperateTypeSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + OperateType playletmod.OperateType `json:"operateType"` // 运营类型 1-短剧 2-漫剧 +} + +// PlayletBannerSetReq web端单/批量设置短剧是否显示在首页banner请求 +type PlayletBannerSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + ShowOnBanner bool `json:"showOnBanner"` // 是否显示在首页banner(true-显示,false-不显示) +} + +// PlayletThemeSetReq web端单/批量设置短剧题材请求 +type PlayletThemeSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + ThemeIds []int64 `json:"themeIds"` // 题材ID集合,可设置多个题材 +} + +// PlayletTagSetReq web端单/批量设置短剧标签请求 +type PlayletTagSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + TagIds *[]int64 `json:"tagIds"` // 标签ID集合,可设置多个标签,传空数组表示清空标签,不传表示参数错误 +} + +// PlayletCategorySetReq web端单/批量设置短剧分类请求 +type PlayletCategorySetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + CategoryIds *[]int64 `json:"categoryIds"` // 分类ID集合,未传categoryBinds时使用,传空数组表示清空分类 + CategoryBinds *[]PlayletCategoryBind `json:"categoryBinds"` // 分类绑定及排序,优先于categoryIds,传空数组表示清空分类 +} + +// PlayletVirtualLikeSetReq web端单/批量设置短剧虚拟点赞数请求 +type PlayletVirtualLikeSetReq struct { + Ids []int64 `json:"ids"` // 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + MinVirtualCountLike int64 `json:"minVirtualCountLike"` // 虚拟点赞数最小值,必须大于等于0 + MaxVirtualCountLike int64 `json:"maxVirtualCountLike"` // 虚拟点赞数最大值,必须大于等于最小值 +} + +// PlayletThemeListReq web端题材分页列表请求 +type PlayletThemeListReq struct { + pubdef.Page + Id *int64 `form:"id" json:"id"` + Name string `form:"name" json:"name"` + Slug string `form:"slug" json:"slug"` // slug,精确匹配 + Status *bool `form:"status" json:"status"` + IsRecommend *bool `form:"isRecommend" json:"isRecommend"` // 是否推荐 +} + +// PlayletThemeListResp web端题材列表项 +type PlayletThemeListResp struct { + Id int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` // slug + Icon string `json:"icon"` + Status bool `json:"status"` + IsRecommend bool `json:"isRecommend"` // 是否推荐 + 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"` + IsRecommend bool `json:"isRecommend"` // 是否推荐 + Rank int64 `json:"rank"` + SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletThemeAddResp web端新增题材响应 +type PlayletThemeAddResp struct { + Id int64 `json:"id"` +} + +// PlayletThemeUpdateReq web端更新题材请求 +type PlayletThemeUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Name *string `json:"name"` + Slug *string `json:"slug"` // slug,历史数据为空时更新会自动生成 + Icon *string `json:"icon"` + Status *bool `json:"status"` + IsRecommend *bool `json:"isRecommend"` // 是否推荐 + Rank *int64 `json:"rank"` + SeoInfo *pubdef.SeoValue `json:"seoInfo"` // Seo相关信息 +} + +// PlayletThemeDelReq web端删除题材请求 +type PlayletThemeDelReq struct { + Id int64 `json:"id" binding:"required"` +} + +// PlayletThemeRecommendSetReq web端单/批量设置题材是否推荐请求 +type PlayletThemeRecommendSetReq struct { + Ids []int64 `json:"ids"` // 题材ID集合,传1个表示单条设置,传多个表示批量设置 + IsRecommend bool `json:"isRecommend"` // 是否推荐(true-推荐,false-取消推荐) +} + +// PlayletTagListReq web端标签分页列表请求 +type PlayletTagListReq struct { + pubdef.Page + Id *int64 `form:"id" json:"id"` + Name string `form:"name" json:"name"` + Slug string `form:"slug" json:"slug"` // slug,精确匹配 + Status *bool `form:"status" json:"status"` +} + +// PlayletTagListResp web端标签列表项 +type PlayletTagListResp struct { + Id int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` // slug + 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"` + Slug string `json:"slug"` // slug,不传则按 SEO 关键词或名称自动生成 + 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"` + Slug *string `json:"slug"` // slug,历史数据为空时更新会自动生成 + 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"` +} + +// PlayletChapterListReq web端章节分页列表请求 +type PlayletChapterListReq struct { + pubdef.Page + PlayletId *int64 `form:"playletId" json:"playletId"` + Id *int64 `form:"id" json:"id"` + Status *playletchaptermod.Status `form:"status" json:"status"` // 上架状态 0-下架 1-上架 + WatermarkStatus *playletchaptermod.WatermarkStatus `form:"watermarkStatus" json:"watermarkStatus"` +} + +// PlayletChapterListResp web端章节列表项 +type PlayletChapterListResp struct { + Id int64 `json:"id"` + PlayletId int64 `json:"playletId"` + MediaContentId uint `json:"mediaContentId"` + CurrentEpisode int64 `json:"currentEpisode"` + Title string `json:"title"` + Md5 string `json:"md5"` + Hash string `json:"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"` + Status playletchaptermod.Status `json:"status"` // 上架状态 0-下架 1-上架 + PayType playletchaptermod.PayType `json:"payType"` + SingleCoins int64 `json:"singleCoins"` + WatermarkStatus playletchaptermod.WatermarkStatus `json:"watermarkStatus"` + MediaSourceId string `json:"mediaSourceId"` + MediaTaskId string `json:"mediaTaskId"` + 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"` + CurrentEpisode int64 `json:"currentEpisode" binding:"required"` + Title string `json:"title"` + Md5 string `json:"md5"` + Hash string `json:"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"` + SingleCoins int64 `json:"singleCoins"` + MediaSourceId string `json:"mediaSourceId"` + Status *playletchaptermod.Status `json:"status"` // 上架状态,不传按媒资来源默认 +} + +// PlayletChapterAddResp web端新增章节响应 +type PlayletChapterAddResp struct { + Id int64 `json:"id"` +} + +// PlayletChapterUpdateReq web端更新章节请求 +type PlayletChapterUpdateReq struct { + Id int64 `json:"id" binding:"required"` + CurrentEpisode *int64 `json:"currentEpisode"` + Title *string `json:"title"` + Md5 *string `json:"md5"` + Hash *string `json:"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"` + SingleCoins *int64 `json:"singleCoins"` + Status *playletchaptermod.Status `json:"status"` // 上架状态 0-下架 1-上架 +} + +// PlayletChapterStatusFillResp 回填历史剧集上架状态响应 +type PlayletChapterStatusFillResp struct { + UpdatedCount int64 `json:"updatedCount"` // 本次回填数量 +} + +// PlayletChapterStatusSetReq web端单/批量设置章节上架状态请求 +type PlayletChapterStatusSetReq struct { + Ids []int64 `json:"ids"` // 章节ID集合,传1个表示单条设置,传多个表示批量设置 + Status playletchaptermod.Status `json:"status"` // 上架状态 0-下架 1-上架 +} + +// 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) +} + +// 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"` // 同步结果集合 +} + +// PlayletSlugSyncReq web端同步历史空 slug 请求 +type PlayletSlugSyncReq struct { + MaxSize int64 `json:"maxSize"` // 本次最多处理的空 slug 记录数,默认500,最大2000 +} + +// PlayletSlugSyncResp web端同步历史空 slug 响应 +type PlayletSlugSyncResp struct { + ThemeUpdated int64 `json:"themeUpdated"` // 本次补齐的题材数 + TagUpdated int64 `json:"tagUpdated"` // 本次补齐的标签数 + PlayletUpdated int64 `json:"playletUpdated"` // 本次补齐的短剧数 + HomeConfigUpdated int64 `json:"homeConfigUpdated"` // 本次补齐的首页配置数 + HasMore bool `json:"hasMore"` // 是否还有未处理的历史数据 +} + +// PlayletSalesSortType 销售数据排序,均为降序 +type PlayletSalesSortType int + +const ( + PlayletSalesSortUnlock PlayletSalesSortType = 0 // 金币解锁次数 + PlayletSalesSortLike PlayletSalesSortType = 1 // 点赞数 + PlayletSalesSortCollect PlayletSalesSortType = 2 // 收藏数 +) + +// PlayletSalesReq 短剧销售数据列表请求。起止时间均为北京时间,都不传时默认昨天。 +type PlayletSalesReq struct { + Id *int64 `form:"id" json:"id"` // 短剧ID + Title string `form:"title" json:"title"` // 标题模糊匹配 + StartTime string `form:"startTime" json:"startTime"` // 开始时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339 + EndTime string `form:"endTime" json:"endTime"` // 结束时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339 + SortType PlayletSalesSortType `form:"sortType" json:"sortType"` // 排序:0金币解锁次数 1点赞数 2收藏数 +} + +// PlayletSalesItem 短剧销售数据列表项,指标均为所选时间段内数据。 +type PlayletSalesItem struct { + Id int64 `json:"id"` + Title string `json:"title"` + PlayName string `json:"playName"` + Cover string `json:"cover"` + Slug string `json:"slug"` + Status playletmod.Status `json:"status"` + UnlockCount int64 `json:"unlockCount"` // 金币解锁次数 + UnlockCoins int64 `json:"unlockCoins"` // 金币解锁消耗 + LikeCount int64 `json:"likeCount"` // 期间新增且当前仍在的短剧点赞数 + CollectCount int64 `json:"collectCount"` // 期间新增且当前仍在的短剧收藏数 +} + +// PlayletSalesResp 短剧销售数据列表响应,最多返回 50 条。 +type PlayletSalesResp struct { + StartTime string `json:"startTime"` // 实际统计开始时间(北京时间) + EndTime string `json:"endTime"` // 实际统计结束时间(北京时间) + List []*PlayletSalesItem `json:"list"` +} diff --git a/web/api/proto/playlet_topic_group.go b/web/api/proto/playlet_topic_group.go new file mode 100644 index 0000000..47d0358 --- /dev/null +++ b/web/api/proto/playlet_topic_group.go @@ -0,0 +1,49 @@ +package proto + +import ( + "videoweb/pub/models/playlettopicgroupmod" + "videoweb/pub/pubdef" +) + +// PlayletTopicGroupListReq 专题区块列表请求 +type PlayletTopicGroupListReq struct { + pubdef.Page // 分页参数 + Status *bool `form:"status"` // 是否启用,不传则查询全部 +} + +// PlayletTopicGroupListResp 专题区块列表响应 +type PlayletTopicGroupListResp struct { + TotalCount int64 `json:"totalCount"` // 符合条件的区块总数 + PageNum int `json:"pageNum"` // 当前页码 + PageSize int `json:"pageSize"` // 每页数量 + List playlettopicgroupmod.PlayletTopicGroupList `json:"list"` // 专题区块列表 +} + +// PlayletTopicGroupAddReq 新增专题区块请求 +type PlayletTopicGroupAddReq struct { + Name string `json:"name" binding:"required"` // 专题区块名称 + Desc string `json:"desc"` // 专题区块描述 + TopicIds []int64 `json:"topicIds"` // 专题ID顺序即展示顺序 + Rank int64 `json:"rank"` // 区块排序,越大越靠前 + Status *bool `json:"status" binding:"required"` // 是否启用 +} + +// PlayletTopicGroupAddResp 新增专题区块响应 +type PlayletTopicGroupAddResp struct { + Id int64 `json:"id"` // 新增的专题区块ID +} + +// PlayletTopicGroupUpdateReq 更新专题区块请求 +type PlayletTopicGroupUpdateReq struct { + Id int64 `json:"id" binding:"required"` // 专题区块ID + Name *string `json:"name"` // 专题区块名称,不传则不修改 + Desc *string `json:"desc"` // 专题区块描述,不传则不修改 + TopicIds *[]int64 `json:"topicIds"` // 专题ID集合,数组顺序即展示顺序,不传则不修改 + Rank *int64 `json:"rank"` // 区块排序,越大越靠前,不传则不修改 + Status *bool `json:"status"` // 是否启用,不传则不修改 +} + +// PlayletTopicGroupDeleteReq 删除专题区块请求 +type PlayletTopicGroupDeleteReq struct { + Id int64 `json:"id" binding:"required"` // 待删除的专题区块ID +} diff --git a/web/api/proto/video_slug.go b/web/api/proto/video_slug.go new file mode 100644 index 0000000..dd224a5 --- /dev/null +++ b/web/api/proto/video_slug.go @@ -0,0 +1,16 @@ +package proto + +// VideoSlugSyncReq web端同步视频相关历史空 slug 请求 +type VideoSlugSyncReq struct { + MaxSize int64 `json:"maxSize"` // 本次最多处理的空 slug 记录数,默认500,最大2000 +} + +// VideoSlugSyncResp web端同步视频相关历史空 slug 响应 +type VideoSlugSyncResp struct { + CategoryUpdated int64 `json:"categoryUpdated"` // 本次补齐的分类数 + TagUpdated int64 `json:"tagUpdated"` // 本次补齐的标签数 + TopicUpdated int64 `json:"topicUpdated"` // 本次补齐的题材数 + CreaterUpdated int64 `json:"createrUpdated"` // 本次补齐的作者数 + VideoUpdated int64 `json:"videoUpdated"` // 本次补齐的视频数 + 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..4f46a22 --- /dev/null +++ b/web/api/web_acc/acc.go @@ -0,0 +1,355 @@ +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, + 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..99afc0c --- /dev/null +++ b/web/api/web_acc/protodef.go @@ -0,0 +1,477 @@ +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"` + // 邮箱 + 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_analytics/analytics.go b/web/api/web_analytics/analytics.go new file mode 100644 index 0000000..85cb2b1 --- /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} webanalytics.AnalyticsStat "成功" +// @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} webanalytics.AnalyticsDailyStat "成功" +// @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) +} diff --git a/web/api/web_analytics/proto.go b/web/api/web_analytics/proto.go new file mode 100644 index 0000000..b2d441f --- /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"` //当月数据 +} diff --git a/web/api/web_announcement/announcement.go b/web/api/web_announcement/announcement.go new file mode 100644 index 0000000..e6076e1 --- /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 公告-列表 +// @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 公告-增加 +// @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 公告-删除 +// @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 公告-更新 +// @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_card/viporder.go b/web/api/web_card/viporder.go new file mode 100644 index 0000000..8d4b548 --- /dev/null +++ b/web/api/web_card/viporder.go @@ -0,0 +1,67 @@ +package web_card + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_viporder" + + "github.com/gin-gonic/gin" +) + +// @Tags 会员卡管理 +// @Summary 查询充值订单列表 +// @Description 分页查询充值订单,支持按 OrderType 筛选:1会员 2金币;不传 orderType 则返回全部类型 +// @Accept json +// @Produce json +// @Param param body srv_viporder.VipOrderReq true "查询参数" +// @Success 200 {object} srv_viporder.VipOrderResp "成功" +// @Failure 400 {string} string "参数错误" +// @Security ApiKeyAuth +// @Router /api/web/card/order/list [POST] +func GetOrders(ctx *gin.Context) { + var req srv_viporder.VipOrderReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if req.PageNum < 1 { + req.PageNum = 1 + } + if req.PageSize < 1 || req.PageSize > 100 { + req.PageSize = 20 + } + + resp, serr := srv_viporder.GetOrders(ctx, &req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SyncDistrictCode web端同步历史订单渠道码 +// @Tags 会员卡管理 +// @Summary 同步历史订单渠道码 +// @Description 将历史订单中空的渠道码,按用户当前 districtCode 回填。可重复调用,hasMore 为 true 时继续请求。 +// @Accept json +// @Produce json +// @Param param body srv_viporder.SyncDistrictCodeReq false "同步参数" +// @Success 200 {object} srv_viporder.SyncDistrictCodeResp "成功" +// @Failure 400 {string} string "参数错误" +// @Security ApiKeyAuth +// @Router /api/web/card/order/district/sync [POST] +func SyncDistrictCode(ctx *gin.Context) { + var req srv_viporder.SyncDistrictCodeReq + if ctx.Request.ContentLength > 0 { + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + } + resp, serr := srv_viporder.SyncDistrictCode(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_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_coin/cointrans.go b/web/api/web_coin/cointrans.go new file mode 100644 index 0000000..2c282b4 --- /dev/null +++ b/web/api/web_coin/cointrans.go @@ -0,0 +1,40 @@ +package web_coin + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_cointrans" + + "github.com/gin-gonic/gin" +) + +// @Tags 金币管理 +// @Summary 查询金币交易记录 +// @Description 分页查询金币收支流水,支持按用户、收支方向、交易类型、时间范围筛选 +// @Accept json +// @Produce json +// @Param param body srv_cointrans.CoinTransReq true "查询参数" +// @Success 200 {object} srv_cointrans.CoinTransResp "成功" +// @Failure 400 {string} string "参数错误" +// @Security ApiKeyAuth +// @Router /api/web/coin/trans [POST] +func GetCoinTrans(ctx *gin.Context) { + var req srv_cointrans.CoinTransReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if req.PageNum < 1 { + req.PageNum = 1 + } + if req.PageSize < 1 || req.PageSize > 100 { + req.PageSize = 20 + } + + resp, serr := srv_cointrans.GetCoinTrans(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_commend/commend.go b/web/api/web_commend/commend.go new file mode 100644 index 0000000..c9f7b52 --- /dev/null +++ b/web/api/web_commend/commend.go @@ -0,0 +1,168 @@ +package web_commend + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_commend" + + "github.com/gin-gonic/gin" +) + +// @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"` +} + +// @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) +} + +// @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) +} + +// @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"` +} + +// @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) +} + +// @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..6b81076 --- /dev/null +++ b/web/api/web_contactus/contactus.go @@ -0,0 +1,60 @@ +package web_contactus + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_contactus" + + "github.com/gin-gonic/gin" +) + +// List web端联系我们列表 +// @Tags web联系我们相关接口 +// @Summary web端联系我们列表 -- by 旺鸣 +// @Description 分页查询联系我们列表 +// @Accept json +// @Produce json +// @Param params query proto.ContactUsListReq true "web端联系我们列表请求" +// @Success 200 {object} proto.ContactUsListPageResp{list=[]proto.ContactUsListResp} "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/contactus/list [get] +func List(ctx *gin.Context) { + var req proto.ContactUsListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_contactus.ListContactUs(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Handle web端处理联系我们 +// @Tags web联系我们相关接口 +// @Summary web端处理联系我们 -- by 旺鸣 +// @Description 批量更新联系我们处理状态 +// @Accept json +// @Produce json +// @Param params body proto.ContactUsHandleReq true "web端联系我们处理请求" +// @Success 200 {object} proto.ContactUsHandleResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/contactus/handle [post] +func Handle(ctx *gin.Context) { + var req proto.ContactUsHandleReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_contactus.HandleContactUs(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_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_feedback/feedback.go b/web/api/web_feedback/feedback.go new file mode 100644 index 0000000..abe8aba --- /dev/null +++ b/web/api/web_feedback/feedback.go @@ -0,0 +1,60 @@ +package web_feedback + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_feedback" + + "github.com/gin-gonic/gin" +) + +// List web端意见反馈列表 +// @Tags web意见反馈相关接口 +// @Summary web端意见反馈列表 -- by 旺鸣 +// @Description 分页查询用户意见反馈列表 +// @Accept json +// @Produce json +// @Param params query proto.FeedbackListReq true "web端意见反馈列表请求" +// @Success 200 {object} proto.FeedbackListPageResp{list=[]proto.FeedbackListResp} "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/feedback/list [get] +func List(ctx *gin.Context) { + var req proto.FeedbackListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_feedback.ListFeedback(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Handle web端处理意见反馈 +// @Tags web意见反馈相关接口 +// @Summary web端处理意见反馈 -- by 旺鸣 +// @Description 批量更新意见反馈处理状态 +// @Accept json +// @Produce json +// @Param params body proto.FeedbackHandleReq true "web端意见反馈处理请求" +// @Success 200 {object} proto.FeedbackHandleResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/feedback/handle [post] +func Handle(ctx *gin.Context) { + var req proto.FeedbackHandleReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_feedback.HandleFeedback(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_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..02a9150 --- /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.Code, err.Tips) + 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.Code, err.Tips) + 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.Code, err.Tips) + 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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_homeconfig/home_config.go b/web/api/web_homeconfig/home_config.go new file mode 100644 index 0000000..a3e32d0 --- /dev/null +++ b/web/api/web_homeconfig/home_config.go @@ -0,0 +1,105 @@ +package web_homeconfig + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_homeconfig" + + "github.com/gin-gonic/gin" +) + +// List 首页配置列表 +// @Tags web首页配置相关接口 +// @Summary 首页配置列表 -- by 王鸣 +// @Description 分页查询首页配置 +// @Accept json +// @Produce json +// @Param params query proto.HomeConfigListReq true "首页配置列表请求" +// @Success 200 {object} proto.HomeConfigListPageResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/home/config/list [get] +func List(ctx *gin.Context) { + var req proto.HomeConfigListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_homeconfig.ListHomeConfig(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Add 新增首页配置 +// @Tags web首页配置相关接口 +// @Summary 新增首页配置 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.HomeConfigAddReq true "新增首页配置请求" +// @Success 200 {object} proto.HomeConfigAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/home/config/add [post] +func Add(ctx *gin.Context) { + var req proto.HomeConfigAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_homeconfig.AddHomeConfig(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Update 更新首页配置 +// @Tags web首页配置相关接口 +// @Summary 更新首页配置 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.HomeConfigUpdateReq true "更新首页配置请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/home/config/update [post] +func Update(ctx *gin.Context) { + var req proto.HomeConfigUpdateReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_homeconfig.UpdateHomeConfig(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// Del 删除首页配置 +// @Tags web首页配置相关接口 +// @Summary 删除首页配置 -- by 王鸣 +// @Accept json +// @Produce json +// @Param params body proto.HomeConfigDelReq true "删除首页配置请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/home/config/del [post] +func Del(ctx *gin.Context) { + var req proto.HomeConfigDelReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_homeconfig.DelHomeConfig(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_ipblack/ipblack.go b/web/api/web_ipblack/ipblack.go new file mode 100644 index 0000000..a888a76 --- /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, "") +} diff --git a/web/api/web_mediaresource/resource.go b/web/api/web_mediaresource/resource.go new file mode 100644 index 0000000..5b9e374 --- /dev/null +++ b/web/api/web_mediaresource/resource.go @@ -0,0 +1,163 @@ +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) +} + +// ResourceJHList web端嘉华短剧媒资列表 +// @Tags web媒资库相关接口 +// @Summary web端嘉华短剧媒资列表 -- by 王鸣 +// @Description 嘉华短剧媒资列表,固定查询 media_type=5,回填是否已入库,支持按 isAdd 筛选 +// @Accept json +// @Produce json +// @Param params query proto.PlayletResourceJHListReq true "嘉华短剧媒资列表请求" +// @Success 200 {object} proto.PlayletResourceJHListResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/resource/jh/list [get] +func ResourceJHList(ctx *gin.Context) { + var req proto.PlayletResourceJHListReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_mediaresource.ListPlayletJHResource(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ResourceJHImport 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/jh/import [post] +func ResourceJHImport(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.ImportPlayletJHResource(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err, "") + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ResourceJHChapters web端嘉华短剧媒资剧集列表 +// @Tags web媒资库相关接口 +// @Summary web端嘉华短剧媒资剧集列表 -- by 王鸣 +// @Description 按媒资ID查询嘉华短剧剧集信息 +// @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/jh/chapters [get] +func ResourceJHChapters(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.ListPlayletJHResourceChapters(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, "") + 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/home_config_topic_options.go b/web/api/web_playlet/home_config_topic_options.go new file mode 100644 index 0000000..aa9f42f --- /dev/null +++ b/web/api/web_playlet/home_config_topic_options.go @@ -0,0 +1,34 @@ +package web_playlet + +import ( + "github.com/gin-gonic/gin" + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_homeconfig" +) + +// HomeConfigTopicOptions web端首页题材下拉选项 +// @Tags web短剧相关接口 +// @Summary web端分页查询首页题材可选项 +// @Description 查询短剧题材;排除其他已启用模块占用的题材,configId用于回显当前配置已选项 +// @Accept json +// @Produce json +// @Param params query proto.HomeTopicOptionsReq true "首页题材选项请求" +// @Success 200 {object} proto.HomeTopicOptionsResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/home-config/topic/options [get] +func HomeConfigTopicOptions(ctx *gin.Context) { + var req proto.HomeTopicOptionsReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_homeconfig.ListHomeTopicOptions(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/playlet.go b/web/api/web_playlet/playlet.go new file mode 100644 index 0000000..56982ff --- /dev/null +++ b/web/api/web_playlet/playlet.go @@ -0,0 +1,604 @@ +package web_playlet + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_mediaresource" + "videoweb/web/service/srv_playlet" + + "github.com/gin-gonic/gin" +) + +// List web端短剧列表 +// @Tags web短剧相关接口 +// @Summary web端短剧列表 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Add web端新增短剧 +// @Tags web短剧相关接口 +// @Summary web端新增短剧 +// @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) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Update web端更新短剧 +// @Tags web短剧相关接口 +// @Summary web端更新短剧 +// @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); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// Del web端单/批量物理删除短剧 +// @Tags web短剧相关接口 +// @Summary web端单/批量物理删除短剧 +// @Description 根据短剧ID集合物理删除 playlet 与 playlet_chapter 记录;不删除点赞、收藏、评论、观看记录、解锁记录和支付流水 +// @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.Code, err.Tips) + 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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// FillOnlineAt web端临时回填已上架但上架时间为空的短剧 +// @Tags web短剧相关接口 +// @Summary 临时回填空上架时间 +// @Description 将已上架且上架时间为空的短剧,上架时间设置为该条修改时间 +// @Accept json +// @Produce json +// @Success 200 {object} proto.PlayletOnlineAtFillResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/online-at/fill [post] +func FillOnlineAt(ctx *gin.Context) { + resp, err := srv_playlet.FillEmptyPlayletOnlineAt(ctx) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// 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.Code, err.Tips) + 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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetOperateType web端单/批量设置短剧运营类型 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧运营类型 +// @Description 根据短剧ID集合批量更新 playlet 集合 operateType 字段,1-短剧 2-漫剧 +// @Accept json +// @Produce json +// @Param params body proto.PlayletOperateTypeSetReq true "单/批量设置短剧运营类型请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/operate-type/set [post] +func SetOperateType(ctx *gin.Context) { + var req proto.PlayletOperateTypeSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletOperateType(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + 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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetTheme web端单/批量设置短剧题材 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧题材 --by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 themeIds 字段,传1个ID表示单条设置,传多个ID表示批量设置;themeIds 可设置多个题材 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetTag web端单/批量设置短剧标签 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧标签 --by 王鸣 +// @Description 根据短剧ID集合批量更新 playlet 集合 tagIds 字段,传1个ID表示单条设置,传多个ID表示批量设置;tagIds 可设置多个标签,传空数组表示清空标签 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetCategory web端单/批量设置短剧分类 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧分类 +// @Description 根据短剧ID集合批量更新 playlet 集合 categoryIds 字段,传1个ID表示单条设置,传多个ID表示批量设置;categoryIds 可为空,可设置多个分类,不传或空数组表示清空分类 +// @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.Code, err.Tips) + 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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ChapterList web端章节列表 +// @Tags web短剧相关接口 +// @Summary web端章节列表 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ChapterAdd web端新增章节 +// @Tags web短剧相关接口 +// @Summary web端新增章节 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ChapterUpdate web端更新章节 +// @Tags web短剧相关接口 +// @Summary web端更新章节 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ChapterDel web端单/批量删除章节 +// @Tags web短剧相关接口 +// @Summary web端单/批量删除章节 +// @Description 根据章节ID集合删除 playlet_chapter 记录,传1个ID表示单条删除,传多个ID表示批量删除;同时清理该剧集的点赞、收藏、解锁记录 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ChapterFillStatus web端回填历史剧集上架状态 +// @Tags web短剧相关接口 +// @Summary 回填历史剧集为上架 +// @Description 将未写 status 字段的历史剧集设为上架,发版后调用一次 +// @Accept json +// @Produce json +// @Success 200 {object} proto.PlayletChapterStatusFillResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/chapter/status/fill [post] +func ChapterFillStatus(ctx *gin.Context) { + resp, err := srv_playlet.FillPlayletChapterStatusUp(ctx) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ChapterSetStatus web端单/批量设置章节上架状态 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置章节上架状态 --by 王鸣 +// @Description 根据章节ID集合批量更新 playlet_chapter 集合 status 字段,传1个ID表示单条设置,传多个ID表示批量设置 +// @Accept json +// @Produce json +// @Param params body proto.PlayletChapterStatusSetReq true "单/批量设置章节上架状态请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/chapter/status/set [post] +func ChapterSetStatus(ctx *gin.Context) { + var req proto.PlayletChapterStatusSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletChapterStatus(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ChapterSetPayType web端单/批量设置章节支付方式 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置章节支付方式 --by 王鸣 +// @Description 根据章节ID集合批量更新 playlet_chapter 集合 payType 字段,传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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ChapterSync web端手动同步短剧剧集 +// @Tags web短剧相关接口 +// @Summary web端手动单/批量同步短剧剧集 --by 王鸣 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// SetPayType web端单/批量设置短剧默认支付方式 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置短剧默认支付方式 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ResetWatermark web端按短剧ID批量重置剧集水印 +// @Tags web短剧相关接口 +// @Summary web端按短剧ID批量重置剧集水印 +// @Description 根据短剧ID集合,将其下全部剧集水印状态重置为未处理,并清空水印地址与 media 任务关联;原片地址不变,等待定时任务重新加水印 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SyncSlug web端同步历史空 slug +// @Tags web短剧相关接口 +// @Summary 同步历史空 slug +// @Description 为题材、标签、分类、短剧、首页配置中空的 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。 +// @Accept json +// @Produce json +// @Param param body proto.PlayletSlugSyncReq false "同步参数" +// @Success 200 {object} proto.PlayletSlugSyncResp "成功" +// @Failure 400 {string} string "参数错误" +// @Security ApiKeyAuth +// @Router /api/web/playlet/slug/sync [POST] +func SyncSlug(ctx *gin.Context) { + var req proto.PlayletSlugSyncReq + if ctx.Request.ContentLength > 0 { + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + } + resp, serr := srv_playlet.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_playlet/sales.go b/web/api/web_playlet/sales.go new file mode 100644 index 0000000..af069aa --- /dev/null +++ b/web/api/web_playlet/sales.go @@ -0,0 +1,35 @@ +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" +) + +// Sales web端短剧销售数据 +// @Tags web短剧相关接口 +// @Summary web端短剧销售数据 +// @Description 按北京时间区间汇总金币解锁、短剧点赞、短剧收藏,最多返回前50条 +// @Accept json +// @Produce json +// @Param params query proto.PlayletSalesReq true "销售数据筛选" +// @Success 200 {object} proto.PlayletSalesResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/sales [get] +func Sales(ctx *gin.Context) { + var req proto.PlayletSalesReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.ListPlayletSales(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/tag.go b/web/api/web_playlet/tag.go new file mode 100644 index 0000000..a84bab2 --- /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端标签列表 +// @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端新增标签 +// @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端更新标签 +// @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端删除标签 +// @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..a78e28a --- /dev/null +++ b/web/api/web_playlet/theme.go @@ -0,0 +1,129 @@ +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端题材列表 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ThemeAdd web端新增题材 +// @Tags web短剧相关接口 +// @Summary web端新增题材 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// ThemeUpdate web端更新题材 +// @Tags web短剧相关接口 +// @Summary web端更新题材 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetThemeRecommend web端单/批量设置题材是否推荐 +// @Tags web短剧相关接口 +// @Summary web端单/批量设置题材是否推荐 +// @Description 根据题材ID集合批量更新 playlet_theme 集合 isRecommend 字段 +// @Accept json +// @Produce json +// @Param params body proto.PlayletThemeRecommendSetReq true "单/批量设置题材是否推荐请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/theme/recommend/set [post] +func SetThemeRecommend(ctx *gin.Context) { + var req proto.PlayletThemeRecommendSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.SetPlayletThemeRecommend(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ThemeDel web端删除题材 +// @Tags web短剧相关接口 +// @Summary web端删除题材 +// @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.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_playlet/topic_group.go b/web/api/web_playlet/topic_group.go new file mode 100644 index 0000000..5a25b94 --- /dev/null +++ b/web/api/web_playlet/topic_group.go @@ -0,0 +1,104 @@ +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" +) + +// TopicGroupList 查询专题区块 +// @Tags web短剧相关接口 +// @Summary 查询专题区块 +// @Accept json +// @Produce json +// @Param params query proto.PlayletTopicGroupListReq true "筛选条件" +// @Success 200 {object} proto.PlayletTopicGroupListResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/topic-group/list [get] +func TopicGroupList(ctx *gin.Context) { + var req proto.PlayletTopicGroupListReq + if ctx.ShouldBindQuery(&req) != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.ListPlayletTopicGroups(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TopicGroupAdd 新增专题区块 +// @Tags web短剧相关接口 +// @Summary 新增专题区块 +// @Accept json +// @Produce json +// @Param params body proto.PlayletTopicGroupAddReq true "区块数据" +// @Success 200 {object} proto.PlayletTopicGroupAddResp "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/topic-group/add [post] +func TopicGroupAdd(ctx *gin.Context) { + var req proto.PlayletTopicGroupAddReq + if ctx.ShouldBindJSON(&req) != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_playlet.AddPlayletTopicGroup(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// TopicGroupUpdate 更新专题区块 +// @Tags web短剧相关接口 +// @Summary 更新专题区块 +// @Accept json +// @Produce json +// @Param params body proto.PlayletTopicGroupUpdateReq true "区块数据" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/topic-group/update [post] +func TopicGroupUpdate(ctx *gin.Context) { + var req proto.PlayletTopicGroupUpdateReq + if ctx.ShouldBindJSON(&req) != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.UpdatePlayletTopicGroup(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// TopicGroupDel 删除专题区块 +// @Tags web短剧相关接口 +// @Summary 删除专题区块 +// @Accept json +// @Produce json +// @Param params body proto.PlayletTopicGroupDeleteReq true "区块ID" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/playlet/topic-group/del [post] +func TopicGroupDel(ctx *gin.Context) { + var req proto.PlayletTopicGroupDeleteReq + if ctx.ShouldBindJSON(&req) != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_playlet.DeletePlayletTopicGroup(ctx, req.Id); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} diff --git a/web/api/web_recharge/new_pay_center.go b/web/api/web_recharge/new_pay_center.go new file mode 100644 index 0000000..0ccec85 --- /dev/null +++ b/web/api/web_recharge/new_pay_center.go @@ -0,0 +1,140 @@ +package web_recharge + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/vipordermod" + "videoweb/pub/payCenter" + "videoweb/pub/pubdef" + srv_recharge "videoweb/web/service/srv_recharge" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "go.mongodb.org/mongo-driver/bson" +) + +// newPayCenterCallbackDeps 将查单和入账边界集中,便于用内存替身验证回调不会错误入账。 +type newPayCenterCallbackDeps struct { + findOrder func(context.Context, string) (*vipordermod.VipOrder, *srvdef.SrvErr) + queryOrder func(context.Context, string) (payCenter.QueryOrderInfo, error) + applyRecharge func(*gin.Context, *srv_recharge.BillCallbackParam) *srvdef.SrvErr +} + +// NewPayCenterRchgCallBack 接收新支付中心签名充值通知。 +// @Tags 支付回调 +// @Summary 新支付中心充值回调 +// @Description 校验商户号、MD5签名、金额及平台订单号,查单取得支付时间后按原流程入账;重复成功通知直接应答成功。 +// @Accept json +// @Produce plain +// @Param request body payCenter.RechargeCallbackResp true "支付中心签名回调" +// @Success 200 {string} string "success" +// @Failure 400 {string} string "fail" +// @Router /api/web/third/newpaycenter/recharge/callback [POST] +func NewPayCenterRchgCallBack(ctx *gin.Context) { + deps := newPayCenterCallbackDeps{ + findOrder: func(c context.Context, tradeNo string) (*vipordermod.VipOrder, *srvdef.SrvErr) { + return vipordermod.FindOneVipOrder(c, bson.M{vipordermod.FN.VipOrder.TradeNo: tradeNo}) + }, + queryOrder: func(c context.Context, tradeNo string) (payCenter.QueryOrderInfo, error) { + return (&payCenter.Recharge{Ctx: c, TradeNo: tradeNo}).QueryOrder(c) + }, + applyRecharge: srv_recharge.OnPayCenterCallback, + } + handleNewPayCenterRchgCallBack(ctx, deps) +} + +func handleNewPayCenterRchgCallBack(ctx *gin.Context, deps newPayCenterCallbackDeps) { + if err := processNewPayCenterRchgCallBack(ctx, deps); err != nil { + log.Error("【/api/web/third/newpaycenter/recharge/callback】充值回调处理失败", log.E(err)) + ctx.String(http.StatusBadRequest, "fail") + return + } + ctx.String(http.StatusOK, "success") +} + +func processNewPayCenterRchgCallBack(ctx *gin.Context, deps newPayCenterCallbackDeps) error { + var callback payCenter.RechargeCallbackResp + bindErr := ctx.ShouldBindBodyWith(&callback, binding.JSON) + bodyValue, _ := ctx.Get(gin.BodyBytesKey) + rawBody, _ := bodyValue.([]byte) + log.Info("【/api/web/third/newpaycenter/recharge/callback】收到充值回调", + log.Any("tradeNo", callback.TradeNo), + log.Any("requestBody", string(rawBody)), + log.Any("clientIP", ctx.ClientIP()), + ) + if bindErr != nil { + return fmt.Errorf("回调参数解析失败: %w", bindErr) + } + request := &payCenter.Recharge{TradeNo: callback.TradeNo} + if request.GetMercID() == "" || request.GetAppSecret() == "" { + return errors.New("新支付中心回调配置缺少 mercID 或 mercSecret") + } + if callback.Code != http.StatusOK || callback.MercID != request.GetMercID() { + return fmt.Errorf("回调状态或商户不匹配,tradeNo=%s", callback.TradeNo) + } + signSource := strconv.Itoa(callback.Code) + callback.MercID + callback.OID + callback.PayMoney + callback.TradeNo + request.GetAppSecret() + if !payCenter.VerifySign(callback.Sign, signSource) { + return fmt.Errorf("回调验签失败,tradeNo=%s", callback.TradeNo) + } + amount, err := payCenter.ParseYuanToFen(callback.PayMoney) + if err != nil || amount <= 0 { + return fmt.Errorf("回调金额格式错误或非正数,tradeNo=%s", callback.TradeNo) + } + order, serr := deps.findOrder(ctx, callback.TradeNo) + if serr != nil { + return fmt.Errorf("查找本地充值订单失败,tradeNo=%s: %w", callback.TradeNo, serr) + } + if order == nil || order.TradeNo != callback.TradeNo { + return fmt.Errorf("本地充值订单不存在,tradeNo=%s", callback.TradeNo) + } + amountMismatch := amount != order.Amount + oidMismatch := order.Oid != "" && callback.OID != order.Oid + if amountMismatch || oidMismatch { + log.Error("【/api/web/third/newpaycenter/recharge/callback】回调金额或支付中心订单号不匹配", + log.Any("tradeNo", callback.TradeNo), + log.Any("userId", order.UserId), + log.Any("callbackPayMoneyYuan", callback.PayMoney), + log.Any("callbackAmountFen", amount), + log.Any("orderAmountFen", order.Amount), + log.Any("callbackOid", callback.OID), + log.Any("orderOid", order.Oid), + log.Any("orderStatus", order.Status), + log.Any("amountMismatch", amountMismatch), + log.Any("oidMismatch", oidMismatch), + ) + return fmt.Errorf("回调金额或支付中心订单号不匹配,tradeNo=%s", callback.TradeNo) + } + if order.Status == pubdef.VipOrderSuccess { + log.Info("【/api/web/third/newpaycenter/recharge/callback】重复成功通知", log.Any("tradeNo", callback.TradeNo)) + return nil + } + info, err := deps.queryOrder(ctx, callback.TradeNo) + if err != nil { + return fmt.Errorf("支付中心查单失败,tradeNo=%s: %w", callback.TradeNo, err) + } + if (info.MercID != "" && info.MercID != callback.MercID) || (info.TradeNo != "" && info.TradeNo != callback.TradeNo) { + return fmt.Errorf("支付中心查单返回了其他商户或订单,tradeNo=%s", callback.TradeNo) + } + paymentAt, err := time.ParseInLocation(time.RFC3339, info.PayTime, time.Local) + if err != nil { + return fmt.Errorf("支付中心支付时间无效,tradeNo=%s: %w", callback.TradeNo, err) + } + param := &srv_recharge.BillCallbackParam{ + TradeNo: callback.TradeNo, + Oid: callback.OID, + RealAmount: amount, + PaymentAt: paymentAt, + } + if serr := deps.applyRecharge(ctx, param); serr != nil { + return fmt.Errorf("本地充值入账失败,tradeNo=%s: %w", callback.TradeNo, serr) + } + log.Info("【/api/web/third/newpaycenter/recharge/callback】充值入账成功", log.Any("tradeNo", callback.TradeNo), log.Any("userId", order.UserId), log.Any("amount", amount)) + return nil +} diff --git a/web/api/web_recharge/summary.go b/web/api/web_recharge/summary.go new file mode 100644 index 0000000..a6c9098 --- /dev/null +++ b/web/api/web_recharge/summary.go @@ -0,0 +1,34 @@ +package web_recharge + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_viporder" + + "github.com/gin-gonic/gin" +) + +// RechargeSummary 统计指定时间范围内的充值汇总 +// @Tags wbapi统计 +// @Summary 统计充值汇总 +// @Description 按北京时间的开始/结束时间汇总支付成功订单金额与笔数 +// @Accept json +// @Produce json +// @Param startTime query string true "开始时间,北京时间,支持 2006-01-02、2006-01-02 15:04:05、RFC3339" +// @Param endTime query string true "结束时间,北京时间,支持 2006-01-02、2006-01-02 15:04:05、RFC3339" +// @Success 200 {object} srv_viporder.RechargeSummaryResp "成功" +// @Failure 400 {string} string "失败" +// @Router /wbapi/sgdjweb/recharge/summary [GET] +func RechargeSummary(ctx *gin.Context) { + var req srv_viporder.RechargeSummaryReq + if err := ctx.ShouldBindQuery(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err.Error()) + return + } + resp, serr := srv_viporder.GetRechargeSummary(ctx, &req) + if serr != nil { + common.ServerJSON(ctx, serr, nil) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/web/api/web_rechargepackage/package.go b/web/api/web_rechargepackage/package.go new file mode 100644 index 0000000..1aa2654 --- /dev/null +++ b/web/api/web_rechargepackage/package.go @@ -0,0 +1,137 @@ +package web_rechargepackage + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/service/srv_rechargepackage" + + "github.com/gin-gonic/gin" +) + +// List 查询购买套餐列表 +// @Tags 套餐管理 +// @Summary 查询购买套餐列表 -- by 王鸣 +// @Accept json +// @Produce json +// @Param param body srv_rechargepackage.RechargePackageListReq true "查询参数" +// @Success 200 {object} srv_rechargepackage.RechargePackageListResp "成功" +// @Security ApiKeyAuth +// @Router /api/web/recharge/package/list [POST] +func List(ctx *gin.Context) { + var req srv_rechargepackage.RechargePackageListReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, err := srv_rechargepackage.ListRechargePackage(ctx, &req) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// VipList 查询会员畅享套餐选项 +// @Tags 套餐管理 +// @Summary 查询会员畅享套餐选项 -- by 王鸣 +// @Description 只返回 PackageType=1 的启用套餐,字段仅包含 id 和 name,不分页 +// @Produce json +// @Success 200 {array} srv_rechargepackage.VipPackageOption "成功" +// @Security ApiKeyAuth +// @Router /api/web/recharge/package/vip/list [POST] +func VipList(ctx *gin.Context) { + resp, err := srv_rechargepackage.ListVipPackageOptions(ctx) + if err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} + +// Add 新增购买套餐 +// @Tags 套餐管理 +// @Summary 新增购买套餐 -- by 王鸣 +// @Accept json +// @Produce json +// @Param param body srv_rechargepackage.RechargePackageAddReq true "新增参数" +// @Success 200 {string} string "成功" +// @Security ApiKeyAuth +// @Router /api/web/recharge/package/add [POST] +func Add(ctx *gin.Context) { + var req srv_rechargepackage.RechargePackageAddReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_rechargepackage.AddRechargePackage(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// Update 更新购买套餐 +// @Tags 套餐管理 +// @Summary 更新购买套餐 -- by 王鸣 +// @Accept json +// @Produce json +// @Param param body srv_rechargepackage.RechargePackageUpdateReq true "更新参数" +// @Success 200 {string} string "成功" +// @Security ApiKeyAuth +// @Router /api/web/recharge/package/update [POST] +func Update(ctx *gin.Context) { + var req srv_rechargepackage.RechargePackageUpdateReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_rechargepackage.UpdateRechargePackage(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// Delete 删除购买套餐 +// @Tags 套餐管理 +// @Summary 删除购买套餐 -- by 王鸣 +// @Accept json +// @Produce json +// @Param param body srv_rechargepackage.RechargePackageDelReq true "删除参数" +// @Success 200 {string} string "成功" +// @Security ApiKeyAuth +// @Router /api/web/recharge/package/del [POST] +func Delete(ctx *gin.Context) { + var req srv_rechargepackage.RechargePackageDelReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_rechargepackage.DeleteRechargePackage(ctx, req.Id); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetStatus 单/批量设置购买套餐状态 +// @Tags 套餐管理 +// @Summary 单/批量设置购买套餐状态 -- by 王鸣 +// @Accept json +// @Produce json +// @Param param body srv_rechargepackage.RechargePackageStatusSetReq true "设置状态参数" +// @Success 200 {string} string "成功" +// @Security ApiKeyAuth +// @Router /api/web/recharge/package/status/set [POST] +func SetStatus(ctx *gin.Context) { + var req srv_rechargepackage.RechargePackageStatusSetReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_rechargepackage.SetRechargePackageStatus(ctx, &req); err != nil { + common.ServerJSON(ctx, err.Code, err.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} 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_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..ab84675 --- /dev/null +++ b/web/api/web_user/user.go @@ -0,0 +1,119 @@ +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, "") +} + +// @Tags 用户管理 +// @Summary 赠送/扣除用户金币 +// @Description coin 为正数表示赠送,为负数表示扣除;操作会自动记录金币流水 +// @Accept json +// @Produce json +// @Param param body srv_user.GiftCoinReq true "赠送参数" +// @Success 200 {string} string "成功" +// @Security ApiKeyAuth +// @Router /api/web/user/giftcoin [POST] +func GiftCoin(ctx *gin.Context) { + var req srv_user.GiftCoinReq + if err := ctx.ShouldBind(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if serr := srv_user.GiftCoin(ctx, &req); serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// @Tags 用户管理 +// @Summary 修改用户会员 +// @Description 根据会员畅享套餐ID修改用户会员信息,不处理赠送金币 +// @Accept json +// @Produce json +// @Param param body srv_user.UpdateVipReq true "修改会员参数" +// @Success 200 {string} string "成功" +// @Security ApiKeyAuth +// @Router /api/web/user/vip/update [POST] +func UpdateVip(ctx *gin.Context) { + var req srv_user.UpdateVipReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if serr := srv_user.UpdateVip(ctx, &req); serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, "") +} + +// ResetPassword web端按用户ID重置密码 +// @Tags 用户管理 +// @Summary 重置用户密码 +// @Description 传入用户ID和明文密码,服务端按 app 登录相同规则哈希后写入 +// @Accept json +// @Produce json +// @Param param body srv_user.ResetPasswordReq true "重置密码参数" +// @Success 200 {string} string "成功" +// @Security ApiKeyAuth +// @Router /api/web/user/resetPassword [POST] +func ResetPassword(ctx *gin.Context) { + var req srv_user.ResetPasswordReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if serr := srv_user.ResetPassword(ctx, &req); 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..da9f0f6 --- /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" +) + +// @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, serr := srv_videocategory.GetVideoCategorys(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 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 + } + serr := srv_videocategory.AddVideoCategory(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_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 + } + serr := srv_videocategory.DelVideoCategory(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_videocategory.VideoCategoryUpdateReq 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 + } + + serr := srv_videocategory.UpdateVideoCategory(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/creater.go b/web/api/web_video/creater.go new file mode 100644 index 0000000..0e3e041 --- /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.VideoCreaterUpdateReq 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/hottag.go b/web/api/web_video/hottag.go new file mode 100644 index 0000000..15b220f --- /dev/null +++ b/web/api/web_video/hottag.go @@ -0,0 +1,45 @@ +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/hgdjnet/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 标签管理 +// @Summary 查询今日和昨日 IP 去重统计 +// @Description 返回 app 端同步到 MongoDB 的每日访问 IP 去重数 +// @Accept json +// @Produce json +// @Success 200 {object} srv_videotag.IpStatResp "成功" +// @Router /wbapi/sgdjweb/ipstat [GET] +func GetIpStatData(ctx *gin.Context) { + resp := srv_videotag.FetchIpStat(ctx) + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/web/api/web_video/m3u8.go b/web/api/web_video/m3u8.go new file mode 100644 index 0000000..a66fa01 --- /dev/null +++ b/web/api/web_video/m3u8.go @@ -0,0 +1,125 @@ +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 + useNewKey := ctx.Query("useNewKey") == "1" || ctx.Query("newKey") == "1" + var buff *bytes.Buffer + if useNewKey { + // 短剧原片:不追加媒资版本前缀,并使用 conf/newm3u8.key + _, buff, err = m3u8.TransformExOriginalPath(req.Name, cdnDomain, origDomain, 3, webg.Conf.TsAuthKey, webg.Conf.LsjMediaConf.M3u8Yuan, "", "/api/web/newenkey") + } else { + _, 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) +} + +// NewEnkey 返回短剧原片使用的新 M3U8 解密密钥(conf/newm3u8.key)。 +func NewEnkey(ctx *gin.Context) { + fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) { + buffer, err := m3u8.NewEnkey(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..05c65fe --- /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.VideoTagInfoUpdateReq 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/topic.go b/web/api/web_video/topic.go new file mode 100644 index 0000000..9b36814 --- /dev/null +++ b/web/api/web_video/topic.go @@ -0,0 +1,111 @@ +package web_video + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videotopicmod" + "videoweb/web/service/srv_videotopic" + + "github.com/gin-gonic/gin" +) + +// @Tags 视频专题 +// @Summary 分组分页获取视频专题 +// @Description 分组分页获取视频专题 +// @Accept json +// @Produce json +// @Param param body srv_videotopic.VideoTopicReq true "参数列表" +// @Success 200 {object} srv_videotopic.VideoTopicResp "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videotopic/list [POST] +func GetVideoTopic(ctx *gin.Context) { + var req = &srv_videotopic.VideoTopicReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + resp, serr := srv_videotopic.GetVideoTopics(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 videotopicmod.VideoTopicModel true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videotopic/add [POST] +func AddOneVideoTopic(ctx *gin.Context) { + var req = &videotopicmod.VideoTopicModel{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_videotopic.AddVideoTopic(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_videotopic.VideoTopicDelReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videotopic/del [POST] +func DelOneVideoTopic(ctx *gin.Context) { + var req = &srv_videotopic.VideoTopicDelReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, err) + return + } + serr := srv_videotopic.DelVideoTopic(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_videotopic.VideoTopicUpdateReq true "参数列表" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "获取失败的返回结果" +// @Security ApiKeyAuth +// @Router /api/web/videotopic/update [POST] +func UpdateOneVideoTopic(ctx *gin.Context) { + var req = &srv_videotopic.VideoTopicUpdateReq{} + if err := ctx.ShouldBind(req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + + serr := srv_videotopic.UpdateVideoTopic(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..9ef63fd --- /dev/null +++ b/web/api/web_video/video.go @@ -0,0 +1,236 @@ +package web_video + +import ( + "videoweb/pub/common" + "videoweb/pub/common/srvdef" + "videoweb/web/api/proto" + "videoweb/web/middleware/auth" + "videoweb/web/service/srv_videoinfo" + "videoweb/web/service/srv_videoslug" + + "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) +} + +// @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 删除一个视频管理 +// @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.ErrParamError, nil) + return + } + userId := ctx.GetInt64(auth.AccId) + ua, _ := common.GetUA(ctx) + serr := srv_videoinfo.UpdateVideoInfo(ctx, req.Id, userId, req.Param, ua) + 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.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 + } + + serr := srv_videoinfo.AddVideoCollection(ctx, req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + + common.ServerJSON(ctx, srvdef.Success, "") +} + +// SetVideoFakeWatchTimes 单/批量设置视频假观看次数 +// @Tags 视频管理 +// @Summary 单/批量设置视频假观看次数 --by 旺鸣 +// @Description 根据ids批量随机设置视频假观看次数 +// @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) +} + +// SetCategory web端单/批量设置视频分类 +// @Tags 视频管理 +// @Summary web端单/批量设置视频分类 +// @Description 根据视频ID集合批量更新 video_information 集合 categoryIds 字段,传1个ID表示单条设置,传多个ID表示批量设置;categoryIds 可为空,不传或空数组表示清空分类 +// @Accept json +// @Produce json +// @Param param body srv_videoinfo.SetVideoCategoryReq true "单/批量设置视频分类请求" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/category/set [post] +func SetCategory(ctx *gin.Context) { + var req srv_videoinfo.SetVideoCategoryReq + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + if err := srv_videoinfo.SetVideoCategory(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) +} + +// SyncSlug web端同步视频相关历史空 slug +// @Tags 视频管理 +// @Summary 同步历史空 slug +// @Description 为分类、标签、题材、作者、视频中空的 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。 +// @Accept json +// @Produce json +// @Param param body proto.VideoSlugSyncReq false "同步参数" +// @Success 200 {object} proto.VideoSlugSyncResp "成功" +// @Failure 400 {string} string "参数错误" +// @Security ApiKeyAuth +// @Router /api/web/videoinfo/slug/sync [POST] +func SyncSlug(ctx *gin.Context) { + var req proto.VideoSlugSyncReq + if ctx.Request.ContentLength > 0 { + if err := ctx.ShouldBindJSON(&req); err != nil { + common.ServerJSON(ctx, srvdef.ErrParamError, nil) + return + } + } + resp, serr := srv_videoslug.SyncEmptySlugs(ctx, &req) + if serr != nil { + common.ServerJSON(ctx, serr.Code, serr.Tips) + return + } + common.ServerJSON(ctx, srvdef.Success, resp) +} diff --git a/web/deploy_prod.sh b/web/deploy_prod.sh new file mode 100755 index 0000000..9794f2f --- /dev/null +++ b/web/deploy_prod.sh @@ -0,0 +1,28 @@ +#!/bin/bash +FILE_NAME="web_mdcom_web" +REMOTE_FILE_PATH="/home/server2/web_mdcom/web/" +TAG_NAME="web_mdcom_web.tar.gz" + +set -e +set -o +function send_msg() { + msg="@jscdsndj @xiuyuan_Lee @steven0yu %0A服务:SERVER2  站点-91spz.com管理服务(WEB)%0A更新内容:$content%0A创建时间: $(date +%F_%X)%0A操作人:$(git config user.name)" + curl -X POST "https://api.telegram.org/bot6357085965:AAEmXfjZnZwnDTicL7Wjbd7cXvYv_G98w8A/sendMessage" -d "chat_id=$1&text=$msg" >/dev/null 2>&1 +} + +read -p "输入yes, 开始上传: " word +if [[ ! "yes" =~ $word ]]; then + echo "终止" +else + read -e -p "本次更新内容(输入q退出编辑): " -r content + echo "开始" + rm -rf ./$FILE_NAME ./$TAG_NAME ./yinsemd5.txt + GOOS=linux GOARCH=amd64 go build -o $FILE_NAME + md5sum $FILE_NAME | cut -d ' ' -f1 >yinsemd5.txt + tar zvcf $TAG_NAME ./$FILE_NAME yinsemd5.txt + md5sum $TAG_NAME | cut -d ' ' -f1 >yinsemd5.txt + scp -i ~/.ssh/id_rsa -P 22889 ./yinsemd5.txt server2@116.212.120.2:$REMOTE_FILE_PATH + scp -i ~/.ssh/id_rsa -P 22889 ./$TAG_NAME server2@116.212.120.2:$REMOTE_FILE_PATH + send_msg -4791758991 #服务器小组 +fi +rm -rf ./$FILE_NAME ./$TAG_NAME ./yinsemd5.txt \ No newline at end of file diff --git a/web/deploy_test.sh b/web/deploy_test.sh new file mode 100755 index 0000000..1342b70 --- /dev/null +++ b/web/deploy_test.sh @@ -0,0 +1,11 @@ +#!/bin/bash +sudo make test + +scp -P 22889 web_91av_web.tar.gz root@172.104.181.99:/server/web_91av/web/ +ssh root@172.104.181.99 -p 22889 "cd /server/web_91av/web/ && /bin/tar -xf /server/web_91av/web/web_91av_web.tar.gz && /usr/bin/supervisorctl restart web_91av_web" +if [ $? -eq 0 ];then + echo -e "\033[32m 91spz.com web更新成功 \033[0m" +else + echo -e "\033[31m 91spz.com web更新失败 \033[0m" +fi +rm -f web_91av_web.tar.gz diff --git a/web/docs/docs.go b/web/docs/docs.go new file mode 100644 index 0000000..098a514 --- /dev/null +++ b/web/docs/docs.go @@ -0,0 +1,14241 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package 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/announcement/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_announcement.AddAnnouncementReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/announcement/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_announcement.DelAnnouncementReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/announcement/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_announcement.GetAnnouncementListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_announcement.GetAnnouncementListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/announcement/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_announcement.UpdateAnnouncementReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/card/order/district/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "将历史订单中空的渠道码,按用户当前 districtCode 回填。可重复调用,hasMore 为 true 时继续请求。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "会员卡管理" + ], + "summary": "同步历史订单渠道码", + "parameters": [ + { + "description": "同步参数", + "name": "param", + "in": "body", + "schema": { + "$ref": "#/definitions/srv_viporder.SyncDistrictCodeReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_viporder.SyncDistrictCodeResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/card/order/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询充值订单,支持按 OrderType 筛选:1会员 2金币;不传 orderType 则返回全部类型", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "会员卡管理" + ], + "summary": "查询充值订单列表", + "parameters": [ + { + "description": "查询参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_viporder.VipOrderReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_viporder.VipOrderResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/coin/trans": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询金币收支流水,支持按用户、收支方向、交易类型、时间范围筛选", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "金币管理" + ], + "summary": "查询金币交易记录", + "parameters": [ + { + "description": "查询参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_cointrans.CoinTransReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_cointrans.CoinTransResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/contactus/handle": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "批量更新联系我们处理状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web联系我们相关接口" + ], + "summary": "web端处理联系我们 -- by 旺鸣", + "parameters": [ + { + "description": "web端联系我们处理请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.ContactUsHandleReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ContactUsHandleResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/contactus/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询联系我们列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web联系我们相关接口" + ], + "summary": "web端联系我们列表 -- by 旺鸣", + "parameters": [ + { + "type": "string", + "description": "创建结束时间,格式:2026-06-09 00:00:00", + "name": "createEndTime", + "in": "query" + }, + { + "type": "string", + "description": "创建开始时间,格式:2026-06-08 00:00:00", + "name": "createStartTime", + "in": "query" + }, + { + "type": "string", + "description": "邮箱", + "name": "email", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "处理状态,0-待处理、1-已处理", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他", + "name": "type", + "in": "query" + }, + { + "type": "integer", + "description": "用户ID", + "name": "userId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.ContactUsListPageResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContactUsListResp" + } + } + } + } + ] + } + }, + "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/feedback/handle": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "批量更新意见反馈处理状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web意见反馈相关接口" + ], + "summary": "web端处理意见反馈 -- by 旺鸣", + "parameters": [ + { + "description": "web端意见反馈处理请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.FeedbackHandleReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.FeedbackHandleResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/feedback/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询用户意见反馈列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web意见反馈相关接口" + ], + "summary": "web端意见反馈列表 -- by 旺鸣", + "parameters": [ + { + "type": "string", + "description": "创建结束时间,格式:2026-06-09 00:00:00", + "name": "createEndTime", + "in": "query" + }, + { + "type": "string", + "description": "创建开始时间,格式:2026-06-08 00:00:00", + "name": "createStartTime", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "处理状态,0-待处理、1-已处理", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他", + "name": "type", + "in": "query" + }, + { + "type": "integer", + "description": "用户ID", + "name": "userId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.FeedbackListPageResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.FeedbackListResp" + } + } + } + } + ] + } + }, + "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/home/config/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.HomeConfigAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeConfigAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/config/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.HomeConfigDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/config/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", + "description": "页码,默认1", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量,默认10,最大100", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序字段 1-推荐 2-热播 3-最近更新 4-新片预告", + "name": "sortType", + "in": "query" + }, + { + "type": "boolean", + "description": "上下架状态 true-上架 false-下架", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "类型 1-短剧 2-视频 3-题材", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeConfigListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/config/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.HomeConfigUpdateReq" + } + } + ], + "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": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增短剧", + "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/chapter/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增章节", + "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 记录,传1个ID表示单条删除,传多个ID表示批量删除;同时清理该剧集的点赞、收藏、解锁记录", + "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": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端章节列表", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "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" + ], + "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 字段,传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端手动单/批量同步短剧剧集 --by 王鸣", + "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": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端更新章节", + "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/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合物理删除 playlet 与 playlet_chapter 记录;不删除点赞、收藏、评论、观看记录、解锁记录和支付流水", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量物理删除短剧", + "parameters": [ + { + "description": "单/批量删除短剧请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletDelReq" + } + } + ], + "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端短剧列表", + "parameters": [ + { + "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", + "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": "是否原创(推荐)", + "name": "isRecommend", + "in": "query" + }, + { + "enum": [ + 1, + 2 + ], + "type": "integer", + "x-enum-comments": { + "OperateTypeComic": "漫剧", + "OperateTypePlaylet": "短剧" + }, + "x-enum-varnames": [ + "OperateTypePlaylet", + "OperateTypeComic" + ], + "description": "运营类型 1-短剧 2-漫剧", + "name": "operateType", + "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", + "name": "showOnBanner", + "in": "query" + }, + { + "type": "string", + "description": "slug,精确匹配", + "name": "slug", + "in": "query" + }, + { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "type": "integer", + "x-enum-comments": { + "PlayletListSortCountBrowse": "浏览数", + "PlayletListSortCountCollect": "收藏数", + "PlayletListSortCountLike": "点赞数", + "PlayletListSortCreatedAt": "创建时间(默认)", + "PlayletListSortOnlineAt": "上架时间", + "PlayletListSortRecommendRank": "推荐排序", + "PlayletListSortUpdatedAt": "修改时间" + }, + "x-enum-varnames": [ + "PlayletListSortCreatedAt", + "PlayletListSortRecommendRank", + "PlayletListSortUpdatedAt", + "PlayletListSortCountLike", + "PlayletListSortCountBrowse", + "PlayletListSortCountCollect", + "PlayletListSortOnlineAt" + ], + "description": "排序:0创建时间(默认) 1推荐排序 2修改时间 3点赞数 4浏览数 5收藏数 6上架时间,均为降序", + "name": "sortType", + "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", + "name": "title", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/online-at/fill": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "将已上架且上架时间为空的短剧,上架时间设置为该条修改时间", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "临时回填空上架时间", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletOnlineAtFillResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/operate-type/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 operateType 字段,1-短剧 2-漫剧", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧运营类型", + "parameters": [ + { + "description": "单/批量设置短剧运营类型请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletOperateTypeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "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端单/批量设置短剧默认支付方式", + "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/sales": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按北京时间区间汇总金币解锁、短剧点赞、短剧收藏,最多返回前50条", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端短剧销售数据", + "parameters": [ + { + "type": "string", + "description": "结束时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339", + "name": "endTime", + "in": "query" + }, + { + "type": "integer", + "description": "短剧ID", + "name": "id", + "in": "query" + }, + { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "x-enum-comments": { + "PlayletSalesSortCollect": "收藏数", + "PlayletSalesSortLike": "点赞数", + "PlayletSalesSortUnlock": "金币解锁次数" + }, + "x-enum-varnames": [ + "PlayletSalesSortUnlock", + "PlayletSalesSortLike", + "PlayletSalesSortCollect" + ], + "description": "排序:0金币解锁次数 1点赞数 2收藏数", + "name": "sortType", + "in": "query" + }, + { + "type": "string", + "description": "开始时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339", + "name": "startTime", + "in": "query" + }, + { + "type": "string", + "description": "标题模糊匹配", + "name": "title", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletSalesResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/slug/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "为题材、标签、分类、短剧中空的 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "同步历史空 slug", + "parameters": [ + { + "description": "同步参数", + "name": "param", + "in": "body", + "schema": { + "$ref": "#/definitions/proto.PlayletSlugSyncReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletSlugSyncResp" + } + }, + "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端新增标签", + "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端删除标签", + "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端标签列表", + "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": "string", + "description": "slug,精确匹配", + "name": "slug", + "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表示批量设置;tagIds 可设置多个标签,传空数组表示清空标签", + "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端更新标签", + "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端新增题材", + "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端删除题材", + "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端题材列表", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "description": "是否推荐", + "name": "isRecommend", + "in": "query" + }, + { + "type": "string", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "string", + "description": "slug,精确匹配", + "name": "slug", + "in": "query" + }, + { + "type": "boolean", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletThemeListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/recommend/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据题材ID集合批量更新 playlet_theme 集合 isRecommend 字段", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置题材是否推荐", + "parameters": [ + { + "description": "单/批量设置题材是否推荐请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletThemeRecommendSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 themeIds 字段,传1个ID表示单条设置,传多个ID表示批量设置;themeIds 可设置多个题材", + "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端更新题材", + "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": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端更新短剧", + "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集合,将其下全部剧集水印状态重置为未处理,并清空水印地址与 media 任务关联;原片地址不变,等待定时任务重新加水印", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端按短剧ID批量重置剧集水印", + "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/recharge/package/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "新增购买套餐 -- by 王鸣", + "parameters": [ + { + "description": "新增参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/recharge/package/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "删除购买套餐 -- by 王鸣", + "parameters": [ + { + "description": "删除参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/recharge/package/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "查询购买套餐列表 -- by 王鸣", + "parameters": [ + { + "description": "查询参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageListResp" + } + } + } + } + }, + "/api/web/recharge/package/status/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "单/批量设置购买套餐状态 -- by 王鸣", + "parameters": [ + { + "description": "设置状态参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageStatusSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/recharge/package/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "更新购买套餐 -- by 王鸣", + "parameters": [ + { + "description": "更新参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/recharge/package/vip/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "只返回 PackageType=1 的启用套餐,字段仅包含 id 和 name,不分页", + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "查询会员畅享套餐选项 -- by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/srv_rechargepackage.VipPackageOption" + } + } + } + } + } + }, + "/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/jh/chapters": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按媒资ID查询嘉华短剧剧集信息", + "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/jh/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/jh/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "嘉华短剧媒资列表,固定查询 media_type=5,回填是否已入库,支持按 isAdd 筛选", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web媒资库相关接口" + ], + "summary": "web端嘉华短剧媒资列表 -- by 王鸣", + "parameters": [ + { + "type": "boolean", + "description": "是否已入库:true只查已入库,false只查未入库,不传查全部", + "name": "isAdd", + "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.PlayletResourceJHListResp" + } + }, + "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": "boolean", + "description": "是否已加入后台数据:true只查已加入,false只查未加入,不传查全部", + "name": "isAdd", + "in": "query" + }, + { + "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/stat/analytics": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "统计数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "获取统计数据" + ], + "summary": "统计数据", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/webanalytics.AnalyticsStat" + } + }, + "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/webanalytics.AnalyticsDailyStat" + } + }, + "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/third/newpaycenter/recharge/callback": { + "post": { + "description": "校验商户号、MD5签名、金额及平台订单号,查单取得支付时间后入账;重复成功通知直接应答成功。", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "支付回调" + ], + "summary": "新支付中心充值回调", + "parameters": [ + { + "description": "支付中心签名回调", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payCenter.RechargeCallbackResp" + } + } + ], + "responses": { + "200": { + "description": "success", + "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" + } + }, + "400": { + "description": "fail", + "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/giftcoin": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "coin 为正数表示赠送,为负数表示扣除;操作会自动记录金币流水", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户管理" + ], + "summary": "赠送/扣除用户金币", + "parameters": [ + { + "description": "赠送参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_user.GiftCoinReq" + } + } + ], + "responses": { + "200": { + "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/user/vip/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据会员畅享套餐ID修改用户会员信息,不处理赠送金币", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户管理" + ], + "summary": "修改用户会员", + "parameters": [ + { + "description": "修改会员参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_user.UpdateVipReq" + } + } + ], + "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.VideoCategoryUpdateReq" + } + } + ], + "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.VideoCreaterUpdateReq" + } + } + ], + "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/setFakeWatchTimes": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据ids批量随机设置视频假观看次数", + "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/slug/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "为分类、标签、题材、作者、视频中空的 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "同步历史空 slug", + "parameters": [ + { + "description": "同步参数", + "name": "param", + "in": "body", + "schema": { + "$ref": "#/definitions/proto.VideoSlugSyncReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoSlugSyncResp" + } + }, + "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/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.VideoTagInfoUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotopic/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个视频专题", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频专题" + ], + "summary": "添加一个视频专题", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videotopicmod.VideoTopicModel" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotopic/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_videotopic.VideoTopicDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotopic/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_videotopic.VideoTopicReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videotopic.VideoTopicResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotopic/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_videotopic.VideoTopicUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/wbapi/cgdj/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/cgdj/ipstat": { + "get": { + "description": "返回 app 端同步到 MongoDB 的每日访问 IP 去重数", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "查询今日和昨日 IP 去重统计", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videotag.IpStatResp" + } + } + } + } + }, + "/wbapi/hgdj/recharge/summary": { + "get": { + "description": "按北京时间的开始/结束时间汇总支付成功订单金额与笔数", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "wbapi统计" + ], + "summary": "统计充值汇总", + "parameters": [ + { + "type": "string", + "description": "开始时间,北京时间,支持 2006-01-02、2006-01-02 15:04:05、RFC3339", + "name": "startTime", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "结束时间,北京时间,支持 2006-01-02、2006-01-02 15:04:05、RFC3339", + "name": "endTime", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_viporder.RechargeSummaryResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "announcementmod.Announcement": { + "type": "object", + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "type": "integer" + }, + "sort": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "cointransmod.CoinTrans": { + "type": "object", + "properties": { + "amount": { + "description": "本次交易金币数(正整数)", + "type": "integer" + }, + "balanceAfter": { + "description": "交易后余额", + "type": "integer" + }, + "balanceBefore": { + "description": "交易前余额", + "type": "integer" + }, + "createdAt": { + "description": "交易时间", + "type": "string" + }, + "flowDir": { + "description": "收支方向:1收入 -1支出", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.FlowDir" + } + ] + }, + "id": { + "description": "唯一ID", + "type": "integer" + }, + "orderNo": { + "description": "关联订单号(可为空)", + "type": "string" + }, + "relatedId": { + "description": "关联内容ID(视频等,可为空)", + "type": "integer" + }, + "remark": { + "description": "备注描述", + "type": "string" + }, + "tranType": { + "description": "交易类型", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.TranType" + } + ] + }, + "userId": { + "description": "用户ID", + "type": "integer" + }, + "userName": { + "description": "用户昵称(快照)", + "type": "string" + } + } + }, + "cointransmod.FlowDir": { + "type": "integer", + "enum": [ + 1, + -1 + ], + "x-enum-comments": { + "FlowIn": "收入(充值、赠送)", + "FlowOut": "支出(消费、解锁)" + }, + "x-enum-varnames": [ + "FlowIn", + "FlowOut" + ] + }, + "cointransmod.TranType": { + "type": "integer", + "enum": [ + 1, + 2, + 3, + 4, + 5 + ], + "x-enum-comments": { + "TranTypeAdminAdjust": "管理员手动调整", + "TranTypeCoinRecharge": "金币充值(购买金币)", + "TranTypeDarkAlley": "历史遗留交易类型(已下线)", + "TranTypeVideoUnlock": "视频解锁", + "TranTypeVipGiftCoin": "购买会员赠送金币" + }, + "x-enum-varnames": [ + "TranTypeCoinRecharge", + "TranTypeVipGiftCoin", + "TranTypeDarkAlley", + "TranTypeVideoUnlock", + "TranTypeAdminAdjust" + ] + }, + "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" + } + } + }, + "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.OperateType": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "OperateTypeComic": "漫剧", + "OperateTypePlaylet": "短剧" + }, + "x-enum-varnames": [ + "OperateTypePlaylet", + "OperateTypeComic" + ] + }, + "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.ContactUsHandleReq": { + "type": "object", + "required": [ + "ids", + "status" + ], + "properties": { + "ids": { + "description": "联系我们ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "remark": { + "description": "处理备注", + "type": "string" + }, + "status": { + "description": "处理状态,0-待处理、1-已处理", + "type": "integer" + } + } + }, + "proto.ContactUsHandleResp": { + "type": "object", + "properties": { + "matchedCount": { + "description": "命中数量", + "type": "integer" + }, + "modifiedCount": { + "description": "修改数量", + "type": "integer" + } + } + }, + "proto.ContactUsListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "集合列表" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.ContactUsListResp": { + "type": "object", + "properties": { + "attachments": { + "description": "附件地址", + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "description": "内容", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "remark": { + "description": "处理备注", + "type": "string" + }, + "status": { + "description": "处理状态,0-待处理、1-已处理", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + }, + "type": { + "description": "问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "proto.FeedbackHandleReq": { + "type": "object", + "required": [ + "ids", + "status" + ], + "properties": { + "ids": { + "description": "意见反馈ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "remark": { + "description": "处理备注", + "type": "string" + }, + "status": { + "description": "处理状态,0-待处理、1-已处理", + "type": "integer" + } + } + }, + "proto.FeedbackHandleResp": { + "type": "object", + "properties": { + "matchedCount": { + "description": "命中数量", + "type": "integer" + }, + "modifiedCount": { + "description": "修改数量", + "type": "integer" + } + } + }, + "proto.FeedbackListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "集合列表" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.FeedbackListResp": { + "type": "object", + "properties": { + "contact": { + "description": "联系方式", + "type": "string" + }, + "content": { + "description": "描述内容", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "remark": { + "description": "处理备注", + "type": "string" + }, + "screenshots": { + "description": "问题截图", + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "description": "处理状态,0-待处理、1-已处理", + "type": "integer" + }, + "type": { + "description": "问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "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.HomeConfigAddReq": { + "type": "object", + "required": [ + "name", + "sortType", + "status", + "type" + ], + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "sortType": { + "description": "排序字段 1-推荐 2-热播 3-最近更新 4-新片预告", + "type": "integer" + }, + "status": { + "description": "上下架状态 true-上架 false-下架", + "type": "boolean" + }, + "type": { + "description": "类型 1-短剧 2-视频 3-题材", + "type": "integer" + } + } + }, + "proto.HomeConfigAddResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.HomeConfigDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.HomeConfigListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "集合列表" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.HomeConfigUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "sortType": { + "description": "排序字段 1-推荐 2-热播 3-最近更新 4-新片预告", + "type": "integer" + }, + "status": { + "description": "上下架状态 true-上架 false-下架", + "type": "boolean" + }, + "type": { + "description": "类型 1-短剧 2-视频 3-题材", + "type": "integer" + } + } + }, + "proto.PlayletAddReq": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "anchor": { + "type": "string" + }, + "banner": { + "type": "string" + }, + "contentType": { + "$ref": "#/definitions/playletmod.ContentType" + }, + "cover": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "horizontalCover": { + "description": "横版封面", + "type": "string" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "type": "boolean" + }, + "isRecommend": { + "type": "boolean" + }, + "onlineAt": { + "description": "上架时间,上架时不传则按当前时间", + "type": "string" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧,不传默认短剧", + "allOf": [ + { + "$ref": "#/definitions/playletmod.OperateType" + } + ] + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "preview": { + "type": "string" + }, + "recommendRank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "type": "boolean" + }, + "singleCoins": { + "description": "单集金币(支付方式为1-金币时候生效)", + "type": "integer" + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或标题自动生成", + "type": "string" + }, + "status": { + "$ref": "#/definitions/playletmod.Status" + }, + "tagIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "themeIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "type": "string" + }, + "topRank": { + "type": "integer" + }, + "totalEpisode": { + "type": "integer" + } + } + }, + "proto.PlayletAddResp": { + "type": "object", + "properties": { + "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.PlayletChapterAddReq": { + "type": "object", + "required": [ + "currentEpisode", + "playletId" + ], + "properties": { + "currentEpisode": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "md5": { + "type": "string" + }, + "mediaSize": { + "type": "integer" + }, + "mediaSourceId": { + "type": "string" + }, + "mediaTime": { + "type": "integer" + }, + "payType": { + "$ref": "#/definitions/playletchaptermod.PayType" + }, + "playletId": { + "type": "integer" + }, + "ratio": { + "type": "number" + }, + "singleCoins": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "videoUrl": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "proto.PlayletChapterAddResp": { + "type": "object", + "properties": { + "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-金币时必填且需\u003e0)", + "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": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "md5": { + "type": "string" + }, + "mediaSize": { + "type": "integer" + }, + "mediaTime": { + "type": "integer" + }, + "payType": { + "$ref": "#/definitions/playletchaptermod.PayType" + }, + "ratio": { + "type": "number" + }, + "singleCoins": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "videoUrl": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "proto.PlayletDelReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条删除,传多个表示批量删除", + "type": "array", + "items": { + "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.PlayletListSortType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "x-enum-comments": { + "PlayletListSortCountBrowse": "浏览数", + "PlayletListSortCountCollect": "收藏数", + "PlayletListSortCountLike": "点赞数", + "PlayletListSortCreatedAt": "创建时间(默认)", + "PlayletListSortOnlineAt": "上架时间", + "PlayletListSortRecommendRank": "推荐排序", + "PlayletListSortUpdatedAt": "修改时间" + }, + "x-enum-varnames": [ + "PlayletListSortCreatedAt", + "PlayletListSortRecommendRank", + "PlayletListSortUpdatedAt", + "PlayletListSortCountLike", + "PlayletListSortCountBrowse", + "PlayletListSortCountCollect", + "PlayletListSortOnlineAt" + ] + }, + "proto.PlayletOnlineAtFillResp": { + "type": "object", + "properties": { + "updatedCount": { + "description": "本次回填数量", + "type": "integer" + } + } + }, + "proto.PlayletOperateTypeSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "allOf": [ + { + "$ref": "#/definitions/playletmod.OperateType" + } + ] + } + } + }, + "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-金币时必填且需\u003e0)", + "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.PlayletResourceJHListItem": { + "type": "object", + "properties": { + "actors": { + "description": "演员集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletResourceMediaActor" + } + }, + "anchor": { + "description": "作者", + "type": "string" + }, + "create_time": { + "description": "创建时间", + "type": "integer" + }, + "data_version": { + "description": "数据版本", + "type": "integer" + }, + "horizontalCover": { + "description": "兼容驼峰", + "type": "string" + }, + "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": "更新状态", + "type": "integer" + }, + "update_time": { + "description": "更新时间", + "type": "integer" + }, + "verticalCover": { + "description": "兼容驼峰", + "type": "string" + }, + "vertical_cover": { + "description": "竖版封面", + "type": "string" + }, + "vlogger_id": { + "type": "integer" + } + } + }, + "proto.PlayletResourceJHListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletResourceJHListItem" + } + }, + "total": { + "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.PlayletResourceMediaActor": { + "type": "object", + "properties": { + "avatar": { + "description": "头像", + "type": "string" + }, + "gender": { + "description": "性别", + "type": "integer" + }, + "name": { + "description": "名称", + "type": "string" + } + } + }, + "proto.PlayletResourceUseReq": { + "type": "object", + "properties": { + "ids": { + "description": "媒资中心资源ID集合-多个用逗号隔开,请求示例[28830,28829,28828,28827,28826,28825]", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletSalesItem": { + "type": "object", + "properties": { + "collectCount": { + "description": "期间新增且当前仍在的短剧收藏数", + "type": "integer" + }, + "cover": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "likeCount": { + "description": "期间新增且当前仍在的短剧点赞数", + "type": "integer" + }, + "playName": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/playletmod.Status" + }, + "title": { + "type": "string" + }, + "unlockCoins": { + "description": "金币解锁消耗", + "type": "integer" + }, + "unlockCount": { + "description": "金币解锁次数", + "type": "integer" + } + } + }, + "proto.PlayletSalesResp": { + "type": "object", + "properties": { + "endTime": { + "description": "实际统计结束时间(北京时间)", + "type": "string" + }, + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletSalesItem" + } + }, + "startTime": { + "description": "实际统计开始时间(北京时间)", + "type": "string" + } + } + }, + "proto.PlayletSalesSortType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "PlayletSalesSortCollect": "收藏数", + "PlayletSalesSortLike": "点赞数", + "PlayletSalesSortUnlock": "金币解锁次数" + }, + "x-enum-varnames": [ + "PlayletSalesSortUnlock", + "PlayletSalesSortLike", + "PlayletSalesSortCollect" + ] + }, + "proto.PlayletSlugSyncReq": { + "type": "object", + "properties": { + "maxSize": { + "description": "本次最多处理的空 slug 记录数,默认500,最大2000", + "type": "integer" + } + } + }, + "proto.PlayletSlugSyncResp": { + "type": "object", + "properties": { + "hasMore": { + "description": "是否还有未处理的历史数据", + "type": "boolean" + }, + "playletUpdated": { + "description": "本次补齐的短剧数", + "type": "integer" + }, + "tagUpdated": { + "description": "本次补齐的标签数", + "type": "integer" + }, + "themeUpdated": { + "description": "本次补齐的题材数", + "type": "integer" + } + } + }, + "proto.PlayletStatusSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "onlineAt": { + "description": "上架时间;传入则使用该时间,未传且从未设置过则用当前时间", + "type": "string" + }, + "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" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或名称自动生成", + "type": "string" + }, + "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" + } + ] + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "type": "boolean" + } + } + }, + "proto.PlayletThemeAddReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "icon": { + "type": "string" + }, + "isRecommend": { + "description": "是否推荐", + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或名称自动生成", + "type": "string" + }, + "status": { + "type": "boolean" + } + } + }, + "proto.PlayletThemeAddResp": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "proto.PlayletThemeDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "proto.PlayletThemeListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "集合列表" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletThemeRecommendSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "题材ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "isRecommend": { + "description": "是否推荐(true-推荐,false-取消推荐)", + "type": "boolean" + } + } + }, + "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": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "isRecommend": { + "description": "是否推荐", + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "type": "boolean" + } + } + }, + "proto.PlayletUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "anchor": { + "type": "string" + }, + "banner": { + "type": "string" + }, + "contentType": { + "$ref": "#/definitions/playletmod.ContentType" + }, + "cover": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "horizontalCover": { + "description": "横版封面", + "type": "string" + }, + "id": { + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "type": "boolean" + }, + "isRecommend": { + "type": "boolean" + }, + "onlineAt": { + "description": "上架时间,新上架时不传则按当前时间", + "type": "string" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "allOf": [ + { + "$ref": "#/definitions/playletmod.OperateType" + } + ] + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "preview": { + "type": "string" + }, + "recommendRank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "type": "boolean" + }, + "singleCoins": { + "description": "单集金币(支付方式为1-金币时候生效)", + "type": "integer" + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "$ref": "#/definitions/playletmod.Status" + }, + "tagIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "themeIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "type": "string" + }, + "topRank": { + "type": "integer" + }, + "totalEpisode": { + "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.VideoSlugSyncReq": { + "type": "object", + "properties": { + "maxSize": { + "description": "本次最多处理的空 slug 记录数,默认500,最大2000", + "type": "integer" + } + } + }, + "proto.VideoSlugSyncResp": { + "type": "object", + "properties": { + "categoryUpdated": { + "description": "本次补齐的分类数", + "type": "integer" + }, + "createrUpdated": { + "description": "本次补齐的作者数", + "type": "integer" + }, + "hasMore": { + "description": "是否还有未处理的历史数据", + "type": "boolean" + }, + "tagUpdated": { + "description": "本次补齐的标签数", + "type": "integer" + }, + "topicUpdated": { + "description": "本次补齐的题材数", + "type": "integer" + }, + "videoUpdated": { + "description": "本次补齐的视频数", + "type": "integer" + } + } + }, + "pubdef.LoginType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "LoginTypeAccount": "账号登陆", + "LoginTypeDevice": "设备登陆", + "LoginTypeEmail": "邮箱登陆" + }, + "x-enum-varnames": [ + "LoginTypeDevice", + "LoginTypeAccount", + "LoginTypeEmail" + ] + }, + "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" + ] + }, + "pubdef.UserType": { + "type": "integer", + "enum": [ + 0, + 1, + 0 + ], + "x-enum-comments": { + "UserTypeGuest": "游客用户", + "UserTypeNormal": "普通用户(已注册)" + }, + "x-enum-varnames": [ + "UserTypeGuest", + "UserTypeNormal", + "UserTypeNone" + ] + }, + "pubdef.VipOrderStatus": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-comments": { + "VipOrderFailed": "支付失败", + "VipOrderPending": "待支付", + "VipOrderSuccess": "支付成功" + }, + "x-enum-varnames": [ + "VipOrderPending", + "VipOrderSuccess", + "VipOrderFailed" + ] + }, + "pubdef.VipType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "VipTypeMonth": "月卡", + "VipTypeNone": "没卡", + "VipTypeYear": "年卡" + }, + "x-enum-varnames": [ + "VipTypeNone", + "VipTypeMonth", + "VipTypeYear" + ] + }, + "rechargepackagemod.PackageType": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "PackageTypeCoin": "金币充值", + "PackageTypeVip": "会员畅享" + }, + "x-enum-varnames": [ + "PackageTypeVip", + "PackageTypeCoin" + ] + }, + "rechargepackagemod.Status": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "StatusDisabled": "停用", + "StatusEnabled": "启用" + }, + "x-enum-varnames": [ + "StatusEnabled", + "StatusDisabled" + ] + }, + "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_announcement.AddAnnouncementReq": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "sort": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "srv_announcement.DelAnnouncementReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_announcement.GetAnnouncementListReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题,模糊搜索", + "type": "string" + } + } + }, + "srv_announcement.GetAnnouncementListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/announcementmod.Announcement" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_announcement.UpdateAnnouncementParam": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "sort": { + "type": "integer" + }, + "status": { + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, + "srv_announcement.UpdateAnnouncementReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "$ref": "#/definitions/srv_announcement.UpdateAnnouncementParam" + } + } + }, + "srv_cointrans.CoinTransReq": { + "type": "object", + "properties": { + "endTime": { + "description": "结束时间 2006-01-02 15:04:05", + "type": "string" + }, + "flowDir": { + "description": "收支方向:1收入 -1支出", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.FlowDir" + } + ] + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "startTime": { + "description": "开始时间 2006-01-02 15:04:05", + "type": "string" + }, + "tranType": { + "description": "交易类型", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.TranType" + } + ] + }, + "userId": { + "description": "用户ID", + "type": "integer" + } + } + }, + "srv_cointrans.CoinTransResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/cointransmod.CoinTrans" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_commend.CommendReplay": { + "type": "object", + "properties": { + "objectId": { + "description": "帖子ID", + "type": "integer" + }, + "parentsId": { + "description": "一级评论ID", + "type": "integer" + }, + "replyCommentId": { + "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": "对象类型 10-短剧 11-短剧剧集 20-视频", + "type": "integer" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "reviewStatus": { + "type": "integer" + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "userId": { + "description": "评论用户ID", + "type": "integer" + }, + "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": { + "countLike": { + "description": "评论点赞数", + "type": "integer" + }, + "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": "对象类型 10-短剧 11-短剧剧集 20-视频", + "type": "integer" + }, + "parentsId": { + "description": "上级评论ID", + "type": "integer" + }, + "replyCommentId": { + "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" + }, + "userAvatar": { + "description": "评论用户头像", + "type": "string" + }, + "userId": { + "description": "评论用户ID", + "type": "integer" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "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_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" + } + } + }, + "payCenter.RechargeCallbackResp": { + "type": "object", + "required": [ + "code", + "mercId", + "oid", + "payMoney", + "tradeNo", + "sign" + ], + "properties": { + "code": { + "type": "integer" + }, + "mercId": { + "type": "string" + }, + "oid": { + "type": "string" + }, + "payMoney": { + "type": "string" + }, + "tradeNo": { + "type": "string" + }, + "sign": { + "type": "string" + } + } + }, + "srv_rechargepackage.RechargePackageAddReq": { + "type": "object", + "required": [ + "name", + "packageType", + "price" + ], + "properties": { + "baseCoin": { + "description": "基础金币数,会员套餐传0", + "type": "integer" + }, + "durationDays": { + "description": "会员有效天数,金币套餐传0", + "type": "integer" + }, + "giftCoin": { + "description": "赠送金币数", + "type": "integer" + }, + "name": { + "description": "展示标题", + "type": "string" + }, + "originalPrice": { + "description": "原价,单位分", + "type": "integer" + }, + "packageType": { + "description": "套餐类型:1-会员畅享 2-金币充值", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.PackageType" + } + ] + }, + "price": { + "description": "售价,单位分", + "type": "integer" + }, + "remark": { + "description": "后台备注", + "type": "string" + }, + "sort": { + "description": "排序,越小越靠前", + "type": "integer" + }, + "status": { + "description": "状态:1-启用 2-停用,默认1", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.Status" + } + ] + } + } + }, + "srv_rechargepackage.RechargePackageDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "套餐ID", + "type": "integer" + } + } + }, + "srv_rechargepackage.RechargePackageItem": { + "type": "object", + "properties": { + "baseCoin": { + "description": "基础金币数", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "durationDays": { + "description": "会员有效天数", + "type": "integer" + }, + "durationHours": { + "description": "会员有效小时数,由durationDays计算", + "type": "integer" + }, + "giftCoin": { + "description": "赠送金币数", + "type": "integer" + }, + "id": { + "description": "套餐ID", + "type": "integer" + }, + "name": { + "description": "展示标题", + "type": "string" + }, + "originalPrice": { + "description": "原价,单位分", + "type": "integer" + }, + "packageType": { + "description": "套餐类型:1-会员畅享 2-金币充值", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.PackageType" + } + ] + }, + "price": { + "description": "售价,单位分", + "type": "integer" + }, + "remark": { + "description": "后台备注", + "type": "string" + }, + "sort": { + "description": "排序,越小越靠前", + "type": "integer" + }, + "status": { + "description": "状态:1-启用 2-停用", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.Status" + } + ] + }, + "subTitle": { + "description": "展示副标题", + "type": "string" + }, + "totalCoin": { + "description": "到账总金币数,由baseCoin+giftCoin计算", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "srv_rechargepackage.RechargePackageListReq": { + "type": "object", + "properties": { + "id": { + "description": "套餐ID", + "type": "integer" + }, + "name": { + "description": "展示标题,模糊搜索", + "type": "string" + }, + "packageType": { + "description": "套餐类型:1-会员畅享 2-金币充值", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.PackageType" + } + ] + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "状态:1-启用 2-停用", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.Status" + } + ] + } + } + }, + "srv_rechargepackage.RechargePackageListResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页列表", + "type": "array", + "items": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageItem" + } + }, + "total": { + "description": "总数量", + "type": "integer" + } + } + }, + "srv_rechargepackage.RechargePackageStatusSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "套餐ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "status": { + "description": "状态:1-启用 2-停用", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.Status" + } + ] + } + } + }, + "srv_rechargepackage.RechargePackageUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "baseCoin": { + "description": "基础金币数", + "type": "integer" + }, + "durationDays": { + "description": "会员有效天数", + "type": "integer" + }, + "giftCoin": { + "description": "赠送金币数", + "type": "integer" + }, + "id": { + "description": "套餐ID", + "type": "integer" + }, + "name": { + "description": "展示标题", + "type": "string" + }, + "originalPrice": { + "description": "原价,单位分", + "type": "integer" + }, + "packageType": { + "description": "套餐类型:1-会员畅享 2-金币充值", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.PackageType" + } + ] + }, + "price": { + "description": "售价,单位分", + "type": "integer" + }, + "remark": { + "description": "后台备注", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "状态:1-启用 2-停用", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.Status" + } + ] + } + } + }, + "srv_rechargepackage.VipPackageOption": { + "type": "object", + "properties": { + "id": { + "description": "套餐ID", + "type": "integer" + }, + "name": { + "description": "套餐名称", + "type": "string" + } + } + }, + "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" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "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.GiftCoinReq": { + "type": "object", + "required": [ + "coin", + "userId" + ], + "properties": { + "coin": { + "description": "赠送金币数(正数=增加,负数=扣除)", + "type": "integer" + }, + "remark": { + "description": "备注(记录在流水表)", + "type": "string" + }, + "userId": { + "description": "目标用户ID", + "type": "integer" + } + } + }, + "srv_user.UpdateVipReq": { + "type": "object", + "required": [ + "packageId", + "userId" + ], + "properties": { + "packageId": { + "description": "会员畅享套餐ID", + "type": "integer" + }, + "userId": { + "description": "用户ID", + "type": "integer" + } + } + }, + "srv_user.UserListReq": { + "type": "object", + "properties": { + "accountNo": { + "description": "账号(精确)", + "type": "string" + }, + "districtCode": { + "description": "渠道码", + "type": "string" + }, + "email": { + "description": "邮箱(精确)", + "type": "string" + }, + "id": { + "description": "用户ID", + "type": "integer" + }, + "nickName": { + "description": "昵称(模糊)", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserStatus" + } + ] + }, + "type": { + "description": "用户类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserType" + } + ] + } + } + }, + "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": { + "id": { + "description": "分类ID", + "type": "integer" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_videocategory.VideoCategoryResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videocategorymod.VideoCategory" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videocategory.VideoCategoryUpdate": { + "type": "object", + "properties": { + "appId": { + "description": "appId,唯一id", + "type": "integer" + }, + "contentType": { + "description": "内容:0: 默认 1:首页 2:今日", + "type": "integer" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "indexNow": { + "type": "integer" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sortType": { + "description": "排序方式 0:上线时间 1:观看次数 2排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "srv_videocategory.VideoCategoryUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_videocategory.VideoCategoryUpdate" + } + ] + } + } + }, + "srv_videocreater.VideoCreaterDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videocreater.VideoCreaterReq": { + "type": "object", + "properties": { + "id": { + "description": "分类ID", + "type": "integer" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "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" + }, + "indexNow": { + "type": "integer" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "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" + } + } + }, + "srv_videocreater.VideoCreaterUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_videocreater.VideoCreaterUpdate" + } + ] + } + } + }, + "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" + } + }, + "slug": { + "description": "slug", + "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.SetFakeWatchTimesReq": { + "type": "object", + "properties": { + "ids": { + "description": "视频ID集合,支持单个或批量", + "type": "array", + "items": { + "type": "integer" + } + }, + "watchTimesMax": { + "description": "假观看次数随机最大值", + "type": "integer" + }, + "watchTimesMin": { + "description": "假观看次数随机最小值", + "type": "integer" + } + } + }, + "srv_videoinfo.VideoInfoDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videoinfo.VideoInfoReq": { + "type": "object", + "properties": { + "categoryId": { + "description": "分类Id", + "type": "integer" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名称", + "type": "string" + }, + "endTime": { + "description": "创建时间-截止", + "type": "string" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "isH265": { + "description": "是否支持 H265", + "type": "boolean" + }, + "isHot": { + "description": "是否热门", + "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=播放次数", + "type": "integer" + }, + "startTime": { + "description": "创建时间-开始", + "type": "string" + }, + "status": { + "description": "帖子上架状态", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.InformationSatus" + } + ] + }, + "tag": { + "description": "标签", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + }, + "topicId": { + "description": "专题Id", + "type": "integer" + }, + "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": { + "addedTime": { + "type": "string" + }, + "categoryIds": { + "description": "分类Id", + "type": "array", + "items": { + "type": "integer" + } + }, + "content": { + "type": "string" + }, + "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" + }, + "isTop": { + "description": "是否置顶", + "type": "boolean" + }, + "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/videoinformationmod.InformationSatus" + } + ] + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "topicIds": { + "description": "专题ID", + "type": "array", + "items": { + "type": "integer" + } + }, + "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_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.IpStatResp": { + "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" + }, + "indexNow": { + "type": "integer" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "srv_videotag.VideoTagInfoUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_videotag.VideoTagInfoUpdate" + } + ] + } + } + }, + "srv_videotopic.VideoTopicDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videotopic.VideoTopicReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "videoCategoryId": { + "type": "integer" + } + } + }, + "srv_videotopic.VideoTopicResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videotopicmod.VideoTopicModel" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videotopic.VideoTopicUpdate": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "image": { + "description": "背景", + "type": "string" + }, + "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" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "videoCategoryId": { + "description": "视频分类id", + "type": "integer" + }, + "videoTopicType": { + "description": "视频类型", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.MediaType" + } + ] + } + } + }, + "srv_videotopic.VideoTopicUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_videotopic.VideoTopicUpdate" + } + ] + } + } + }, + "srv_viporder.RechargeSummaryResp": { + "type": "object", + "properties": { + "coinAmount": { + "description": "金币成功订单金额(分)", + "type": "integer" + }, + "coinOrderCount": { + "description": "金币成功订单数", + "type": "integer" + }, + "endTime": { + "description": "实际统计结束时间(北京时间)", + "type": "string" + }, + "orderCount": { + "description": "成功订单数", + "type": "integer" + }, + "startTime": { + "description": "实际统计开始时间(北京时间)", + "type": "string" + }, + "totalAmount": { + "description": "成功订单金额合计(分)", + "type": "integer" + }, + "userCount": { + "description": "成功充值用户数(去重)", + "type": "integer" + }, + "vipAmount": { + "description": "会员成功订单金额(分)", + "type": "integer" + }, + "vipOrderCount": { + "description": "会员成功订单数", + "type": "integer" + } + } + }, + "srv_viporder.SyncDistrictCodeReq": { + "type": "object", + "properties": { + "maxSize": { + "description": "本次最多扫描的空渠道码订单数,默认500,最大2000", + "type": "integer" + } + } + }, + "srv_viporder.SyncDistrictCodeResp": { + "type": "object", + "properties": { + "hasMore": { + "description": "是否还有未处理的历史订单", + "type": "boolean" + }, + "orderUpdated": { + "description": "成功回填的订单数", + "type": "integer" + }, + "scanned": { + "description": "本次扫描的空渠道码订单数", + "type": "integer" + }, + "userSkipped": { + "description": "用户本身没有渠道码", + "type": "integer" + }, + "userUpdated": { + "description": "成功回填的用户数", + "type": "integer" + } + } + }, + "srv_viporder.VipOrderReq": { + "type": "object", + "properties": { + "districtCode": { + "description": "渠道码", + "type": "string" + }, + "endTime": { + "description": "创建时间-结束", + "type": "string" + }, + "orderType": { + "description": "订单类型:1会员 2金币", + "allOf": [ + { + "$ref": "#/definitions/vipordermod.OrderType" + } + ] + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "startTime": { + "description": "创建时间-开始", + "type": "string" + }, + "status": { + "description": "订单状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.VipOrderStatus" + } + ] + }, + "tradeNo": { + "description": "订单号", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + } + } + }, + "srv_viporder.VipOrderResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/vipordermod.VipOrder" + } + }, + "total": { + "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" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "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" + }, + "deviceBrand": { + "description": "设备品牌", + "type": "string" + }, + "deviceModel": { + "description": "设备型号", + "type": "string" + }, + "districtCode": { + "description": "商区码", + "type": "string" + }, + "email": { + "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" + }, + "isDirect": { + "description": "是否是直推 true:是直推用户", + "type": "boolean" + }, + "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" + }, + "privacyMode": { + "description": "隐私模式:true-开启,false-关闭(默认)", + "type": "boolean" + }, + "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" + }, + "sessionId": { + "description": "会话ID", + "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" + }, + "systemName": { + "description": "系统名称", + "type": "string" + }, + "systemVersion": { + "description": "系统版本", + "type": "string" + }, + "type": { + "description": "类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserType" + } + ] + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "vipType": { + "description": "Vip类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.VipType" + } + ] + } + } + }, + "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" + }, + "deviceBrand": { + "description": "设备品牌", + "type": "string" + }, + "deviceModel": { + "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" + }, + "isDirect": { + "description": "是否是直推 true:是直推用户", + "type": "boolean" + }, + "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" + }, + "sessionId": { + "description": "会话ID", + "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" + }, + "systemName": { + "description": "系统名称", + "type": "string" + }, + "systemVersion": { + "description": "系统版本", + "type": "string" + }, + "type": { + "description": "类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserType" + } + ] + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "vipType": { + "description": "Vip类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.VipType" + } + ] + } + } + }, + "videocategorymod.VideoCategory": { + "type": "object", + "properties": { + "contentType": { + "description": "内容:0: 默认 1:首页 2:今日 3:合集", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "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": "排序方式 0:上线时间 1:观看次数 2排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "videocreatermod.VideoCreaterInfo": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "type": "integer" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "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" + } + } + }, + "videoinformationmod.FakeFlow": { + "type": "object", + "properties": { + "watchTimes": { + "description": "假观看次数", + "type": "integer" + } + } + }, + "videoinformationmod.Flow": { + "type": "object", + "properties": { + "collects": { + "description": "收藏数", + "type": "integer" + }, + "comments": { + "description": "评论条数", + "type": "integer" + }, + "likes": { + "description": "点赞数", + "type": "integer" + }, + "monthWatchs": { + "description": "本月观看次数", + "type": "integer" + }, + "showTimes": { + "description": "展示次数", + "type": "integer" + }, + "todayWatchs": { + "description": "当日观看次数", + "type": "integer" + }, + "watchTimes": { + "description": "观看次数", + "type": "integer" + }, + "weekWatchs": { + "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.InformationSatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "InformationSatusVerifyCancel": "下架", + "InformationSatusVerifyOK": "上架", + "InformationSatusWaiting": "初始化" + }, + "x-enum-varnames": [ + "InformationSatusWaiting", + "InformationSatusVerifyOK", + "InformationSatusVerifyCancel" + ] + }, + "videoinformationmod.MediaType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-comments": { + "MediaType_Advitise": "广告", + "MediaType_Collection": "合集", + "MediaType_Video": "视频(帖子)", + "MediaType_VideoV2": "视频" + }, + "x-enum-varnames": [ + "MediaType_Video", + "MediaType_Collection", + "MediaType_Advitise", + "MediaType_VideoV2" + ] + }, + "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": "H265 视频地址", + "type": "string" + }, + "height": { + "description": "分辨率高(视频才会存在)", + "type": "integer" + }, + "preMp4FileNameFs": { + "description": "预览MP4文件", + "type": "string" + }, + "taskId": { + "description": "视频ID(视频才会存在)", + "type": "string" + }, + "unencryptedPreMp4": { + "description": "不加密的预览MP4", + "type": "string" + }, + "url": { + "description": "视频URl", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "width": { + "description": "分辨率宽(视频才会存在)", + "type": "integer" + } + } + }, + "videoinformationmod.VideoInformation": { + "type": "object", + "properties": { + "addedTime": { + "description": "帖子上架时间", + "type": "string" + }, + "audioFile": { + "description": "音频文件路径", + "type": "string" + }, + "categoryIds": { + "description": "分类Id (帖子用)", + "type": "array", + "items": { + "type": "integer" + } + }, + "content": { + "description": "富文本", + "type": "string" + }, + "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/videoinformationmod.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" + }, + "isTop": { + "description": "是否置顶", + "type": "boolean" + }, + "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/videoinformationmod.Flow" + } + ] + }, + "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/videoinformationmod.InformationSatus" + } + ] + }, + "syncId": { + "description": "同步ID", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "topicIds": { + "description": "专题ID(视频用)", + "type": "array", + "items": { + "type": "integer" + } + }, + "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" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "watchTimes": { + "description": "预计算:该标签下所有视频的累计播放量", + "type": "integer" + } + } + }, + "videotopicmod.VideoTopicModel": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "image": { + "description": "背景", + "type": "string" + }, + "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" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "videoCategoryId": { + "description": "视频分类id", + "type": "integer" + }, + "videoTopicType": { + "description": "视频类型", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.MediaType" + } + ] + }, + "works": { + "description": "作品数", + "type": "integer" + } + } + }, + "vipordermod.OrderType": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "OrderTypeCoin": "金币购买", + "OrderTypeVip": "会员购买" + }, + "x-enum-varnames": [ + "OrderTypeVip", + "OrderTypeCoin" + ] + }, + "vipordermod.VipOrder": { + "type": "object", + "properties": { + "amount": { + "description": "支付金额(分)", + "type": "integer" + }, + "cardId": { + "description": "── VIP 专用 ──", + "type": "integer" + }, + "cardName": { + "description": "套餐名快照", + "type": "string" + }, + "coin": { + "description": "到账金币数(快照)", + "type": "integer" + }, + "coinCfgId": { + "description": "── 金币专用 ──", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "districtCode": { + "description": "下单时用户渠道码", + "type": "string" + }, + "finishedAt": { + "description": "完成时间", + "type": "string" + }, + "id": { + "description": "唯一ID", + "type": "integer" + }, + "ipAddr": { + "description": "客户端 IP", + "type": "string" + }, + "oid": { + "description": "第三方订单号", + "type": "string" + }, + "orderType": { + "description": "订单类型:1会员 2金币", + "allOf": [ + { + "$ref": "#/definitions/vipordermod.OrderType" + } + ] + }, + "payChannel": { + "description": "支付渠道", + "type": "string" + }, + "payMode": { + "description": "支付方式:overage/alipay/wechat", + "type": "string" + }, + "payRate": { + "description": "费率", + "type": "string" + }, + "payUrl": { + "description": "支付链接", + "type": "string" + }, + "payUrlMode": { + "description": "链接打开方式", + "type": "string" + }, + "status": { + "description": "订单状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.VipOrderStatus" + } + ] + }, + "sysType": { + "description": "ios/android", + "type": "string" + }, + "tradeNo": { + "description": "本地订单号", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "userId": { + "description": "用户ID", + "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" + } + }, + "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": { + "actressBackGround": { + "description": "女优通用背景图", + "type": "string" + }, + "advitiesTopicNum": { + "description": "广告间隔", + "type": "integer" + }, + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "customerServiceHost": { + "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" + }, + "id": { + "type": "integer" + }, + "imgAppCdn": { + "description": "图片CDN(app使用)", + "type": "string" + }, + "imgAppUsCdn": { + "description": "图片CDN(海外app使用)", + "type": "string" + }, + "imgWebCdn": { + "description": "图片CDN(web使用)", + "type": "string" + }, + "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" + }, + "officialAppAddr": { + "description": "官网app地址", + "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" + } + }, + "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": { + "actressBackGround": { + "description": "女优通用背景图", + "type": "string" + }, + "advitiesTopicNum": { + "description": "广告间隔", + "type": "integer" + }, + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "customerServiceHost": { + "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" + }, + "enableH265": { + "description": "是否允许客户端使用 H265 地址播放", + "type": "boolean" + }, + "foreverUrlAddr": { + "description": "永久地址", + "type": "string" + }, + "freeVPN": { + "description": "免费vpn链接", + "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" + }, + "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" + }, + "officialAppAddr": { + "description": "官网app地址", + "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" + } + }, + "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" + } + } + } + }, + "webanalytics.AnalyticsDailyStat": { + "type": "object", + "properties": { + "list": { + "description": "当月数据", + "type": "array", + "items": { + "$ref": "#/definitions/webanalytics.LastWebStatInfo" + } + } + } + }, + "webanalytics.AnalyticsStat": { + "type": "object", + "properties": { + "todayList": { + "description": "当日数据", + "type": "array", + "items": { + "$ref": "#/definitions/webanalytics.LastWebStatInfo" + } + }, + "weekList": { + "description": "环比数据", + "type": "array", + "items": { + "$ref": "#/definitions/webanalytics.LastWebStatInfo" + } + }, + "yestodayList": { + "description": "同比数据", + "type": "array", + "items": { + "$ref": "#/definitions/webanalytics.LastWebStatInfo" + } + } + } + }, + "webanalytics.LastWebStatInfo": { + "type": "object", + "properties": { + "activeUsers": { + "description": "最近半小时活跃数", + "type": "integer" + }, + "appId": { + "description": "appId", + "type": "integer" + }, + "appName": { + "description": "APP名称", + "type": "string" + }, + "leadAmount": { + "description": "日导量充值", + "type": "number" + }, + "leadNewCount": { + "description": "日导量新增", + "type": "number" + }, + "sumDate": { + "description": "时间半小时", + "type": "string" + }, + "yestodayActiveUsers": { + "description": "活跃用户数", + "type": "integer" + }, + "yestodayNewUsers": { + "description": "新增数量", + "type": "integer" + }, + "yestodayPV": { + "description": "页面浏览量 (PV)", + "type": "integer" + }, + "yestodaySessions": { + "description": "会话数", + "type": "integer" + }, + "yestodayUV": { + "description": "独立用户数 (UV)", + "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: "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..0f978f2 --- /dev/null +++ b/web/docs/swagger.json @@ -0,0 +1,14221 @@ +{ + "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/announcement/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_announcement.AddAnnouncementReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/announcement/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_announcement.DelAnnouncementReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/announcement/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_announcement.GetAnnouncementListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_announcement.GetAnnouncementListResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/announcement/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_announcement.UpdateAnnouncementReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/card/order/district/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "将历史订单中空的渠道码,按用户当前 districtCode 回填。可重复调用,hasMore 为 true 时继续请求。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "会员卡管理" + ], + "summary": "同步历史订单渠道码", + "parameters": [ + { + "description": "同步参数", + "name": "param", + "in": "body", + "schema": { + "$ref": "#/definitions/srv_viporder.SyncDistrictCodeReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_viporder.SyncDistrictCodeResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/card/order/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询充值订单,支持按 OrderType 筛选:1会员 2金币;不传 orderType 则返回全部类型", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "会员卡管理" + ], + "summary": "查询充值订单列表", + "parameters": [ + { + "description": "查询参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_viporder.VipOrderReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_viporder.VipOrderResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/coin/trans": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询金币收支流水,支持按用户、收支方向、交易类型、时间范围筛选", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "金币管理" + ], + "summary": "查询金币交易记录", + "parameters": [ + { + "description": "查询参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_cointrans.CoinTransReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_cointrans.CoinTransResp" + } + }, + "400": { + "description": "参数错误", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/contactus/handle": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "批量更新联系我们处理状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web联系我们相关接口" + ], + "summary": "web端处理联系我们 -- by 旺鸣", + "parameters": [ + { + "description": "web端联系我们处理请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.ContactUsHandleReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.ContactUsHandleResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/contactus/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询联系我们列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web联系我们相关接口" + ], + "summary": "web端联系我们列表 -- by 旺鸣", + "parameters": [ + { + "type": "string", + "description": "创建结束时间,格式:2026-06-09 00:00:00", + "name": "createEndTime", + "in": "query" + }, + { + "type": "string", + "description": "创建开始时间,格式:2026-06-08 00:00:00", + "name": "createStartTime", + "in": "query" + }, + { + "type": "string", + "description": "邮箱", + "name": "email", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "处理状态,0-待处理、1-已处理", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他", + "name": "type", + "in": "query" + }, + { + "type": "integer", + "description": "用户ID", + "name": "userId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.ContactUsListPageResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.ContactUsListResp" + } + } + } + } + ] + } + }, + "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/feedback/handle": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "批量更新意见反馈处理状态", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web意见反馈相关接口" + ], + "summary": "web端处理意见反馈 -- by 旺鸣", + "parameters": [ + { + "description": "web端意见反馈处理请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.FeedbackHandleReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.FeedbackHandleResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/feedback/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "分页查询用户意见反馈列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web意见反馈相关接口" + ], + "summary": "web端意见反馈列表 -- by 旺鸣", + "parameters": [ + { + "type": "string", + "description": "创建结束时间,格式:2026-06-09 00:00:00", + "name": "createEndTime", + "in": "query" + }, + { + "type": "string", + "description": "创建开始时间,格式:2026-06-08 00:00:00", + "name": "createStartTime", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "处理状态,0-待处理、1-已处理", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他", + "name": "type", + "in": "query" + }, + { + "type": "integer", + "description": "用户ID", + "name": "userId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/proto.FeedbackListPageResp" + }, + { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.FeedbackListResp" + } + } + } + } + ] + } + }, + "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/home/config/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.HomeConfigAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeConfigAddResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/config/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.HomeConfigDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/config/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", + "description": "页码,默认1", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量,默认10,最大100", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "排序字段 1-推荐 2-热播 3-最近更新 4-新片预告", + "name": "sortType", + "in": "query" + }, + { + "type": "boolean", + "description": "上下架状态 true-上架 false-下架", + "name": "status", + "in": "query" + }, + { + "type": "integer", + "description": "类型 1-短剧 2-视频 3-题材", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.HomeConfigListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/home/config/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.HomeConfigUpdateReq" + } + } + ], + "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": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增短剧", + "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/chapter/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端新增章节", + "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 记录,传1个ID表示单条删除,传多个ID表示批量删除;同时清理该剧集的点赞、收藏、解锁记录", + "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": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端章节列表", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "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" + ], + "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 字段,传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端手动单/批量同步短剧剧集 --by 王鸣", + "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": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端更新章节", + "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/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合物理删除 playlet 与 playlet_chapter 记录;不删除点赞、收藏、评论、观看记录、解锁记录和支付流水", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量物理删除短剧", + "parameters": [ + { + "description": "单/批量删除短剧请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletDelReq" + } + } + ], + "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端短剧列表", + "parameters": [ + { + "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", + "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": "是否原创(推荐)", + "name": "isRecommend", + "in": "query" + }, + { + "enum": [ + 1, + 2 + ], + "type": "integer", + "x-enum-comments": { + "OperateTypeComic": "漫剧", + "OperateTypePlaylet": "短剧" + }, + "x-enum-varnames": [ + "OperateTypePlaylet", + "OperateTypeComic" + ], + "description": "运营类型 1-短剧 2-漫剧", + "name": "operateType", + "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", + "name": "showOnBanner", + "in": "query" + }, + { + "type": "string", + "description": "slug,精确匹配", + "name": "slug", + "in": "query" + }, + { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "type": "integer", + "x-enum-comments": { + "PlayletListSortCountBrowse": "浏览数", + "PlayletListSortCountCollect": "收藏数", + "PlayletListSortCountLike": "点赞数", + "PlayletListSortCreatedAt": "创建时间(默认)", + "PlayletListSortOnlineAt": "上架时间", + "PlayletListSortRecommendRank": "推荐排序", + "PlayletListSortUpdatedAt": "修改时间" + }, + "x-enum-varnames": [ + "PlayletListSortCreatedAt", + "PlayletListSortRecommendRank", + "PlayletListSortUpdatedAt", + "PlayletListSortCountLike", + "PlayletListSortCountBrowse", + "PlayletListSortCountCollect", + "PlayletListSortOnlineAt" + ], + "description": "排序:0创建时间(默认) 1推荐排序 2修改时间 3点赞数 4浏览数 5收藏数 6上架时间,均为降序", + "name": "sortType", + "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", + "name": "title", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/online-at/fill": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "将已上架且上架时间为空的短剧,上架时间设置为该条修改时间", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "临时回填空上架时间", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletOnlineAtFillResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/operate-type/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 operateType 字段,1-短剧 2-漫剧", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置短剧运营类型", + "parameters": [ + { + "description": "单/批量设置短剧运营类型请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletOperateTypeSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "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端单/批量设置短剧默认支付方式", + "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/sales": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按北京时间区间汇总金币解锁、短剧点赞、短剧收藏,最多返回前50条", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端短剧销售数据", + "parameters": [ + { + "type": "string", + "description": "结束时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339", + "name": "endTime", + "in": "query" + }, + { + "type": "integer", + "description": "短剧ID", + "name": "id", + "in": "query" + }, + { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "x-enum-comments": { + "PlayletSalesSortCollect": "收藏数", + "PlayletSalesSortLike": "点赞数", + "PlayletSalesSortUnlock": "金币解锁次数" + }, + "x-enum-varnames": [ + "PlayletSalesSortUnlock", + "PlayletSalesSortLike", + "PlayletSalesSortCollect" + ], + "description": "排序:0金币解锁次数 1点赞数 2收藏数", + "name": "sortType", + "in": "query" + }, + { + "type": "string", + "description": "开始时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339", + "name": "startTime", + "in": "query" + }, + { + "type": "string", + "description": "标题模糊匹配", + "name": "title", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletSalesResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/slug/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "为题材、标签、分类、短剧中空的 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "同步历史空 slug", + "parameters": [ + { + "description": "同步参数", + "name": "param", + "in": "body", + "schema": { + "$ref": "#/definitions/proto.PlayletSlugSyncReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletSlugSyncResp" + } + }, + "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端新增标签", + "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端删除标签", + "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端标签列表", + "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": "string", + "description": "slug,精确匹配", + "name": "slug", + "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表示批量设置;tagIds 可设置多个标签,传空数组表示清空标签", + "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端更新标签", + "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端新增题材", + "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端删除题材", + "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端题材列表", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "query" + }, + { + "type": "boolean", + "description": "是否推荐", + "name": "isRecommend", + "in": "query" + }, + { + "type": "string", + "name": "name", + "in": "query" + }, + { + "type": "integer", + "name": "pageNum", + "in": "query" + }, + { + "type": "integer", + "name": "pageSize", + "in": "query" + }, + { + "type": "string", + "description": "slug,精确匹配", + "name": "slug", + "in": "query" + }, + { + "type": "boolean", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.PlayletThemeListPageResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/recommend/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据题材ID集合批量更新 playlet_theme 集合 isRecommend 字段", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端单/批量设置题材是否推荐", + "parameters": [ + { + "description": "单/批量设置题材是否推荐请求", + "name": "params", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proto.PlayletThemeRecommendSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/playlet/theme/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据短剧ID集合批量更新 playlet 集合 themeIds 字段,传1个ID表示单条设置,传多个ID表示批量设置;themeIds 可设置多个题材", + "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端更新题材", + "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": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端更新短剧", + "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集合,将其下全部剧集水印状态重置为未处理,并清空水印地址与 media 任务关联;原片地址不变,等待定时任务重新加水印", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web短剧相关接口" + ], + "summary": "web端按短剧ID批量重置剧集水印", + "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/recharge/package/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "新增购买套餐 -- by 王鸣", + "parameters": [ + { + "description": "新增参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageAddReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/recharge/package/del": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "删除购买套餐 -- by 王鸣", + "parameters": [ + { + "description": "删除参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/recharge/package/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "查询购买套餐列表 -- by 王鸣", + "parameters": [ + { + "description": "查询参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageListReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageListResp" + } + } + } + } + }, + "/api/web/recharge/package/status/set": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "单/批量设置购买套餐状态 -- by 王鸣", + "parameters": [ + { + "description": "设置状态参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageStatusSetReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/recharge/package/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "更新购买套餐 -- by 王鸣", + "parameters": [ + { + "description": "更新参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/recharge/package/vip/list": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "只返回 PackageType=1 的启用套餐,字段仅包含 id 和 name,不分页", + "produces": [ + "application/json" + ], + "tags": [ + "套餐管理" + ], + "summary": "查询会员畅享套餐选项 -- by 王鸣", + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/srv_rechargepackage.VipPackageOption" + } + } + } + } + } + }, + "/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/jh/chapters": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "按媒资ID查询嘉华短剧剧集信息", + "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/jh/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/jh/list": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "嘉华短剧媒资列表,固定查询 media_type=5,回填是否已入库,支持按 isAdd 筛选", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "web媒资库相关接口" + ], + "summary": "web端嘉华短剧媒资列表 -- by 王鸣", + "parameters": [ + { + "type": "boolean", + "description": "是否已入库:true只查已入库,false只查未入库,不传查全部", + "name": "isAdd", + "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.PlayletResourceJHListResp" + } + }, + "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": "boolean", + "description": "是否已加入后台数据:true只查已加入,false只查未加入,不传查全部", + "name": "isAdd", + "in": "query" + }, + { + "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/stat/analytics": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "统计数据", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "获取统计数据" + ], + "summary": "统计数据", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/webanalytics.AnalyticsStat" + } + }, + "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/webanalytics.AnalyticsDailyStat" + } + }, + "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/third/newpaycenter/recharge/callback": { + "post": { + "description": "校验商户号、MD5签名、金额及平台订单号,查单取得支付时间后入账;重复成功通知直接应答成功。", + "consumes": [ + "application/json" + ], + "produces": [ + "text/plain" + ], + "tags": [ + "支付回调" + ], + "summary": "新支付中心充值回调", + "parameters": [ + { + "description": "支付中心签名回调", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/payCenter.RechargeCallbackResp" + } + } + ], + "responses": { + "200": { + "description": "success", + "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" + } + }, + "400": { + "description": "fail", + "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/giftcoin": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "coin 为正数表示赠送,为负数表示扣除;操作会自动记录金币流水", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户管理" + ], + "summary": "赠送/扣除用户金币", + "parameters": [ + { + "description": "赠送参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_user.GiftCoinReq" + } + } + ], + "responses": { + "200": { + "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/user/vip/update": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据会员畅享套餐ID修改用户会员信息,不处理赠送金币", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "用户管理" + ], + "summary": "修改用户会员", + "parameters": [ + { + "description": "修改会员参数", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/srv_user.UpdateVipReq" + } + } + ], + "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.VideoCategoryUpdateReq" + } + } + ], + "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.VideoCreaterUpdateReq" + } + } + ], + "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/setFakeWatchTimes": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "根据ids批量随机设置视频假观看次数", + "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/slug/sync": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "为分类、标签、题材、作者、视频中空的 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频管理" + ], + "summary": "同步历史空 slug", + "parameters": [ + { + "description": "同步参数", + "name": "param", + "in": "body", + "schema": { + "$ref": "#/definitions/proto.VideoSlugSyncReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/proto.VideoSlugSyncResp" + } + }, + "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/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.VideoTagInfoUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotopic/add": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "添加一个视频专题", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "视频专题" + ], + "summary": "添加一个视频专题", + "parameters": [ + { + "description": "参数列表", + "name": "param", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videotopicmod.VideoTopicModel" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotopic/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_videotopic.VideoTopicDelReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotopic/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_videotopic.VideoTopicReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videotopic.VideoTopicResp" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/web/videotopic/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_videotopic.VideoTopicUpdateReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "type": "string" + } + }, + "400": { + "description": "获取失败的返回结果", + "schema": { + "type": "string" + } + } + } + } + }, + "/wbapi/cgdj/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/cgdj/ipstat": { + "get": { + "description": "返回 app 端同步到 MongoDB 的每日访问 IP 去重数", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "标签管理" + ], + "summary": "查询今日和昨日 IP 去重统计", + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_videotag.IpStatResp" + } + } + } + } + }, + "/wbapi/hgdj/recharge/summary": { + "get": { + "description": "按北京时间的开始/结束时间汇总支付成功订单金额与笔数", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "wbapi统计" + ], + "summary": "统计充值汇总", + "parameters": [ + { + "type": "string", + "description": "开始时间,北京时间,支持 2006-01-02、2006-01-02 15:04:05、RFC3339", + "name": "startTime", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "结束时间,北京时间,支持 2006-01-02、2006-01-02 15:04:05、RFC3339", + "name": "endTime", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "$ref": "#/definitions/srv_viporder.RechargeSummaryResp" + } + }, + "400": { + "description": "失败", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "announcementmod.Announcement": { + "type": "object", + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "type": "integer" + }, + "sort": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "cointransmod.CoinTrans": { + "type": "object", + "properties": { + "amount": { + "description": "本次交易金币数(正整数)", + "type": "integer" + }, + "balanceAfter": { + "description": "交易后余额", + "type": "integer" + }, + "balanceBefore": { + "description": "交易前余额", + "type": "integer" + }, + "createdAt": { + "description": "交易时间", + "type": "string" + }, + "flowDir": { + "description": "收支方向:1收入 -1支出", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.FlowDir" + } + ] + }, + "id": { + "description": "唯一ID", + "type": "integer" + }, + "orderNo": { + "description": "关联订单号(可为空)", + "type": "string" + }, + "relatedId": { + "description": "关联内容ID(视频等,可为空)", + "type": "integer" + }, + "remark": { + "description": "备注描述", + "type": "string" + }, + "tranType": { + "description": "交易类型", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.TranType" + } + ] + }, + "userId": { + "description": "用户ID", + "type": "integer" + }, + "userName": { + "description": "用户昵称(快照)", + "type": "string" + } + } + }, + "cointransmod.FlowDir": { + "type": "integer", + "enum": [ + 1, + -1 + ], + "x-enum-comments": { + "FlowIn": "收入(充值、赠送)", + "FlowOut": "支出(消费、解锁)" + }, + "x-enum-varnames": [ + "FlowIn", + "FlowOut" + ] + }, + "cointransmod.TranType": { + "type": "integer", + "enum": [ + 1, + 2, + 3, + 4, + 5 + ], + "x-enum-comments": { + "TranTypeAdminAdjust": "管理员手动调整", + "TranTypeCoinRecharge": "金币充值(购买金币)", + "TranTypeDarkAlley": "历史遗留交易类型(已下线)", + "TranTypeVideoUnlock": "视频解锁", + "TranTypeVipGiftCoin": "购买会员赠送金币" + }, + "x-enum-varnames": [ + "TranTypeCoinRecharge", + "TranTypeVipGiftCoin", + "TranTypeDarkAlley", + "TranTypeVideoUnlock", + "TranTypeAdminAdjust" + ] + }, + "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" + } + } + }, + "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.OperateType": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "OperateTypeComic": "漫剧", + "OperateTypePlaylet": "短剧" + }, + "x-enum-varnames": [ + "OperateTypePlaylet", + "OperateTypeComic" + ] + }, + "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.ContactUsHandleReq": { + "type": "object", + "required": [ + "ids", + "status" + ], + "properties": { + "ids": { + "description": "联系我们ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "remark": { + "description": "处理备注", + "type": "string" + }, + "status": { + "description": "处理状态,0-待处理、1-已处理", + "type": "integer" + } + } + }, + "proto.ContactUsHandleResp": { + "type": "object", + "properties": { + "matchedCount": { + "description": "命中数量", + "type": "integer" + }, + "modifiedCount": { + "description": "修改数量", + "type": "integer" + } + } + }, + "proto.ContactUsListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "集合列表" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.ContactUsListResp": { + "type": "object", + "properties": { + "attachments": { + "description": "附件地址", + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "description": "内容", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "email": { + "description": "邮箱", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "remark": { + "description": "处理备注", + "type": "string" + }, + "status": { + "description": "处理状态,0-待处理、1-已处理", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + }, + "type": { + "description": "问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "proto.FeedbackHandleReq": { + "type": "object", + "required": [ + "ids", + "status" + ], + "properties": { + "ids": { + "description": "意见反馈ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "remark": { + "description": "处理备注", + "type": "string" + }, + "status": { + "description": "处理状态,0-待处理、1-已处理", + "type": "integer" + } + } + }, + "proto.FeedbackHandleResp": { + "type": "object", + "properties": { + "matchedCount": { + "description": "命中数量", + "type": "integer" + }, + "modifiedCount": { + "description": "修改数量", + "type": "integer" + } + } + }, + "proto.FeedbackListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "集合列表" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.FeedbackListResp": { + "type": "object", + "properties": { + "contact": { + "description": "联系方式", + "type": "string" + }, + "content": { + "description": "描述内容", + "type": "string" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "remark": { + "description": "处理备注", + "type": "string" + }, + "screenshots": { + "description": "问题截图", + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "description": "处理状态,0-待处理、1-已处理", + "type": "integer" + }, + "type": { + "description": "问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "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.HomeConfigAddReq": { + "type": "object", + "required": [ + "name", + "sortType", + "status", + "type" + ], + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "sortType": { + "description": "排序字段 1-推荐 2-热播 3-最近更新 4-新片预告", + "type": "integer" + }, + "status": { + "description": "上下架状态 true-上架 false-下架", + "type": "boolean" + }, + "type": { + "description": "类型 1-短剧 2-视频 3-题材", + "type": "integer" + } + } + }, + "proto.HomeConfigAddResp": { + "type": "object", + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.HomeConfigDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID", + "type": "integer" + } + } + }, + "proto.HomeConfigListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "集合列表" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.HomeConfigUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "ID", + "type": "integer" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "sortType": { + "description": "排序字段 1-推荐 2-热播 3-最近更新 4-新片预告", + "type": "integer" + }, + "status": { + "description": "上下架状态 true-上架 false-下架", + "type": "boolean" + }, + "type": { + "description": "类型 1-短剧 2-视频 3-题材", + "type": "integer" + } + } + }, + "proto.PlayletAddReq": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "anchor": { + "type": "string" + }, + "banner": { + "type": "string" + }, + "contentType": { + "$ref": "#/definitions/playletmod.ContentType" + }, + "cover": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "horizontalCover": { + "description": "横版封面", + "type": "string" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "type": "boolean" + }, + "isRecommend": { + "type": "boolean" + }, + "onlineAt": { + "description": "上架时间,上架时不传则按当前时间", + "type": "string" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧,不传默认短剧", + "allOf": [ + { + "$ref": "#/definitions/playletmod.OperateType" + } + ] + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "preview": { + "type": "string" + }, + "recommendRank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "type": "boolean" + }, + "singleCoins": { + "description": "单集金币(支付方式为1-金币时候生效)", + "type": "integer" + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或标题自动生成", + "type": "string" + }, + "status": { + "$ref": "#/definitions/playletmod.Status" + }, + "tagIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "themeIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "type": "string" + }, + "topRank": { + "type": "integer" + }, + "totalEpisode": { + "type": "integer" + } + } + }, + "proto.PlayletAddResp": { + "type": "object", + "properties": { + "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.PlayletChapterAddReq": { + "type": "object", + "required": [ + "currentEpisode", + "playletId" + ], + "properties": { + "currentEpisode": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "md5": { + "type": "string" + }, + "mediaSize": { + "type": "integer" + }, + "mediaSourceId": { + "type": "string" + }, + "mediaTime": { + "type": "integer" + }, + "payType": { + "$ref": "#/definitions/playletchaptermod.PayType" + }, + "playletId": { + "type": "integer" + }, + "ratio": { + "type": "number" + }, + "singleCoins": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "videoUrl": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "proto.PlayletChapterAddResp": { + "type": "object", + "properties": { + "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-金币时必填且需\u003e0)", + "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": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "md5": { + "type": "string" + }, + "mediaSize": { + "type": "integer" + }, + "mediaTime": { + "type": "integer" + }, + "payType": { + "$ref": "#/definitions/playletchaptermod.PayType" + }, + "ratio": { + "type": "number" + }, + "singleCoins": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "videoUrl": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "proto.PlayletDelReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条删除,传多个表示批量删除", + "type": "array", + "items": { + "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.PlayletListSortType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "x-enum-comments": { + "PlayletListSortCountBrowse": "浏览数", + "PlayletListSortCountCollect": "收藏数", + "PlayletListSortCountLike": "点赞数", + "PlayletListSortCreatedAt": "创建时间(默认)", + "PlayletListSortOnlineAt": "上架时间", + "PlayletListSortRecommendRank": "推荐排序", + "PlayletListSortUpdatedAt": "修改时间" + }, + "x-enum-varnames": [ + "PlayletListSortCreatedAt", + "PlayletListSortRecommendRank", + "PlayletListSortUpdatedAt", + "PlayletListSortCountLike", + "PlayletListSortCountBrowse", + "PlayletListSortCountCollect", + "PlayletListSortOnlineAt" + ] + }, + "proto.PlayletOnlineAtFillResp": { + "type": "object", + "properties": { + "updatedCount": { + "description": "本次回填数量", + "type": "integer" + } + } + }, + "proto.PlayletOperateTypeSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "allOf": [ + { + "$ref": "#/definitions/playletmod.OperateType" + } + ] + } + } + }, + "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-金币时必填且需\u003e0)", + "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.PlayletResourceJHListItem": { + "type": "object", + "properties": { + "actors": { + "description": "演员集合", + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletResourceMediaActor" + } + }, + "anchor": { + "description": "作者", + "type": "string" + }, + "create_time": { + "description": "创建时间", + "type": "integer" + }, + "data_version": { + "description": "数据版本", + "type": "integer" + }, + "horizontalCover": { + "description": "兼容驼峰", + "type": "string" + }, + "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": "更新状态", + "type": "integer" + }, + "update_time": { + "description": "更新时间", + "type": "integer" + }, + "verticalCover": { + "description": "兼容驼峰", + "type": "string" + }, + "vertical_cover": { + "description": "竖版封面", + "type": "string" + }, + "vlogger_id": { + "type": "integer" + } + } + }, + "proto.PlayletResourceJHListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletResourceJHListItem" + } + }, + "total": { + "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.PlayletResourceMediaActor": { + "type": "object", + "properties": { + "avatar": { + "description": "头像", + "type": "string" + }, + "gender": { + "description": "性别", + "type": "integer" + }, + "name": { + "description": "名称", + "type": "string" + } + } + }, + "proto.PlayletResourceUseReq": { + "type": "object", + "properties": { + "ids": { + "description": "媒资中心资源ID集合-多个用逗号隔开,请求示例[28830,28829,28828,28827,28826,28825]", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proto.PlayletSalesItem": { + "type": "object", + "properties": { + "collectCount": { + "description": "期间新增且当前仍在的短剧收藏数", + "type": "integer" + }, + "cover": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "likeCount": { + "description": "期间新增且当前仍在的短剧点赞数", + "type": "integer" + }, + "playName": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/playletmod.Status" + }, + "title": { + "type": "string" + }, + "unlockCoins": { + "description": "金币解锁消耗", + "type": "integer" + }, + "unlockCount": { + "description": "金币解锁次数", + "type": "integer" + } + } + }, + "proto.PlayletSalesResp": { + "type": "object", + "properties": { + "endTime": { + "description": "实际统计结束时间(北京时间)", + "type": "string" + }, + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/proto.PlayletSalesItem" + } + }, + "startTime": { + "description": "实际统计开始时间(北京时间)", + "type": "string" + } + } + }, + "proto.PlayletSalesSortType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "PlayletSalesSortCollect": "收藏数", + "PlayletSalesSortLike": "点赞数", + "PlayletSalesSortUnlock": "金币解锁次数" + }, + "x-enum-varnames": [ + "PlayletSalesSortUnlock", + "PlayletSalesSortLike", + "PlayletSalesSortCollect" + ] + }, + "proto.PlayletSlugSyncReq": { + "type": "object", + "properties": { + "maxSize": { + "description": "本次最多处理的空 slug 记录数,默认500,最大2000", + "type": "integer" + } + } + }, + "proto.PlayletSlugSyncResp": { + "type": "object", + "properties": { + "hasMore": { + "description": "是否还有未处理的历史数据", + "type": "boolean" + }, + "playletUpdated": { + "description": "本次补齐的短剧数", + "type": "integer" + }, + "tagUpdated": { + "description": "本次补齐的标签数", + "type": "integer" + }, + "themeUpdated": { + "description": "本次补齐的题材数", + "type": "integer" + } + } + }, + "proto.PlayletStatusSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "短剧ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "onlineAt": { + "description": "上架时间;传入则使用该时间,未传且从未设置过则用当前时间", + "type": "string" + }, + "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" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或名称自动生成", + "type": "string" + }, + "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" + } + ] + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "type": "boolean" + } + } + }, + "proto.PlayletThemeAddReq": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "icon": { + "type": "string" + }, + "isRecommend": { + "description": "是否推荐", + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,不传则按 SEO 关键词或名称自动生成", + "type": "string" + }, + "status": { + "type": "boolean" + } + } + }, + "proto.PlayletThemeAddResp": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "proto.PlayletThemeDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "proto.PlayletThemeListPageResp": { + "type": "object", + "properties": { + "list": { + "description": "集合列表" + }, + "pageNum": { + "description": "页数", + "type": "integer" + }, + "pageSize": { + "description": "每页数", + "type": "integer" + }, + "totalCount": { + "description": "总共数量", + "type": "integer" + } + } + }, + "proto.PlayletThemeRecommendSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "题材ID集合,传1个表示单条设置,传多个表示批量设置", + "type": "array", + "items": { + "type": "integer" + } + }, + "isRecommend": { + "description": "是否推荐(true-推荐,false-取消推荐)", + "type": "boolean" + } + } + }, + "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": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "isRecommend": { + "description": "是否推荐", + "type": "boolean" + }, + "name": { + "type": "string" + }, + "rank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "type": "boolean" + } + } + }, + "proto.PlayletUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "anchor": { + "type": "string" + }, + "banner": { + "type": "string" + }, + "contentType": { + "$ref": "#/definitions/playletmod.ContentType" + }, + "cover": { + "type": "string" + }, + "desc": { + "type": "string" + }, + "horizontalCover": { + "description": "横版封面", + "type": "string" + }, + "id": { + "type": "integer" + }, + "isFeatured": { + "description": "是否爆剧(true-是,false-否)", + "type": "boolean" + }, + "isRecommend": { + "type": "boolean" + }, + "onlineAt": { + "description": "上架时间,新上架时不传则按当前时间", + "type": "string" + }, + "operateType": { + "description": "运营类型 1-短剧 2-漫剧", + "allOf": [ + { + "$ref": "#/definitions/playletmod.OperateType" + } + ] + }, + "payType": { + "description": "支付方式 0-免费 1-金币 2-VIP", + "allOf": [ + { + "$ref": "#/definitions/playletmod.PayType" + } + ] + }, + "playName": { + "description": "剧名", + "type": "string" + }, + "preview": { + "type": "string" + }, + "recommendRank": { + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "showOnBanner": { + "type": "boolean" + }, + "singleCoins": { + "description": "单集金币(支付方式为1-金币时候生效)", + "type": "integer" + }, + "slug": { + "description": "slug,历史数据为空时更新会自动生成", + "type": "string" + }, + "status": { + "$ref": "#/definitions/playletmod.Status" + }, + "tagIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "themeIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "type": "string" + }, + "topRank": { + "type": "integer" + }, + "totalEpisode": { + "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.VideoSlugSyncReq": { + "type": "object", + "properties": { + "maxSize": { + "description": "本次最多处理的空 slug 记录数,默认500,最大2000", + "type": "integer" + } + } + }, + "proto.VideoSlugSyncResp": { + "type": "object", + "properties": { + "categoryUpdated": { + "description": "本次补齐的分类数", + "type": "integer" + }, + "createrUpdated": { + "description": "本次补齐的作者数", + "type": "integer" + }, + "hasMore": { + "description": "是否还有未处理的历史数据", + "type": "boolean" + }, + "tagUpdated": { + "description": "本次补齐的标签数", + "type": "integer" + }, + "topicUpdated": { + "description": "本次补齐的题材数", + "type": "integer" + }, + "videoUpdated": { + "description": "本次补齐的视频数", + "type": "integer" + } + } + }, + "pubdef.LoginType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "LoginTypeAccount": "账号登陆", + "LoginTypeDevice": "设备登陆", + "LoginTypeEmail": "邮箱登陆" + }, + "x-enum-varnames": [ + "LoginTypeDevice", + "LoginTypeAccount", + "LoginTypeEmail" + ] + }, + "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" + ] + }, + "pubdef.UserType": { + "type": "integer", + "enum": [ + 0, + 1, + 0 + ], + "x-enum-comments": { + "UserTypeGuest": "游客用户", + "UserTypeNormal": "普通用户(已注册)" + }, + "x-enum-varnames": [ + "UserTypeGuest", + "UserTypeNormal", + "UserTypeNone" + ] + }, + "pubdef.VipOrderStatus": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "x-enum-comments": { + "VipOrderFailed": "支付失败", + "VipOrderPending": "待支付", + "VipOrderSuccess": "支付成功" + }, + "x-enum-varnames": [ + "VipOrderPending", + "VipOrderSuccess", + "VipOrderFailed" + ] + }, + "pubdef.VipType": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "VipTypeMonth": "月卡", + "VipTypeNone": "没卡", + "VipTypeYear": "年卡" + }, + "x-enum-varnames": [ + "VipTypeNone", + "VipTypeMonth", + "VipTypeYear" + ] + }, + "rechargepackagemod.PackageType": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "PackageTypeCoin": "金币充值", + "PackageTypeVip": "会员畅享" + }, + "x-enum-varnames": [ + "PackageTypeVip", + "PackageTypeCoin" + ] + }, + "rechargepackagemod.Status": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "StatusDisabled": "停用", + "StatusEnabled": "启用" + }, + "x-enum-varnames": [ + "StatusEnabled", + "StatusDisabled" + ] + }, + "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_announcement.AddAnnouncementReq": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "content": { + "description": "内容", + "type": "string" + }, + "sort": { + "description": "排序,越大越靠前", + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题", + "type": "string" + } + } + }, + "srv_announcement.DelAnnouncementReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_announcement.GetAnnouncementListReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "是否启用", + "type": "boolean" + }, + "title": { + "description": "标题,模糊搜索", + "type": "string" + } + } + }, + "srv_announcement.GetAnnouncementListResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/announcementmod.Announcement" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_announcement.UpdateAnnouncementParam": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "sort": { + "type": "integer" + }, + "status": { + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, + "srv_announcement.UpdateAnnouncementReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "$ref": "#/definitions/srv_announcement.UpdateAnnouncementParam" + } + } + }, + "srv_cointrans.CoinTransReq": { + "type": "object", + "properties": { + "endTime": { + "description": "结束时间 2006-01-02 15:04:05", + "type": "string" + }, + "flowDir": { + "description": "收支方向:1收入 -1支出", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.FlowDir" + } + ] + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "startTime": { + "description": "开始时间 2006-01-02 15:04:05", + "type": "string" + }, + "tranType": { + "description": "交易类型", + "allOf": [ + { + "$ref": "#/definitions/cointransmod.TranType" + } + ] + }, + "userId": { + "description": "用户ID", + "type": "integer" + } + } + }, + "srv_cointrans.CoinTransResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/cointransmod.CoinTrans" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_commend.CommendReplay": { + "type": "object", + "properties": { + "objectId": { + "description": "帖子ID", + "type": "integer" + }, + "parentsId": { + "description": "一级评论ID", + "type": "integer" + }, + "replyCommentId": { + "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": "对象类型 10-短剧 11-短剧剧集 20-视频", + "type": "integer" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "reviewStatus": { + "type": "integer" + }, + "text": { + "description": "评论内容", + "type": "string" + }, + "userId": { + "description": "评论用户ID", + "type": "integer" + }, + "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": { + "countLike": { + "description": "评论点赞数", + "type": "integer" + }, + "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": "对象类型 10-短剧 11-短剧剧集 20-视频", + "type": "integer" + }, + "parentsId": { + "description": "上级评论ID", + "type": "integer" + }, + "replyCommentId": { + "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" + }, + "userAvatar": { + "description": "评论用户头像", + "type": "string" + }, + "userId": { + "description": "评论用户ID", + "type": "integer" + }, + "userName": { + "description": "用户名称", + "type": "string" + } + } + }, + "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_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" + } + } + }, + "payCenter.RechargeCallbackResp": { + "type": "object", + "required": [ + "code", + "mercId", + "oid", + "payMoney", + "tradeNo", + "sign" + ], + "properties": { + "code": { + "type": "integer" + }, + "mercId": { + "type": "string" + }, + "oid": { + "type": "string" + }, + "payMoney": { + "type": "string" + }, + "tradeNo": { + "type": "string" + }, + "sign": { + "type": "string" + } + } + }, + "srv_rechargepackage.RechargePackageAddReq": { + "type": "object", + "required": [ + "name", + "packageType", + "price" + ], + "properties": { + "baseCoin": { + "description": "基础金币数,会员套餐传0", + "type": "integer" + }, + "durationDays": { + "description": "会员有效天数,金币套餐传0", + "type": "integer" + }, + "giftCoin": { + "description": "赠送金币数", + "type": "integer" + }, + "name": { + "description": "展示标题", + "type": "string" + }, + "originalPrice": { + "description": "原价,单位分", + "type": "integer" + }, + "packageType": { + "description": "套餐类型:1-会员畅享 2-金币充值", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.PackageType" + } + ] + }, + "price": { + "description": "售价,单位分", + "type": "integer" + }, + "remark": { + "description": "后台备注", + "type": "string" + }, + "sort": { + "description": "排序,越小越靠前", + "type": "integer" + }, + "status": { + "description": "状态:1-启用 2-停用,默认1", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.Status" + } + ] + } + } + }, + "srv_rechargepackage.RechargePackageDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "套餐ID", + "type": "integer" + } + } + }, + "srv_rechargepackage.RechargePackageItem": { + "type": "object", + "properties": { + "baseCoin": { + "description": "基础金币数", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "durationDays": { + "description": "会员有效天数", + "type": "integer" + }, + "durationHours": { + "description": "会员有效小时数,由durationDays计算", + "type": "integer" + }, + "giftCoin": { + "description": "赠送金币数", + "type": "integer" + }, + "id": { + "description": "套餐ID", + "type": "integer" + }, + "name": { + "description": "展示标题", + "type": "string" + }, + "originalPrice": { + "description": "原价,单位分", + "type": "integer" + }, + "packageType": { + "description": "套餐类型:1-会员畅享 2-金币充值", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.PackageType" + } + ] + }, + "price": { + "description": "售价,单位分", + "type": "integer" + }, + "remark": { + "description": "后台备注", + "type": "string" + }, + "sort": { + "description": "排序,越小越靠前", + "type": "integer" + }, + "status": { + "description": "状态:1-启用 2-停用", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.Status" + } + ] + }, + "subTitle": { + "description": "展示副标题", + "type": "string" + }, + "totalCoin": { + "description": "到账总金币数,由baseCoin+giftCoin计算", + "type": "integer" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "srv_rechargepackage.RechargePackageListReq": { + "type": "object", + "properties": { + "id": { + "description": "套餐ID", + "type": "integer" + }, + "name": { + "description": "展示标题,模糊搜索", + "type": "string" + }, + "packageType": { + "description": "套餐类型:1-会员畅享 2-金币充值", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.PackageType" + } + ] + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "状态:1-启用 2-停用", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.Status" + } + ] + } + } + }, + "srv_rechargepackage.RechargePackageListResp": { + "type": "object", + "properties": { + "list": { + "description": "当前页列表", + "type": "array", + "items": { + "$ref": "#/definitions/srv_rechargepackage.RechargePackageItem" + } + }, + "total": { + "description": "总数量", + "type": "integer" + } + } + }, + "srv_rechargepackage.RechargePackageStatusSetReq": { + "type": "object", + "properties": { + "ids": { + "description": "套餐ID集合", + "type": "array", + "items": { + "type": "integer" + } + }, + "status": { + "description": "状态:1-启用 2-停用", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.Status" + } + ] + } + } + }, + "srv_rechargepackage.RechargePackageUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "baseCoin": { + "description": "基础金币数", + "type": "integer" + }, + "durationDays": { + "description": "会员有效天数", + "type": "integer" + }, + "giftCoin": { + "description": "赠送金币数", + "type": "integer" + }, + "id": { + "description": "套餐ID", + "type": "integer" + }, + "name": { + "description": "展示标题", + "type": "string" + }, + "originalPrice": { + "description": "原价,单位分", + "type": "integer" + }, + "packageType": { + "description": "套餐类型:1-会员畅享 2-金币充值", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.PackageType" + } + ] + }, + "price": { + "description": "售价,单位分", + "type": "integer" + }, + "remark": { + "description": "后台备注", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "状态:1-启用 2-停用", + "allOf": [ + { + "$ref": "#/definitions/rechargepackagemod.Status" + } + ] + } + } + }, + "srv_rechargepackage.VipPackageOption": { + "type": "object", + "properties": { + "id": { + "description": "套餐ID", + "type": "integer" + }, + "name": { + "description": "套餐名称", + "type": "string" + } + } + }, + "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" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "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.GiftCoinReq": { + "type": "object", + "required": [ + "coin", + "userId" + ], + "properties": { + "coin": { + "description": "赠送金币数(正数=增加,负数=扣除)", + "type": "integer" + }, + "remark": { + "description": "备注(记录在流水表)", + "type": "string" + }, + "userId": { + "description": "目标用户ID", + "type": "integer" + } + } + }, + "srv_user.UpdateVipReq": { + "type": "object", + "required": [ + "packageId", + "userId" + ], + "properties": { + "packageId": { + "description": "会员畅享套餐ID", + "type": "integer" + }, + "userId": { + "description": "用户ID", + "type": "integer" + } + } + }, + "srv_user.UserListReq": { + "type": "object", + "properties": { + "accountNo": { + "description": "账号(精确)", + "type": "string" + }, + "districtCode": { + "description": "渠道码", + "type": "string" + }, + "email": { + "description": "邮箱(精确)", + "type": "string" + }, + "id": { + "description": "用户ID", + "type": "integer" + }, + "nickName": { + "description": "昵称(模糊)", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserStatus" + } + ] + }, + "type": { + "description": "用户类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserType" + } + ] + } + } + }, + "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": { + "id": { + "description": "分类ID", + "type": "integer" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + } + } + }, + "srv_videocategory.VideoCategoryResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videocategorymod.VideoCategory" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videocategory.VideoCategoryUpdate": { + "type": "object", + "properties": { + "appId": { + "description": "appId,唯一id", + "type": "integer" + }, + "contentType": { + "description": "内容:0: 默认 1:首页 2:今日", + "type": "integer" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "indexNow": { + "type": "integer" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "name": { + "description": "名称", + "type": "string" + }, + "rank": { + "description": "排序", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sortType": { + "description": "排序方式 0:上线时间 1:观看次数 2排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "srv_videocategory.VideoCategoryUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_videocategory.VideoCategoryUpdate" + } + ] + } + } + }, + "srv_videocreater.VideoCreaterDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videocreater.VideoCreaterReq": { + "type": "object", + "properties": { + "id": { + "description": "分类ID", + "type": "integer" + }, + "name": { + "description": "分类名称", + "type": "string" + }, + "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" + }, + "indexNow": { + "type": "integer" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "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" + } + } + }, + "srv_videocreater.VideoCreaterUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_videocreater.VideoCreaterUpdate" + } + ] + } + } + }, + "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" + } + }, + "slug": { + "description": "slug", + "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.SetFakeWatchTimesReq": { + "type": "object", + "properties": { + "ids": { + "description": "视频ID集合,支持单个或批量", + "type": "array", + "items": { + "type": "integer" + } + }, + "watchTimesMax": { + "description": "假观看次数随机最大值", + "type": "integer" + }, + "watchTimesMin": { + "description": "假观看次数随机最小值", + "type": "integer" + } + } + }, + "srv_videoinfo.VideoInfoDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videoinfo.VideoInfoReq": { + "type": "object", + "properties": { + "categoryId": { + "description": "分类Id", + "type": "integer" + }, + "creatorId": { + "description": "作者Id", + "type": "integer" + }, + "creatorName": { + "description": "作者名称", + "type": "string" + }, + "endTime": { + "description": "创建时间-截止", + "type": "string" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "isH265": { + "description": "是否支持 H265", + "type": "boolean" + }, + "isHot": { + "description": "是否热门", + "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=播放次数", + "type": "integer" + }, + "startTime": { + "description": "创建时间-开始", + "type": "string" + }, + "status": { + "description": "帖子上架状态", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.InformationSatus" + } + ] + }, + "tag": { + "description": "标签", + "type": "integer" + }, + "title": { + "description": "标题", + "type": "string" + }, + "topicId": { + "description": "专题Id", + "type": "integer" + }, + "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": { + "addedTime": { + "type": "string" + }, + "categoryIds": { + "description": "分类Id", + "type": "array", + "items": { + "type": "integer" + } + }, + "content": { + "type": "string" + }, + "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" + }, + "isTop": { + "description": "是否置顶", + "type": "boolean" + }, + "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/videoinformationmod.InformationSatus" + } + ] + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "topicIds": { + "description": "专题ID", + "type": "array", + "items": { + "type": "integer" + } + }, + "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_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.IpStatResp": { + "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" + }, + "indexNow": { + "type": "integer" + }, + "isReIndexNow": { + "description": "是否重新提交索引", + "type": "boolean" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, + "srv_videotag.VideoTagInfoUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_videotag.VideoTagInfoUpdate" + } + ] + } + } + }, + "srv_videotopic.VideoTopicDelReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + } + } + }, + "srv_videotopic.VideoTopicReq": { + "type": "object", + "properties": { + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "videoCategoryId": { + "type": "integer" + } + } + }, + "srv_videotopic.VideoTopicResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/videotopicmod.VideoTopicModel" + } + }, + "total": { + "type": "integer" + } + } + }, + "srv_videotopic.VideoTopicUpdate": { + "type": "object", + "properties": { + "desc": { + "description": "描述", + "type": "string" + }, + "image": { + "description": "背景", + "type": "string" + }, + "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" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "videoCategoryId": { + "description": "视频分类id", + "type": "integer" + }, + "videoTopicType": { + "description": "视频类型", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.MediaType" + } + ] + } + } + }, + "srv_videotopic.VideoTopicUpdateReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer" + }, + "param": { + "description": "appId,唯一id", + "allOf": [ + { + "$ref": "#/definitions/srv_videotopic.VideoTopicUpdate" + } + ] + } + } + }, + "srv_viporder.RechargeSummaryResp": { + "type": "object", + "properties": { + "coinAmount": { + "description": "金币成功订单金额(分)", + "type": "integer" + }, + "coinOrderCount": { + "description": "金币成功订单数", + "type": "integer" + }, + "endTime": { + "description": "实际统计结束时间(北京时间)", + "type": "string" + }, + "orderCount": { + "description": "成功订单数", + "type": "integer" + }, + "startTime": { + "description": "实际统计开始时间(北京时间)", + "type": "string" + }, + "totalAmount": { + "description": "成功订单金额合计(分)", + "type": "integer" + }, + "userCount": { + "description": "成功充值用户数(去重)", + "type": "integer" + }, + "vipAmount": { + "description": "会员成功订单金额(分)", + "type": "integer" + }, + "vipOrderCount": { + "description": "会员成功订单数", + "type": "integer" + } + } + }, + "srv_viporder.SyncDistrictCodeReq": { + "type": "object", + "properties": { + "maxSize": { + "description": "本次最多扫描的空渠道码订单数,默认500,最大2000", + "type": "integer" + } + } + }, + "srv_viporder.SyncDistrictCodeResp": { + "type": "object", + "properties": { + "hasMore": { + "description": "是否还有未处理的历史订单", + "type": "boolean" + }, + "orderUpdated": { + "description": "成功回填的订单数", + "type": "integer" + }, + "scanned": { + "description": "本次扫描的空渠道码订单数", + "type": "integer" + }, + "userSkipped": { + "description": "用户本身没有渠道码", + "type": "integer" + }, + "userUpdated": { + "description": "成功回填的用户数", + "type": "integer" + } + } + }, + "srv_viporder.VipOrderReq": { + "type": "object", + "properties": { + "districtCode": { + "description": "渠道码", + "type": "string" + }, + "endTime": { + "description": "创建时间-结束", + "type": "string" + }, + "orderType": { + "description": "订单类型:1会员 2金币", + "allOf": [ + { + "$ref": "#/definitions/vipordermod.OrderType" + } + ] + }, + "pageNum": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "startTime": { + "description": "创建时间-开始", + "type": "string" + }, + "status": { + "description": "订单状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.VipOrderStatus" + } + ] + }, + "tradeNo": { + "description": "订单号", + "type": "string" + }, + "userId": { + "description": "用户ID", + "type": "integer" + } + } + }, + "srv_viporder.VipOrderResp": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/vipordermod.VipOrder" + } + }, + "total": { + "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" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "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" + }, + "deviceBrand": { + "description": "设备品牌", + "type": "string" + }, + "deviceModel": { + "description": "设备型号", + "type": "string" + }, + "districtCode": { + "description": "商区码", + "type": "string" + }, + "email": { + "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" + }, + "isDirect": { + "description": "是否是直推 true:是直推用户", + "type": "boolean" + }, + "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" + }, + "privacyMode": { + "description": "隐私模式:true-开启,false-关闭(默认)", + "type": "boolean" + }, + "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" + }, + "sessionId": { + "description": "会话ID", + "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" + }, + "systemName": { + "description": "系统名称", + "type": "string" + }, + "systemVersion": { + "description": "系统版本", + "type": "string" + }, + "type": { + "description": "类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserType" + } + ] + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "vipType": { + "description": "Vip类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.VipType" + } + ] + } + } + }, + "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" + }, + "deviceBrand": { + "description": "设备品牌", + "type": "string" + }, + "deviceModel": { + "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" + }, + "isDirect": { + "description": "是否是直推 true:是直推用户", + "type": "boolean" + }, + "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" + }, + "sessionId": { + "description": "会话ID", + "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" + }, + "systemName": { + "description": "系统名称", + "type": "string" + }, + "systemVersion": { + "description": "系统版本", + "type": "string" + }, + "type": { + "description": "类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.UserType" + } + ] + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "vipType": { + "description": "Vip类型", + "allOf": [ + { + "$ref": "#/definitions/pubdef.VipType" + } + ] + } + } + }, + "videocategorymod.VideoCategory": { + "type": "object", + "properties": { + "contentType": { + "description": "内容:0: 默认 1:首页 2:今日 3:合集", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "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": "排序方式 0:上线时间 1:观看次数 2排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + } + } + }, + "videocreatermod.VideoCreaterInfo": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "id": { + "type": "integer" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "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" + } + } + }, + "videoinformationmod.FakeFlow": { + "type": "object", + "properties": { + "watchTimes": { + "description": "假观看次数", + "type": "integer" + } + } + }, + "videoinformationmod.Flow": { + "type": "object", + "properties": { + "collects": { + "description": "收藏数", + "type": "integer" + }, + "comments": { + "description": "评论条数", + "type": "integer" + }, + "likes": { + "description": "点赞数", + "type": "integer" + }, + "monthWatchs": { + "description": "本月观看次数", + "type": "integer" + }, + "showTimes": { + "description": "展示次数", + "type": "integer" + }, + "todayWatchs": { + "description": "当日观看次数", + "type": "integer" + }, + "watchTimes": { + "description": "观看次数", + "type": "integer" + }, + "weekWatchs": { + "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.InformationSatus": { + "type": "integer", + "enum": [ + 0, + 1, + 2 + ], + "x-enum-comments": { + "InformationSatusVerifyCancel": "下架", + "InformationSatusVerifyOK": "上架", + "InformationSatusWaiting": "初始化" + }, + "x-enum-varnames": [ + "InformationSatusWaiting", + "InformationSatusVerifyOK", + "InformationSatusVerifyCancel" + ] + }, + "videoinformationmod.MediaType": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ], + "x-enum-comments": { + "MediaType_Advitise": "广告", + "MediaType_Collection": "合集", + "MediaType_Video": "视频(帖子)", + "MediaType_VideoV2": "视频" + }, + "x-enum-varnames": [ + "MediaType_Video", + "MediaType_Collection", + "MediaType_Advitise", + "MediaType_VideoV2" + ] + }, + "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": "H265 视频地址", + "type": "string" + }, + "height": { + "description": "分辨率高(视频才会存在)", + "type": "integer" + }, + "preMp4FileNameFs": { + "description": "预览MP4文件", + "type": "string" + }, + "taskId": { + "description": "视频ID(视频才会存在)", + "type": "string" + }, + "unencryptedPreMp4": { + "description": "不加密的预览MP4", + "type": "string" + }, + "url": { + "description": "视频URl", + "type": "string" + }, + "videoTime": { + "description": "影片长度", + "type": "integer" + }, + "width": { + "description": "分辨率宽(视频才会存在)", + "type": "integer" + } + } + }, + "videoinformationmod.VideoInformation": { + "type": "object", + "properties": { + "addedTime": { + "description": "帖子上架时间", + "type": "string" + }, + "audioFile": { + "description": "音频文件路径", + "type": "string" + }, + "categoryIds": { + "description": "分类Id (帖子用)", + "type": "array", + "items": { + "type": "integer" + } + }, + "content": { + "description": "富文本", + "type": "string" + }, + "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/videoinformationmod.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" + }, + "isTop": { + "description": "是否置顶", + "type": "boolean" + }, + "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/videoinformationmod.Flow" + } + ] + }, + "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/videoinformationmod.InformationSatus" + } + ] + }, + "syncId": { + "description": "同步ID", + "type": "string" + }, + "tags": { + "description": "标签", + "type": "array", + "items": { + "type": "integer" + } + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "topicIds": { + "description": "专题ID(视频用)", + "type": "array", + "items": { + "type": "integer" + } + }, + "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" + }, + "indexNow": { + "description": "是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改", + "type": "integer" + }, + "seoInfo": { + "description": "Seo相关信息", + "allOf": [ + { + "$ref": "#/definitions/pubdef.SeoValue" + } + ] + }, + "slug": { + "description": "slug", + "type": "string" + }, + "sort": { + "description": "排序", + "type": "integer" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "帖子标题", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "watchTimes": { + "description": "预计算:该标签下所有视频的累计播放量", + "type": "integer" + } + } + }, + "videotopicmod.VideoTopicModel": { + "type": "object", + "properties": { + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "desc": { + "description": "描述", + "type": "string" + }, + "id": { + "description": "Id", + "type": "integer" + }, + "image": { + "description": "背景", + "type": "string" + }, + "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" + }, + "status": { + "description": "上架状态", + "type": "boolean" + }, + "title": { + "description": "APP显示", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "videoCategoryId": { + "description": "视频分类id", + "type": "integer" + }, + "videoTopicType": { + "description": "视频类型", + "allOf": [ + { + "$ref": "#/definitions/videoinformationmod.MediaType" + } + ] + }, + "works": { + "description": "作品数", + "type": "integer" + } + } + }, + "vipordermod.OrderType": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "x-enum-comments": { + "OrderTypeCoin": "金币购买", + "OrderTypeVip": "会员购买" + }, + "x-enum-varnames": [ + "OrderTypeVip", + "OrderTypeCoin" + ] + }, + "vipordermod.VipOrder": { + "type": "object", + "properties": { + "amount": { + "description": "支付金额(分)", + "type": "integer" + }, + "cardId": { + "description": "── VIP 专用 ──", + "type": "integer" + }, + "cardName": { + "description": "套餐名快照", + "type": "string" + }, + "coin": { + "description": "到账金币数(快照)", + "type": "integer" + }, + "coinCfgId": { + "description": "── 金币专用 ──", + "type": "integer" + }, + "createdAt": { + "description": "创建时间", + "type": "string" + }, + "districtCode": { + "description": "下单时用户渠道码", + "type": "string" + }, + "finishedAt": { + "description": "完成时间", + "type": "string" + }, + "id": { + "description": "唯一ID", + "type": "integer" + }, + "ipAddr": { + "description": "客户端 IP", + "type": "string" + }, + "oid": { + "description": "第三方订单号", + "type": "string" + }, + "orderType": { + "description": "订单类型:1会员 2金币", + "allOf": [ + { + "$ref": "#/definitions/vipordermod.OrderType" + } + ] + }, + "payChannel": { + "description": "支付渠道", + "type": "string" + }, + "payMode": { + "description": "支付方式:overage/alipay/wechat", + "type": "string" + }, + "payRate": { + "description": "费率", + "type": "string" + }, + "payUrl": { + "description": "支付链接", + "type": "string" + }, + "payUrlMode": { + "description": "链接打开方式", + "type": "string" + }, + "status": { + "description": "订单状态", + "allOf": [ + { + "$ref": "#/definitions/pubdef.VipOrderStatus" + } + ] + }, + "sysType": { + "description": "ios/android", + "type": "string" + }, + "tradeNo": { + "description": "本地订单号", + "type": "string" + }, + "updatedAt": { + "description": "更新时间", + "type": "string" + }, + "userId": { + "description": "用户ID", + "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" + } + }, + "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": { + "actressBackGround": { + "description": "女优通用背景图", + "type": "string" + }, + "advitiesTopicNum": { + "description": "广告间隔", + "type": "integer" + }, + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "customerServiceHost": { + "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" + }, + "id": { + "type": "integer" + }, + "imgAppCdn": { + "description": "图片CDN(app使用)", + "type": "string" + }, + "imgAppUsCdn": { + "description": "图片CDN(海外app使用)", + "type": "string" + }, + "imgWebCdn": { + "description": "图片CDN(web使用)", + "type": "string" + }, + "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" + }, + "officialAppAddr": { + "description": "官网app地址", + "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" + } + }, + "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": { + "actressBackGround": { + "description": "女优通用背景图", + "type": "string" + }, + "advitiesTopicNum": { + "description": "广告间隔", + "type": "integer" + }, + "appAddr": { + "description": "APP下载地址(落地页)", + "type": "string" + }, + "channelCode": { + "description": "渠道码", + "type": "string" + }, + "customerServiceHost": { + "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" + }, + "enableH265": { + "description": "是否允许客户端使用 H265 地址播放", + "type": "boolean" + }, + "foreverUrlAddr": { + "description": "永久地址", + "type": "string" + }, + "freeVPN": { + "description": "免费vpn链接", + "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" + }, + "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" + }, + "officialAppAddr": { + "description": "官网app地址", + "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" + } + }, + "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" + } + } + } + }, + "webanalytics.AnalyticsDailyStat": { + "type": "object", + "properties": { + "list": { + "description": "当月数据", + "type": "array", + "items": { + "$ref": "#/definitions/webanalytics.LastWebStatInfo" + } + } + } + }, + "webanalytics.AnalyticsStat": { + "type": "object", + "properties": { + "todayList": { + "description": "当日数据", + "type": "array", + "items": { + "$ref": "#/definitions/webanalytics.LastWebStatInfo" + } + }, + "weekList": { + "description": "环比数据", + "type": "array", + "items": { + "$ref": "#/definitions/webanalytics.LastWebStatInfo" + } + }, + "yestodayList": { + "description": "同比数据", + "type": "array", + "items": { + "$ref": "#/definitions/webanalytics.LastWebStatInfo" + } + } + } + }, + "webanalytics.LastWebStatInfo": { + "type": "object", + "properties": { + "activeUsers": { + "description": "最近半小时活跃数", + "type": "integer" + }, + "appId": { + "description": "appId", + "type": "integer" + }, + "appName": { + "description": "APP名称", + "type": "string" + }, + "leadAmount": { + "description": "日导量充值", + "type": "number" + }, + "leadNewCount": { + "description": "日导量新增", + "type": "number" + }, + "sumDate": { + "description": "时间半小时", + "type": "string" + }, + "yestodayActiveUsers": { + "description": "活跃用户数", + "type": "integer" + }, + "yestodayNewUsers": { + "description": "新增数量", + "type": "integer" + }, + "yestodayPV": { + "description": "页面浏览量 (PV)", + "type": "integer" + }, + "yestodaySessions": { + "description": "会话数", + "type": "integer" + }, + "yestodayUV": { + "description": "独立用户数 (UV)", + "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/web/docs/swagger.yaml b/web/docs/swagger.yaml new file mode 100644 index 0000000..16edfa4 --- /dev/null +++ b/web/docs/swagger.yaml @@ -0,0 +1,9405 @@ +basePath: / +definitions: + announcementmod.Announcement: + properties: + content: + description: 内容 + type: string + createdAt: + description: 创建时间 + type: string + id: + type: integer + sort: + description: 排序,越大越靠前 + type: integer + status: + description: 是否启用 + type: boolean + title: + description: 标题 + type: string + updatedAt: + description: 更新时间 + type: string + type: object + cointransmod.CoinTrans: + properties: + amount: + description: 本次交易金币数(正整数) + type: integer + balanceAfter: + description: 交易后余额 + type: integer + balanceBefore: + description: 交易前余额 + type: integer + createdAt: + description: 交易时间 + type: string + flowDir: + allOf: + - $ref: '#/definitions/cointransmod.FlowDir' + description: 收支方向:1收入 -1支出 + id: + description: 唯一ID + type: integer + orderNo: + description: 关联订单号(可为空) + type: string + relatedId: + description: 关联内容ID(视频等,可为空) + type: integer + remark: + description: 备注描述 + type: string + tranType: + allOf: + - $ref: '#/definitions/cointransmod.TranType' + description: 交易类型 + userId: + description: 用户ID + type: integer + userName: + description: 用户昵称(快照) + type: string + type: object + cointransmod.FlowDir: + enum: + - 1 + - -1 + type: integer + x-enum-comments: + FlowIn: 收入(充值、赠送) + FlowOut: 支出(消费、解锁) + x-enum-varnames: + - FlowIn + - FlowOut + cointransmod.TranType: + enum: + - 1 + - 2 + - 3 + - 4 + - 5 + type: integer + x-enum-comments: + TranTypeAdminAdjust: 管理员手动调整 + TranTypeCoinRecharge: 金币充值(购买金币) + TranTypeDarkAlley: 历史遗留交易类型(已下线) + TranTypeVideoUnlock: 视频解锁 + TranTypeVipGiftCoin: 购买会员赠送金币 + x-enum-varnames: + - TranTypeCoinRecharge + - TranTypeVipGiftCoin + - TranTypeDarkAlley + - TranTypeVideoUnlock + - TranTypeAdminAdjust + 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 + 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.OperateType: + enum: + - 1 + - 2 + type: integer + x-enum-comments: + OperateTypeComic: 漫剧 + OperateTypePlaylet: 短剧 + x-enum-varnames: + - OperateTypePlaylet + - OperateTypeComic + 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.ContactUsHandleReq: + properties: + ids: + description: 联系我们ID集合 + items: + type: integer + type: array + remark: + description: 处理备注 + type: string + status: + description: 处理状态,0-待处理、1-已处理 + type: integer + required: + - ids + - status + type: object + proto.ContactUsHandleResp: + properties: + matchedCount: + description: 命中数量 + type: integer + modifiedCount: + description: 修改数量 + type: integer + type: object + proto.ContactUsListPageResp: + properties: + list: + description: 集合列表 + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总共数量 + type: integer + type: object + proto.ContactUsListResp: + properties: + attachments: + description: 附件地址 + items: + type: string + type: array + content: + description: 内容 + type: string + createdAt: + description: 创建时间 + type: string + email: + description: 邮箱 + type: string + id: + description: ID + type: integer + remark: + description: 处理备注 + type: string + status: + description: 处理状态,0-待处理、1-已处理 + type: integer + title: + description: 标题 + type: string + type: + description: 问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他 + type: integer + updatedAt: + description: 更新时间 + type: string + userId: + description: 用户ID + type: integer + userName: + description: 用户名称 + type: string + type: object + proto.FeedbackHandleReq: + properties: + ids: + description: 意见反馈ID集合 + items: + type: integer + type: array + remark: + description: 处理备注 + type: string + status: + description: 处理状态,0-待处理、1-已处理 + type: integer + required: + - ids + - status + type: object + proto.FeedbackHandleResp: + properties: + matchedCount: + description: 命中数量 + type: integer + modifiedCount: + description: 修改数量 + type: integer + type: object + proto.FeedbackListPageResp: + properties: + list: + description: 集合列表 + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总共数量 + type: integer + type: object + proto.FeedbackListResp: + properties: + contact: + description: 联系方式 + type: string + content: + description: 描述内容 + type: string + createdAt: + description: 创建时间 + type: string + id: + description: ID + type: integer + remark: + description: 处理备注 + type: string + screenshots: + description: 问题截图 + items: + type: string + type: array + status: + description: 处理状态,0-待处理、1-已处理 + type: integer + type: + description: 问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他 + type: integer + updatedAt: + description: 更新时间 + type: string + userId: + description: 用户ID + type: integer + userName: + description: 用户名称 + type: string + 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.HomeConfigAddReq: + properties: + desc: + description: 描述 + type: string + name: + description: 名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + sortType: + description: 排序字段 1-推荐 2-热播 3-最近更新 4-新片预告 + type: integer + status: + description: 上下架状态 true-上架 false-下架 + type: boolean + type: + description: 类型 1-短剧 2-视频 3-题材 + type: integer + required: + - name + - sortType + - status + - type + type: object + proto.HomeConfigAddResp: + properties: + id: + description: ID + type: integer + type: object + proto.HomeConfigDelReq: + properties: + id: + description: ID + type: integer + required: + - id + type: object + proto.HomeConfigListPageResp: + properties: + list: + description: 集合列表 + pageNum: + description: 页数 + type: integer + pageSize: + description: 每页数 + type: integer + totalCount: + description: 总共数量 + type: integer + type: object + proto.HomeConfigUpdateReq: + properties: + desc: + description: 描述 + type: string + id: + description: ID + type: integer + name: + description: 名称 + type: string + rank: + description: 排序,越大越靠前 + type: integer + sortType: + description: 排序字段 1-推荐 2-热播 3-最近更新 4-新片预告 + type: integer + status: + description: 上下架状态 true-上架 false-下架 + type: boolean + type: + description: 类型 1-短剧 2-视频 3-题材 + type: integer + required: + - id + type: object + proto.PlayletAddReq: + properties: + anchor: + type: string + banner: + type: string + contentType: + $ref: '#/definitions/playletmod.ContentType' + cover: + type: string + desc: + type: string + horizontalCover: + description: 横版封面 + type: string + isFeatured: + description: 是否爆剧(true-是,false-否) + type: boolean + isRecommend: + type: boolean + onlineAt: + description: 上架时间,上架时不传则按当前时间 + type: string + operateType: + allOf: + - $ref: '#/definitions/playletmod.OperateType' + description: 运营类型 1-短剧 2-漫剧,不传默认短剧 + payType: + allOf: + - $ref: '#/definitions/playletmod.PayType' + description: 支付方式 0-免费 1-金币 2-VIP + playName: + description: 剧名 + type: string + preview: + type: string + recommendRank: + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + showOnBanner: + type: boolean + singleCoins: + description: 单集金币(支付方式为1-金币时候生效) + type: integer + slug: + description: slug,不传则按 SEO 关键词或标题自动生成 + type: string + status: + $ref: '#/definitions/playletmod.Status' + tagIds: + items: + type: integer + type: array + themeIds: + items: + type: integer + type: array + title: + type: string + topRank: + type: integer + totalEpisode: + type: integer + required: + - title + type: object + proto.PlayletAddResp: + properties: + 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.PlayletChapterAddReq: + properties: + currentEpisode: + type: integer + hash: + type: string + height: + type: integer + md5: + type: string + mediaSize: + type: integer + mediaSourceId: + type: string + mediaTime: + type: integer + payType: + $ref: '#/definitions/playletchaptermod.PayType' + playletId: + type: integer + ratio: + type: number + singleCoins: + type: integer + title: + type: string + videoUrl: + type: string + width: + type: integer + required: + - currentEpisode + - playletId + type: object + proto.PlayletChapterAddResp: + properties: + 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: + type: integer + hash: + type: string + height: + type: integer + id: + type: integer + md5: + type: string + mediaSize: + type: integer + mediaTime: + type: integer + payType: + $ref: '#/definitions/playletchaptermod.PayType' + ratio: + type: number + singleCoins: + type: integer + title: + type: string + videoUrl: + type: string + width: + type: integer + required: + - id + type: object + proto.PlayletDelReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条删除,传多个表示批量删除 + items: + type: integer + type: array + 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.PlayletListSortType: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + type: integer + x-enum-comments: + PlayletListSortCountBrowse: 浏览数 + PlayletListSortCountCollect: 收藏数 + PlayletListSortCountLike: 点赞数 + PlayletListSortCreatedAt: 创建时间(默认) + PlayletListSortOnlineAt: 上架时间 + PlayletListSortRecommendRank: 推荐排序 + PlayletListSortUpdatedAt: 修改时间 + x-enum-varnames: + - PlayletListSortCreatedAt + - PlayletListSortRecommendRank + - PlayletListSortUpdatedAt + - PlayletListSortCountLike + - PlayletListSortCountBrowse + - PlayletListSortCountCollect + - PlayletListSortOnlineAt + proto.PlayletOnlineAtFillResp: + properties: + updatedCount: + description: 本次回填数量 + type: integer + type: object + proto.PlayletOperateTypeSetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + operateType: + allOf: + - $ref: '#/definitions/playletmod.OperateType' + description: 运营类型 1-短剧 2-漫剧 + 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.PlayletResourceJHListItem: + properties: + actors: + description: 演员集合 + items: + $ref: '#/definitions/proto.PlayletResourceMediaActor' + type: array + anchor: + description: 作者 + type: string + create_time: + description: 创建时间 + type: integer + data_version: + description: 数据版本 + type: integer + horizontal_cover: + description: 横版封面 + type: string + horizontalCover: + 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: 更新状态 + type: integer + update_time: + description: 更新时间 + type: integer + vertical_cover: + description: 竖版封面 + type: string + verticalCover: + description: 兼容驼峰 + type: string + vlogger_id: + type: integer + type: object + proto.PlayletResourceJHListResp: + properties: + list: + items: + $ref: '#/definitions/proto.PlayletResourceJHListItem' + type: array + total: + 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.PlayletResourceMediaActor: + properties: + avatar: + description: 头像 + type: string + gender: + description: 性别 + type: integer + name: + description: 名称 + type: string + type: object + proto.PlayletResourceUseReq: + properties: + ids: + description: 媒资中心资源ID集合-多个用逗号隔开,请求示例[28830,28829,28828,28827,28826,28825] + items: + type: integer + type: array + type: object + proto.PlayletSalesItem: + properties: + collectCount: + description: 期间新增且当前仍在的短剧收藏数 + type: integer + cover: + type: string + id: + type: integer + likeCount: + description: 期间新增且当前仍在的短剧点赞数 + type: integer + playName: + type: string + slug: + type: string + status: + $ref: '#/definitions/playletmod.Status' + title: + type: string + unlockCoins: + description: 金币解锁消耗 + type: integer + unlockCount: + description: 金币解锁次数 + type: integer + type: object + proto.PlayletSalesResp: + properties: + endTime: + description: 实际统计结束时间(北京时间) + type: string + list: + items: + $ref: '#/definitions/proto.PlayletSalesItem' + type: array + startTime: + description: 实际统计开始时间(北京时间) + type: string + type: object + proto.PlayletSalesSortType: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + PlayletSalesSortCollect: 收藏数 + PlayletSalesSortLike: 点赞数 + PlayletSalesSortUnlock: 金币解锁次数 + x-enum-varnames: + - PlayletSalesSortUnlock + - PlayletSalesSortLike + - PlayletSalesSortCollect + proto.PlayletSlugSyncReq: + properties: + maxSize: + description: 本次最多处理的空 slug 记录数,默认500,最大2000 + type: integer + type: object + proto.PlayletSlugSyncResp: + properties: + hasMore: + description: 是否还有未处理的历史数据 + type: boolean + playletUpdated: + description: 本次补齐的短剧数 + type: integer + tagUpdated: + description: 本次补齐的标签数 + type: integer + themeUpdated: + description: 本次补齐的题材数 + type: integer + type: object + proto.PlayletStatusSetReq: + properties: + ids: + description: 短剧ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + onlineAt: + description: 上架时间;传入则使用该时间,未传且从未设置过则用当前时间 + type: string + 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相关信息 + slug: + description: slug,不传则按 SEO 关键词或名称自动生成 + type: string + 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相关信息 + slug: + description: slug,历史数据为空时更新会自动生成 + type: string + status: + type: boolean + required: + - id + type: object + proto.PlayletThemeAddReq: + properties: + icon: + type: string + isRecommend: + description: 是否推荐 + type: boolean + name: + type: string + rank: + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug,不传则按 SEO 关键词或名称自动生成 + type: string + status: + type: boolean + required: + - name + type: object + proto.PlayletThemeAddResp: + properties: + id: + type: integer + type: object + proto.PlayletThemeDelReq: + properties: + 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.PlayletThemeRecommendSetReq: + properties: + ids: + description: 题材ID集合,传1个表示单条设置,传多个表示批量设置 + items: + type: integer + type: array + isRecommend: + description: 是否推荐(true-推荐,false-取消推荐) + type: boolean + 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: + type: string + id: + type: integer + isRecommend: + description: 是否推荐 + type: boolean + name: + type: string + rank: + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug,历史数据为空时更新会自动生成 + type: string + status: + type: boolean + required: + - id + type: object + proto.PlayletUpdateReq: + properties: + anchor: + type: string + banner: + type: string + contentType: + $ref: '#/definitions/playletmod.ContentType' + cover: + type: string + desc: + type: string + horizontalCover: + description: 横版封面 + type: string + id: + type: integer + isFeatured: + description: 是否爆剧(true-是,false-否) + type: boolean + isRecommend: + type: boolean + onlineAt: + description: 上架时间,新上架时不传则按当前时间 + type: string + operateType: + allOf: + - $ref: '#/definitions/playletmod.OperateType' + description: 运营类型 1-短剧 2-漫剧 + payType: + allOf: + - $ref: '#/definitions/playletmod.PayType' + description: 支付方式 0-免费 1-金币 2-VIP + playName: + description: 剧名 + type: string + preview: + type: string + recommendRank: + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + showOnBanner: + type: boolean + singleCoins: + description: 单集金币(支付方式为1-金币时候生效) + type: integer + slug: + description: slug,历史数据为空时更新会自动生成 + type: string + status: + $ref: '#/definitions/playletmod.Status' + tagIds: + items: + type: integer + type: array + themeIds: + items: + type: integer + type: array + title: + type: string + topRank: + type: integer + totalEpisode: + 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.VideoSlugSyncReq: + properties: + maxSize: + description: 本次最多处理的空 slug 记录数,默认500,最大2000 + type: integer + type: object + proto.VideoSlugSyncResp: + properties: + categoryUpdated: + description: 本次补齐的分类数 + type: integer + createrUpdated: + description: 本次补齐的作者数 + type: integer + hasMore: + description: 是否还有未处理的历史数据 + type: boolean + tagUpdated: + description: 本次补齐的标签数 + type: integer + topicUpdated: + description: 本次补齐的题材数 + type: integer + videoUpdated: + description: 本次补齐的视频数 + type: integer + type: object + pubdef.LoginType: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + LoginTypeAccount: 账号登陆 + LoginTypeDevice: 设备登陆 + LoginTypeEmail: 邮箱登陆 + x-enum-varnames: + - LoginTypeDevice + - LoginTypeAccount + - LoginTypeEmail + 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 + pubdef.UserType: + enum: + - 0 + - 1 + - 0 + type: integer + x-enum-comments: + UserTypeGuest: 游客用户 + UserTypeNormal: 普通用户(已注册) + x-enum-varnames: + - UserTypeGuest + - UserTypeNormal + - UserTypeNone + pubdef.VipOrderStatus: + enum: + - 1 + - 2 + - 3 + type: integer + x-enum-comments: + VipOrderFailed: 支付失败 + VipOrderPending: 待支付 + VipOrderSuccess: 支付成功 + x-enum-varnames: + - VipOrderPending + - VipOrderSuccess + - VipOrderFailed + pubdef.VipType: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + VipTypeMonth: 月卡 + VipTypeNone: 没卡 + VipTypeYear: 年卡 + x-enum-varnames: + - VipTypeNone + - VipTypeMonth + - VipTypeYear + rechargepackagemod.PackageType: + enum: + - 1 + - 2 + type: integer + x-enum-comments: + PackageTypeCoin: 金币充值 + PackageTypeVip: 会员畅享 + x-enum-varnames: + - PackageTypeVip + - PackageTypeCoin + rechargepackagemod.Status: + enum: + - 1 + - 2 + type: integer + x-enum-comments: + StatusDisabled: 停用 + StatusEnabled: 启用 + x-enum-varnames: + - StatusEnabled + - StatusDisabled + 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_announcement.AddAnnouncementReq: + properties: + content: + description: 内容 + type: string + sort: + description: 排序,越大越靠前 + type: integer + status: + description: 是否启用 + type: boolean + title: + description: 标题 + type: string + required: + - title + type: object + srv_announcement.DelAnnouncementReq: + properties: + id: + type: integer + required: + - id + type: object + srv_announcement.GetAnnouncementListReq: + properties: + pageNum: + type: integer + pageSize: + type: integer + status: + description: 是否启用 + type: boolean + title: + description: 标题,模糊搜索 + type: string + type: object + srv_announcement.GetAnnouncementListResp: + properties: + list: + items: + $ref: '#/definitions/announcementmod.Announcement' + type: array + total: + type: integer + type: object + srv_announcement.UpdateAnnouncementParam: + properties: + content: + type: string + sort: + type: integer + status: + type: boolean + title: + type: string + type: object + srv_announcement.UpdateAnnouncementReq: + properties: + id: + type: integer + param: + $ref: '#/definitions/srv_announcement.UpdateAnnouncementParam' + required: + - id + type: object + srv_cointrans.CoinTransReq: + properties: + endTime: + description: 结束时间 2006-01-02 15:04:05 + type: string + flowDir: + allOf: + - $ref: '#/definitions/cointransmod.FlowDir' + description: 收支方向:1收入 -1支出 + pageNum: + type: integer + pageSize: + type: integer + startTime: + description: 开始时间 2006-01-02 15:04:05 + type: string + tranType: + allOf: + - $ref: '#/definitions/cointransmod.TranType' + description: 交易类型 + userId: + description: 用户ID + type: integer + type: object + srv_cointrans.CoinTransResp: + properties: + list: + items: + $ref: '#/definitions/cointransmod.CoinTrans' + type: array + total: + type: integer + type: object + srv_commend.CommendReplay: + properties: + objectId: + description: 帖子ID + type: integer + parentsId: + description: 一级评论ID + type: integer + replyCommentId: + 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: + description: 对象类型 10-短剧 11-短剧剧集 20-视频 + type: integer + pageNum: + type: integer + pageSize: + type: integer + reviewStatus: + type: integer + text: + description: 评论内容 + type: string + userId: + description: 评论用户ID + type: integer + 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: + countLike: + description: 评论点赞数 + type: integer + 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: 对象类型 10-短剧 11-短剧剧集 20-视频 + type: integer + parentsId: + description: 上级评论ID + type: integer + replyCommentId: + 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 + userAvatar: + description: 评论用户头像 + type: string + userId: + description: 评论用户ID + type: integer + userName: + description: 用户名称 + type: string + 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_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 + payCenter.RechargeCallbackResp: + properties: + code: + type: integer + mercId: + type: string + oid: + type: string + payMoney: + type: string + sign: + type: string + tradeNo: + type: string + required: + - code + - mercId + - oid + - payMoney + - sign + - tradeNo + type: object + srv_rechargepackage.RechargePackageAddReq: + properties: + baseCoin: + description: 基础金币数,会员套餐传0 + type: integer + durationDays: + description: 会员有效天数,金币套餐传0 + type: integer + giftCoin: + description: 赠送金币数 + type: integer + name: + description: 展示标题 + type: string + originalPrice: + description: 原价,单位分 + type: integer + packageType: + allOf: + - $ref: '#/definitions/rechargepackagemod.PackageType' + description: 套餐类型:1-会员畅享 2-金币充值 + price: + description: 售价,单位分 + type: integer + remark: + description: 后台备注 + type: string + sort: + description: 排序,越小越靠前 + type: integer + status: + allOf: + - $ref: '#/definitions/rechargepackagemod.Status' + description: 状态:1-启用 2-停用,默认1 + required: + - name + - packageType + - price + type: object + srv_rechargepackage.RechargePackageDelReq: + properties: + id: + description: 套餐ID + type: integer + required: + - id + type: object + srv_rechargepackage.RechargePackageItem: + properties: + baseCoin: + description: 基础金币数 + type: integer + createdAt: + description: 创建时间 + type: string + durationDays: + description: 会员有效天数 + type: integer + durationHours: + description: 会员有效小时数,由durationDays计算 + type: integer + giftCoin: + description: 赠送金币数 + type: integer + id: + description: 套餐ID + type: integer + name: + description: 展示标题 + type: string + originalPrice: + description: 原价,单位分 + type: integer + packageType: + allOf: + - $ref: '#/definitions/rechargepackagemod.PackageType' + description: 套餐类型:1-会员畅享 2-金币充值 + price: + description: 售价,单位分 + type: integer + remark: + description: 后台备注 + type: string + sort: + description: 排序,越小越靠前 + type: integer + status: + allOf: + - $ref: '#/definitions/rechargepackagemod.Status' + description: 状态:1-启用 2-停用 + subTitle: + description: 展示副标题 + type: string + totalCoin: + description: 到账总金币数,由baseCoin+giftCoin计算 + type: integer + updatedAt: + description: 更新时间 + type: string + type: object + srv_rechargepackage.RechargePackageListReq: + properties: + id: + description: 套餐ID + type: integer + name: + description: 展示标题,模糊搜索 + type: string + packageType: + allOf: + - $ref: '#/definitions/rechargepackagemod.PackageType' + description: 套餐类型:1-会员畅享 2-金币充值 + pageNum: + type: integer + pageSize: + type: integer + status: + allOf: + - $ref: '#/definitions/rechargepackagemod.Status' + description: 状态:1-启用 2-停用 + type: object + srv_rechargepackage.RechargePackageListResp: + properties: + list: + description: 当前页列表 + items: + $ref: '#/definitions/srv_rechargepackage.RechargePackageItem' + type: array + total: + description: 总数量 + type: integer + type: object + srv_rechargepackage.RechargePackageStatusSetReq: + properties: + ids: + description: 套餐ID集合 + items: + type: integer + type: array + status: + allOf: + - $ref: '#/definitions/rechargepackagemod.Status' + description: 状态:1-启用 2-停用 + type: object + srv_rechargepackage.RechargePackageUpdateReq: + properties: + baseCoin: + description: 基础金币数 + type: integer + durationDays: + description: 会员有效天数 + type: integer + giftCoin: + description: 赠送金币数 + type: integer + id: + description: 套餐ID + type: integer + name: + description: 展示标题 + type: string + originalPrice: + description: 原价,单位分 + type: integer + packageType: + allOf: + - $ref: '#/definitions/rechargepackagemod.PackageType' + description: 套餐类型:1-会员畅享 2-金币充值 + price: + description: 售价,单位分 + type: integer + remark: + description: 后台备注 + type: string + sort: + description: 排序 + type: integer + status: + allOf: + - $ref: '#/definitions/rechargepackagemod.Status' + description: 状态:1-启用 2-停用 + required: + - id + type: object + srv_rechargepackage.VipPackageOption: + properties: + id: + description: 套餐ID + type: integer + name: + description: 套餐名称 + type: string + 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 + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + 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.GiftCoinReq: + properties: + coin: + description: 赠送金币数(正数=增加,负数=扣除) + type: integer + remark: + description: 备注(记录在流水表) + type: string + userId: + description: 目标用户ID + type: integer + required: + - coin + - userId + type: object + srv_user.UpdateVipReq: + properties: + packageId: + description: 会员畅享套餐ID + type: integer + userId: + description: 用户ID + type: integer + required: + - packageId + - userId + type: object + srv_user.UserListReq: + properties: + accountNo: + description: 账号(精确) + type: string + districtCode: + description: 渠道码 + type: string + email: + description: 邮箱(精确) + type: string + id: + description: 用户ID + type: integer + nickName: + description: 昵称(模糊) + type: string + pageNum: + type: integer + pageSize: + type: integer + status: + allOf: + - $ref: '#/definitions/pubdef.UserStatus' + description: 状态 + type: + allOf: + - $ref: '#/definitions/pubdef.UserType' + 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: + id: + description: 分类ID + type: integer + name: + description: 分类名称 + type: string + pageNum: + type: integer + pageSize: + type: integer + type: object + srv_videocategory.VideoCategoryResp: + properties: + list: + items: + $ref: '#/definitions/videocategorymod.VideoCategory' + type: array + total: + type: integer + type: object + srv_videocategory.VideoCategoryUpdate: + properties: + appId: + description: appId,唯一id + type: integer + contentType: + description: '内容:0: 默认 1:首页 2:今日' + type: integer + desc: + description: 描述 + type: string + indexNow: + type: integer + isReIndexNow: + description: 是否重新提交索引 + type: boolean + name: + description: 名称 + type: string + rank: + description: 排序 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + sortType: + description: 排序方式 0:上线时间 1:观看次数 2排序 + type: integer + status: + description: 上架状态 + type: boolean + title: + description: APP显示 + type: string + updatedAt: + type: string + type: object + srv_videocategory.VideoCategoryUpdateReq: + properties: + id: + type: integer + param: + allOf: + - $ref: '#/definitions/srv_videocategory.VideoCategoryUpdate' + description: appId,唯一id + required: + - id + type: object + srv_videocreater.VideoCreaterDelReq: + properties: + id: + type: integer + required: + - id + type: object + srv_videocreater.VideoCreaterReq: + properties: + id: + description: 分类ID + type: integer + name: + description: 分类名称 + type: string + 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 + indexNow: + type: integer + isReIndexNow: + description: 是否重新提交索引 + type: boolean + 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 + type: object + srv_videocreater.VideoCreaterUpdateReq: + properties: + id: + type: integer + param: + allOf: + - $ref: '#/definitions/srv_videocreater.VideoCreaterUpdate' + description: appId,唯一id + required: + - id + 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 + slug: + description: slug + type: string + 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.SetFakeWatchTimesReq: + properties: + ids: + description: 视频ID集合,支持单个或批量 + items: + type: integer + type: array + watchTimesMax: + description: 假观看次数随机最大值 + type: integer + watchTimesMin: + description: 假观看次数随机最小值 + type: integer + type: object + srv_videoinfo.VideoInfoDelReq: + properties: + id: + type: integer + required: + - id + type: object + srv_videoinfo.VideoInfoReq: + properties: + categoryId: + description: 分类Id + type: integer + creatorId: + description: 作者Id + type: integer + creatorName: + description: 作者名称 + type: string + endTime: + description: 创建时间-截止 + type: string + id: + description: Id + type: integer + isH265: + description: 是否支持 H265 + type: boolean + isHot: + description: 是否热门 + 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=播放次数 + type: integer + startTime: + description: 创建时间-开始 + type: string + status: + allOf: + - $ref: '#/definitions/videoinformationmod.InformationSatus' + description: 帖子上架状态 + tag: + description: 标签 + type: integer + title: + description: 标题 + type: string + topicId: + description: 专题Id + type: integer + 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: + addedTime: + type: string + categoryIds: + description: 分类Id + items: + type: integer + type: array + content: + type: string + 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 + isTop: + description: 是否置顶 + type: boolean + 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/videoinformationmod.InformationSatus' + description: 帖子上架状态 + tags: + description: 标签 + items: + type: integer + type: array + title: + description: 帖子标题 + type: string + topicIds: + description: 专题ID + items: + type: integer + type: array + 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_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.IpStatResp: + 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 + indexNow: + type: integer + isReIndexNow: + description: 是否重新提交索引 + type: boolean + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + sort: + description: 排序 + type: integer + status: + description: 上架状态 + type: boolean + title: + description: 帖子标题 + type: string + updatedAt: + type: string + type: object + srv_videotag.VideoTagInfoUpdateReq: + properties: + id: + type: integer + param: + allOf: + - $ref: '#/definitions/srv_videotag.VideoTagInfoUpdate' + description: appId,唯一id + required: + - id + type: object + srv_videotopic.VideoTopicDelReq: + properties: + id: + type: integer + required: + - id + type: object + srv_videotopic.VideoTopicReq: + properties: + pageNum: + type: integer + pageSize: + type: integer + videoCategoryId: + type: integer + type: object + srv_videotopic.VideoTopicResp: + properties: + list: + items: + $ref: '#/definitions/videotopicmod.VideoTopicModel' + type: array + total: + type: integer + type: object + srv_videotopic.VideoTopicUpdate: + properties: + desc: + description: 描述 + type: string + image: + description: 背景 + type: string + 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 + status: + description: 上架状态 + type: boolean + title: + description: APP显示 + type: string + updatedAt: + type: string + videoCategoryId: + description: 视频分类id + type: integer + videoTopicType: + allOf: + - $ref: '#/definitions/videoinformationmod.MediaType' + description: 视频类型 + type: object + srv_videotopic.VideoTopicUpdateReq: + properties: + id: + type: integer + param: + allOf: + - $ref: '#/definitions/srv_videotopic.VideoTopicUpdate' + description: appId,唯一id + required: + - id + type: object + srv_viporder.RechargeSummaryResp: + properties: + coinAmount: + description: 金币成功订单金额(分) + type: integer + coinOrderCount: + description: 金币成功订单数 + type: integer + endTime: + description: 实际统计结束时间(北京时间) + type: string + orderCount: + description: 成功订单数 + type: integer + startTime: + description: 实际统计开始时间(北京时间) + type: string + totalAmount: + description: 成功订单金额合计(分) + type: integer + userCount: + description: 成功充值用户数(去重) + type: integer + vipAmount: + description: 会员成功订单金额(分) + type: integer + vipOrderCount: + description: 会员成功订单数 + type: integer + type: object + srv_viporder.SyncDistrictCodeReq: + properties: + maxSize: + description: 本次最多扫描的空渠道码订单数,默认500,最大2000 + type: integer + type: object + srv_viporder.SyncDistrictCodeResp: + properties: + hasMore: + description: 是否还有未处理的历史订单 + type: boolean + orderUpdated: + description: 成功回填的订单数 + type: integer + scanned: + description: 本次扫描的空渠道码订单数 + type: integer + userSkipped: + description: 用户本身没有渠道码 + type: integer + userUpdated: + description: 成功回填的用户数 + type: integer + type: object + srv_viporder.VipOrderReq: + properties: + districtCode: + description: 渠道码 + type: string + endTime: + description: 创建时间-结束 + type: string + orderType: + allOf: + - $ref: '#/definitions/vipordermod.OrderType' + description: 订单类型:1会员 2金币 + pageNum: + type: integer + pageSize: + type: integer + startTime: + description: 创建时间-开始 + type: string + status: + allOf: + - $ref: '#/definitions/pubdef.VipOrderStatus' + description: 订单状态 + tradeNo: + description: 订单号 + type: string + userId: + description: 用户ID + type: integer + type: object + srv_viporder.VipOrderResp: + properties: + list: + items: + $ref: '#/definitions/vipordermod.VipOrder' + type: array + total: + 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 + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + 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 + deviceBrand: + description: 设备品牌 + type: string + deviceModel: + description: 设备型号 + type: string + districtCode: + description: 商区码 + type: string + email: + 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 + isDirect: + description: 是否是直推 true:是直推用户 + type: boolean + 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 + privacyMode: + description: 隐私模式:true-开启,false-关闭(默认) + type: boolean + 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 + sessionId: + description: 会话ID + 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 + systemName: + description: 系统名称 + type: string + systemVersion: + description: 系统版本 + type: string + type: + allOf: + - $ref: '#/definitions/pubdef.UserType' + description: 类型 + updatedAt: + description: 更新时间 + type: string + vipType: + allOf: + - $ref: '#/definitions/pubdef.VipType' + description: Vip类型 + 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 + deviceBrand: + description: 设备品牌 + type: string + deviceModel: + 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 + isDirect: + description: 是否是直推 true:是直推用户 + type: boolean + 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 + sessionId: + description: 会话ID + 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 + systemName: + description: 系统名称 + type: string + systemVersion: + description: 系统版本 + type: string + type: + allOf: + - $ref: '#/definitions/pubdef.UserType' + description: 类型 + updatedAt: + description: 更新时间 + type: string + vipType: + allOf: + - $ref: '#/definitions/pubdef.VipType' + description: Vip类型 + type: object + videocategorymod.VideoCategory: + properties: + contentType: + description: '内容:0: 默认 1:首页 2:今日 3:合集' + type: integer + createdAt: + description: 创建时间 + type: string + desc: + description: 描述 + type: string + 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: + description: 排序方式 0:上线时间 1:观看次数 2排序 + type: integer + status: + description: 上架状态 + type: boolean + title: + description: APP显示 + type: string + updatedAt: + description: 更新时间 + type: string + type: object + videocreatermod.VideoCreaterInfo: + properties: + createdAt: + description: 创建时间 + type: string + id: + type: integer + indexNow: + description: 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + type: integer + 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 + type: object + videoinformationmod.FakeFlow: + properties: + watchTimes: + description: 假观看次数 + type: integer + type: object + videoinformationmod.Flow: + properties: + collects: + description: 收藏数 + type: integer + comments: + description: 评论条数 + type: integer + likes: + description: 点赞数 + type: integer + monthWatchs: + description: 本月观看次数 + type: integer + showTimes: + description: 展示次数 + type: integer + todayWatchs: + description: 当日观看次数 + type: integer + watchTimes: + description: 观看次数 + type: integer + weekWatchs: + 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.InformationSatus: + enum: + - 0 + - 1 + - 2 + type: integer + x-enum-comments: + InformationSatusVerifyCancel: 下架 + InformationSatusVerifyOK: 上架 + InformationSatusWaiting: 初始化 + x-enum-varnames: + - InformationSatusWaiting + - InformationSatusVerifyOK + - InformationSatusVerifyCancel + videoinformationmod.MediaType: + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-comments: + MediaType_Advitise: 广告 + MediaType_Collection: 合集 + MediaType_Video: 视频(帖子) + MediaType_VideoV2: 视频 + x-enum-varnames: + - MediaType_Video + - MediaType_Collection + - MediaType_Advitise + - MediaType_VideoV2 + 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: H265 视频地址 + type: string + height: + description: 分辨率高(视频才会存在) + type: integer + preMp4FileNameFs: + description: 预览MP4文件 + type: string + taskId: + description: 视频ID(视频才会存在) + type: string + unencryptedPreMp4: + description: 不加密的预览MP4 + type: string + url: + description: 视频URl + type: string + videoTime: + description: 影片长度 + type: integer + width: + description: 分辨率宽(视频才会存在) + type: integer + type: object + videoinformationmod.VideoInformation: + properties: + addedTime: + description: 帖子上架时间 + type: string + audioFile: + description: 音频文件路径 + type: string + categoryIds: + description: 分类Id (帖子用) + items: + type: integer + type: array + content: + description: 富文本 + type: string + 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/videoinformationmod.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 + isTop: + description: 是否置顶 + type: boolean + 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/videoinformationmod.Flow' + description: 真实流量 + 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/videoinformationmod.InformationSatus' + description: 帖子上架状态 + syncId: + description: 同步ID + type: string + tags: + description: 标签 + items: + type: integer + type: array + title: + description: 帖子标题 + type: string + topicIds: + description: 专题ID(视频用) + items: + type: integer + type: array + 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 + indexNow: + description: 是否需要提交 0:代表提交 1:表示已提交 运营通过接口可以修改 + type: integer + seoInfo: + allOf: + - $ref: '#/definitions/pubdef.SeoValue' + description: Seo相关信息 + slug: + description: slug + type: string + sort: + description: 排序 + type: integer + status: + description: 上架状态 + type: boolean + title: + description: 帖子标题 + type: string + updatedAt: + description: 更新时间 + type: string + watchTimes: + description: 预计算:该标签下所有视频的累计播放量 + type: integer + type: object + videotopicmod.VideoTopicModel: + properties: + createdAt: + description: 创建时间 + type: string + desc: + description: 描述 + type: string + id: + description: Id + type: integer + image: + description: 背景 + type: string + 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 + status: + description: 上架状态 + type: boolean + title: + description: APP显示 + type: string + updatedAt: + description: 更新时间 + type: string + videoCategoryId: + description: 视频分类id + type: integer + videoTopicType: + allOf: + - $ref: '#/definitions/videoinformationmod.MediaType' + description: 视频类型 + works: + description: 作品数 + type: integer + type: object + vipordermod.OrderType: + enum: + - 1 + - 2 + type: integer + x-enum-comments: + OrderTypeCoin: 金币购买 + OrderTypeVip: 会员购买 + x-enum-varnames: + - OrderTypeVip + - OrderTypeCoin + vipordermod.VipOrder: + properties: + amount: + description: 支付金额(分) + type: integer + cardId: + description: ── VIP 专用 ── + type: integer + cardName: + description: 套餐名快照 + type: string + coin: + description: 到账金币数(快照) + type: integer + coinCfgId: + description: ── 金币专用 ── + type: integer + createdAt: + description: 创建时间 + type: string + districtCode: + description: 下单时用户渠道码 + type: string + finishedAt: + description: 完成时间 + type: string + id: + description: 唯一ID + type: integer + ipAddr: + description: 客户端 IP + type: string + oid: + description: 第三方订单号 + type: string + orderType: + allOf: + - $ref: '#/definitions/vipordermod.OrderType' + description: 订单类型:1会员 2金币 + payChannel: + description: 支付渠道 + type: string + payMode: + description: 支付方式:overage/alipay/wechat + type: string + payRate: + description: 费率 + type: string + payUrl: + description: 支付链接 + type: string + payUrlMode: + description: 链接打开方式 + type: string + status: + allOf: + - $ref: '#/definitions/pubdef.VipOrderStatus' + description: 订单状态 + sysType: + description: ios/android + type: string + tradeNo: + description: 本地订单号 + type: string + updatedAt: + description: 更新时间 + type: string + userId: + description: 用户ID + 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 + 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: + actressBackGround: + description: 女优通用背景图 + type: string + advitiesTopicNum: + description: 广告间隔 + type: integer + appAddr: + description: APP下载地址(落地页) + type: string + channelCode: + description: 渠道码 + type: string + customerServiceHost: + 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 + id: + type: integer + imgAppCdn: + description: 图片CDN(app使用) + type: string + imgAppUsCdn: + description: 图片CDN(海外app使用) + type: string + imgWebCdn: + description: 图片CDN(web使用) + type: string + 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 + officialAppAddr: + description: 官网app地址 + 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 + 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: + actressBackGround: + description: 女优通用背景图 + type: string + advitiesTopicNum: + description: 广告间隔 + type: integer + appAddr: + description: APP下载地址(落地页) + type: string + channelCode: + description: 渠道码 + type: string + customerServiceHost: + 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 + enableH265: + description: 是否允许客户端使用 H265 地址播放 + type: boolean + foreverUrlAddr: + description: 永久地址 + type: string + freeVPN: + description: 免费vpn链接 + 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 + 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 + officialAppAddr: + description: 官网app地址 + 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 + 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 + webanalytics.AnalyticsDailyStat: + properties: + list: + description: 当月数据 + items: + $ref: '#/definitions/webanalytics.LastWebStatInfo' + type: array + type: object + webanalytics.AnalyticsStat: + properties: + todayList: + description: 当日数据 + items: + $ref: '#/definitions/webanalytics.LastWebStatInfo' + type: array + weekList: + description: 环比数据 + items: + $ref: '#/definitions/webanalytics.LastWebStatInfo' + type: array + yestodayList: + description: 同比数据 + items: + $ref: '#/definitions/webanalytics.LastWebStatInfo' + type: array + type: object + webanalytics.LastWebStatInfo: + properties: + activeUsers: + description: 最近半小时活跃数 + type: integer + appId: + description: appId + type: integer + appName: + description: APP名称 + type: string + leadAmount: + description: 日导量充值 + type: number + leadNewCount: + description: 日导量新增 + type: number + sumDate: + description: 时间半小时 + type: string + yestodayActiveUsers: + description: 活跃用户数 + type: integer + yestodayNewUsers: + description: 新增数量 + type: integer + yestodayPV: + description: 页面浏览量 (PV) + type: integer + yestodaySessions: + description: 会话数 + type: integer + yestodayUV: + description: 独立用户数 (UV) + type: integer + 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/announcement/add: + post: + consumes: + - application/json + description: 公告-增加 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_announcement.AddAnnouncementReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 公告-增加 + tags: + - 公告 + /api/web/announcement/del: + post: + consumes: + - application/json + description: 公告-删除 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_announcement.DelAnnouncementReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 公告-删除 + tags: + - 公告 + /api/web/announcement/list: + post: + consumes: + - application/json + description: 公告-列表 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_announcement.GetAnnouncementListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_announcement.GetAnnouncementListResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 公告-列表 + tags: + - 公告 + /api/web/announcement/update: + post: + consumes: + - application/json + description: 公告-更新 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_announcement.UpdateAnnouncementReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 公告-更新 + tags: + - 公告 + /api/web/card/order/district/sync: + post: + consumes: + - application/json + description: 将历史订单中空的渠道码,按用户当前 districtCode 回填。可重复调用,hasMore 为 true 时继续请求。 + parameters: + - description: 同步参数 + in: body + name: param + schema: + $ref: '#/definitions/srv_viporder.SyncDistrictCodeReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_viporder.SyncDistrictCodeResp' + "400": + description: 参数错误 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 同步历史订单渠道码 + tags: + - 会员卡管理 + /api/web/card/order/list: + post: + consumes: + - application/json + description: 分页查询充值订单,支持按 OrderType 筛选:1会员 2金币;不传 orderType 则返回全部类型 + parameters: + - description: 查询参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_viporder.VipOrderReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_viporder.VipOrderResp' + "400": + description: 参数错误 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 查询充值订单列表 + tags: + - 会员卡管理 + /api/web/coin/trans: + post: + consumes: + - application/json + description: 分页查询金币收支流水,支持按用户、收支方向、交易类型、时间范围筛选 + parameters: + - description: 查询参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_cointrans.CoinTransReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_cointrans.CoinTransResp' + "400": + description: 参数错误 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 查询金币交易记录 + tags: + - 金币管理 + /api/web/contactus/handle: + post: + consumes: + - application/json + description: 批量更新联系我们处理状态 + parameters: + - description: web端联系我们处理请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.ContactUsHandleReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.ContactUsHandleResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端处理联系我们 -- by 旺鸣 + tags: + - web联系我们相关接口 + /api/web/contactus/list: + get: + consumes: + - application/json + description: 分页查询联系我们列表 + parameters: + - description: 创建结束时间,格式:2026-06-09 00:00:00 + in: query + name: createEndTime + type: string + - description: 创建开始时间,格式:2026-06-08 00:00:00 + in: query + name: createStartTime + type: string + - description: 邮箱 + in: query + name: email + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 处理状态,0-待处理、1-已处理 + in: query + name: status + type: integer + - description: 问题类型,1-订单问题、2-账号问题、3-功能问题、4-体验问题、5-其他 + in: query + name: type + type: integer + - description: 用户ID + in: query + name: userId + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.ContactUsListPageResp' + - properties: + list: + items: + $ref: '#/definitions/proto.ContactUsListResp' + type: array + type: object + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端联系我们列表 -- 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/feedback/handle: + post: + consumes: + - application/json + description: 批量更新意见反馈处理状态 + parameters: + - description: web端意见反馈处理请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.FeedbackHandleReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.FeedbackHandleResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端处理意见反馈 -- by 旺鸣 + tags: + - web意见反馈相关接口 + /api/web/feedback/list: + get: + consumes: + - application/json + description: 分页查询用户意见反馈列表 + parameters: + - description: 创建结束时间,格式:2026-06-09 00:00:00 + in: query + name: createEndTime + type: string + - description: 创建开始时间,格式:2026-06-08 00:00:00 + in: query + name: createStartTime + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: 处理状态,0-待处理、1-已处理 + in: query + name: status + type: integer + - description: 问题类型,1-功能建议、2-Bug反馈、3-体验问题、4-账号问题、5-其他 + in: query + name: type + type: integer + - description: 用户ID + in: query + name: userId + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/proto.FeedbackListPageResp' + - properties: + list: + items: + $ref: '#/definitions/proto.FeedbackListResp' + type: array + type: object + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端意见反馈列表 -- by 旺鸣 + tags: + - web意见反馈相关接口 + /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/home/config/add: + post: + consumes: + - application/json + parameters: + - description: 新增首页配置请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.HomeConfigAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeConfigAddResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 新增首页配置 -- by 王鸣 + tags: + - web首页配置相关接口 + /api/web/home/config/del: + post: + consumes: + - application/json + parameters: + - description: 删除首页配置请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.HomeConfigDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除首页配置 -- by 王鸣 + tags: + - web首页配置相关接口 + /api/web/home/config/list: + get: + consumes: + - application/json + description: 分页查询首页配置 + parameters: + - description: 首页配置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: 排序字段 1-推荐 2-热播 3-最近更新 4-新片预告 + in: query + name: sortType + type: integer + - description: 上下架状态 true-上架 false-下架 + in: query + name: status + type: boolean + - description: 类型 1-短剧 2-视频 3-题材 + in: query + name: type + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.HomeConfigListPageResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 首页配置列表 -- by 王鸣 + tags: + - web首页配置相关接口 + /api/web/home/config/update: + post: + consumes: + - application/json + parameters: + - description: 更新首页配置请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.HomeConfigUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新首页配置 -- by 王鸣 + tags: + - web首页配置相关接口 + /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 + 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端新增短剧 + 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/chapter/add: + post: + consumes: + - application/json + 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端新增章节 + tags: + - web短剧相关接口 + /api/web/playlet/chapter/del: + post: + consumes: + - application/json + description: 根据章节ID集合删除 playlet_chapter 记录,传1个ID表示单条删除,传多个ID表示批量删除;同时清理该剧集的点赞、收藏、解锁记录 + 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 + parameters: + - in: query + name: id + type: integer + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - in: query + name: playletId + type: integer + - 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端章节列表 + tags: + - web短剧相关接口 + /api/web/playlet/chapter/pay-type/set: + post: + consumes: + - application/json + description: 根据章节ID集合批量更新 playlet_chapter 集合 payType 字段,传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端手动单/批量同步短剧剧集 --by 王鸣 + tags: + - web短剧相关接口 + /api/web/playlet/chapter/update: + post: + consumes: + - application/json + 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端更新章节 + tags: + - web短剧相关接口 + /api/web/playlet/del: + post: + consumes: + - application/json + description: 根据短剧ID集合物理删除 playlet 与 playlet_chapter 记录;不删除点赞、收藏、评论、观看记录、解锁记录和支付流水 + parameters: + - description: 单/批量删除短剧请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量物理删除短剧 + 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: 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 + - 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: 是否原创(推荐) + in: query + name: isRecommend + type: boolean + - description: 运营类型 1-短剧 2-漫剧 + enum: + - 1 + - 2 + in: query + name: operateType + type: integer + x-enum-comments: + OperateTypeComic: 漫剧 + OperateTypePlaylet: 短剧 + x-enum-varnames: + - OperateTypePlaylet + - OperateTypeComic + - 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 + - in: query + name: showOnBanner + type: boolean + - description: slug,精确匹配 + in: query + name: slug + type: string + - description: 排序:0创建时间(默认) 1推荐排序 2修改时间 3点赞数 4浏览数 5收藏数 6上架时间,均为降序 + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + in: query + name: sortType + type: integer + x-enum-comments: + PlayletListSortCountBrowse: 浏览数 + PlayletListSortCountCollect: 收藏数 + PlayletListSortCountLike: 点赞数 + PlayletListSortCreatedAt: 创建时间(默认) + PlayletListSortOnlineAt: 上架时间 + PlayletListSortRecommendRank: 推荐排序 + PlayletListSortUpdatedAt: 修改时间 + x-enum-varnames: + - PlayletListSortCreatedAt + - PlayletListSortRecommendRank + - PlayletListSortUpdatedAt + - PlayletListSortCountLike + - PlayletListSortCountBrowse + - PlayletListSortCountCollect + - PlayletListSortOnlineAt + - 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 + - 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端短剧列表 + tags: + - web短剧相关接口 + /api/web/playlet/online-at/fill: + post: + consumes: + - application/json + description: 将已上架且上架时间为空的短剧,上架时间设置为该条修改时间 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletOnlineAtFillResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 临时回填空上架时间 + tags: + - web短剧相关接口 + /api/web/playlet/operate-type/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 operateType 字段,1-短剧 2-漫剧 + parameters: + - description: 单/批量设置短剧运营类型请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletOperateTypeSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置短剧运营类型 + 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端单/批量设置短剧默认支付方式 + 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/sales: + get: + consumes: + - application/json + description: 按北京时间区间汇总金币解锁、短剧点赞、短剧收藏,最多返回前50条 + parameters: + - description: 结束时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339 + in: query + name: endTime + type: string + - description: 短剧ID + in: query + name: id + type: integer + - description: 排序:0金币解锁次数 1点赞数 2收藏数 + enum: + - 0 + - 1 + - 2 + in: query + name: sortType + type: integer + x-enum-comments: + PlayletSalesSortCollect: 收藏数 + PlayletSalesSortLike: 点赞数 + PlayletSalesSortUnlock: 金币解锁次数 + x-enum-varnames: + - PlayletSalesSortUnlock + - PlayletSalesSortLike + - PlayletSalesSortCollect + - description: 开始时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339 + in: query + name: startTime + type: string + - description: 标题模糊匹配 + in: query + name: title + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletSalesResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端短剧销售数据 + tags: + - web短剧相关接口 + /api/web/playlet/slug/sync: + post: + consumes: + - application/json + description: 为题材、标签、分类、短剧中空的 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。 + parameters: + - description: 同步参数 + in: body + name: param + schema: + $ref: '#/definitions/proto.PlayletSlugSyncReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.PlayletSlugSyncResp' + "400": + description: 参数错误 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 同步历史空 slug + 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端新增标签 + 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端删除标签 + 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 + - description: slug,精确匹配 + in: query + name: slug + type: string + - 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端标签列表 + tags: + - web短剧相关接口 + /api/web/playlet/tag/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 tagIds 字段,传1个ID表示单条设置,传多个ID表示批量设置;tagIds + 可设置多个标签,传空数组表示清空标签 + 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端更新标签 + 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端新增题材 + 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端删除题材 + tags: + - web短剧相关接口 + /api/web/playlet/theme/list: + get: + consumes: + - application/json + description: 分页查询短剧题材 + parameters: + - in: query + name: id + type: integer + - description: 是否推荐 + in: query + name: isRecommend + type: boolean + - in: query + name: name + type: string + - in: query + name: pageNum + type: integer + - in: query + name: pageSize + type: integer + - description: slug,精确匹配 + in: query + name: slug + type: string + - 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端题材列表 + tags: + - web短剧相关接口 + /api/web/playlet/theme/recommend/set: + post: + consumes: + - application/json + description: 根据题材ID集合批量更新 playlet_theme 集合 isRecommend 字段 + parameters: + - description: 单/批量设置题材是否推荐请求 + in: body + name: params + required: true + schema: + $ref: '#/definitions/proto.PlayletThemeRecommendSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端单/批量设置题材是否推荐 + tags: + - web短剧相关接口 + /api/web/playlet/theme/set: + post: + consumes: + - application/json + description: 根据短剧ID集合批量更新 playlet 集合 themeIds 字段,传1个ID表示单条设置,传多个ID表示批量设置;themeIds + 可设置多个题材 + 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端更新题材 + tags: + - web短剧相关接口 + /api/web/playlet/update: + post: + consumes: + - application/json + 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端更新短剧 + tags: + - web短剧相关接口 + /api/web/playlet/watermark/reset: + post: + consumes: + - application/json + description: 根据短剧ID集合,将其下全部剧集水印状态重置为未处理,并清空水印地址与 media 任务关联;原片地址不变,等待定时任务重新加水印 + 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批量重置剧集水印 + 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/recharge/package/add: + post: + consumes: + - application/json + parameters: + - description: 新增参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_rechargepackage.RechargePackageAddReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 新增购买套餐 -- by 王鸣 + tags: + - 套餐管理 + /api/web/recharge/package/del: + post: + consumes: + - application/json + parameters: + - description: 删除参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_rechargepackage.RechargePackageDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除购买套餐 -- by 王鸣 + tags: + - 套餐管理 + /api/web/recharge/package/list: + post: + consumes: + - application/json + parameters: + - description: 查询参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_rechargepackage.RechargePackageListReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_rechargepackage.RechargePackageListResp' + security: + - ApiKeyAuth: [] + summary: 查询购买套餐列表 -- by 王鸣 + tags: + - 套餐管理 + /api/web/recharge/package/status/set: + post: + consumes: + - application/json + parameters: + - description: 设置状态参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_rechargepackage.RechargePackageStatusSetReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 单/批量设置购买套餐状态 -- by 王鸣 + tags: + - 套餐管理 + /api/web/recharge/package/update: + post: + consumes: + - application/json + parameters: + - description: 更新参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_rechargepackage.RechargePackageUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新购买套餐 -- by 王鸣 + tags: + - 套餐管理 + /api/web/recharge/package/vip/list: + post: + description: 只返回 PackageType=1 的启用套餐,字段仅包含 id 和 name,不分页 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + items: + $ref: '#/definitions/srv_rechargepackage.VipPackageOption' + type: array + security: + - ApiKeyAuth: [] + summary: 查询会员畅享套餐选项 -- by 王鸣 + 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/jh/chapters: + get: + consumes: + - application/json + description: 按媒资ID查询嘉华短剧剧集信息 + 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/jh/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/jh/list: + get: + consumes: + - application/json + description: 嘉华短剧媒资列表,固定查询 media_type=5,回填是否已入库,支持按 isAdd 筛选 + parameters: + - description: 是否已入库:true只查已入库,false只查未入库,不传查全部 + in: query + name: isAdd + type: boolean + - 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.PlayletResourceJHListResp' + "400": + description: 失败 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: web端嘉华短剧媒资列表 -- by 王鸣 + tags: + - web媒资库相关接口 + /api/web/resource/list: + get: + consumes: + - application/json + description: 分页查询短剧媒资列表 + parameters: + - description: 是否已加入后台数据:true只查已加入,false只查未加入,不传查全部 + in: query + name: isAdd + type: boolean + - 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/stat/analytics: + post: + consumes: + - application/json + description: 统计数据 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/webanalytics.AnalyticsStat' + "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/webanalytics.AnalyticsDailyStat' + "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/third/newpaycenter/recharge/callback: + post: + consumes: + - application/json + description: 校验商户号、MD5签名、金额及平台订单号,查单取得支付时间后入账;重复成功通知直接应答成功。 + parameters: + - description: 支付中心签名回调 + in: body + name: request + required: true + schema: + $ref: '#/definitions/payCenter.RechargeCallbackResp' + produces: + - text/plain + responses: + "200": + description: success + schema: + type: string + "400": + description: fail + schema: + type: string + 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/giftcoin: + post: + consumes: + - application/json + description: coin 为正数表示赠送,为负数表示扣除;操作会自动记录金币流水 + parameters: + - description: 赠送参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_user.GiftCoinReq' + produces: + - application/json + responses: + "200": + 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/user/vip/update: + post: + consumes: + - application/json + description: 根据会员畅享套餐ID修改用户会员信息,不处理赠送金币 + parameters: + - description: 修改会员参数 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_user.UpdateVipReq' + 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.VideoCategoryUpdateReq' + 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.VideoCreaterUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个视频作者 + 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/setFakeWatchTimes: + post: + consumes: + - application/json + description: 根据ids批量随机设置视频假观看次数 + 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/slug/sync: + post: + consumes: + - application/json + description: 为分类、标签、题材、作者、视频中空的 slug 按 SEO 关键词或名称自动生成。可重复调用,hasMore 为 true 时继续请求。 + parameters: + - description: 同步参数 + in: body + name: param + schema: + $ref: '#/definitions/proto.VideoSlugSyncReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/proto.VideoSlugSyncResp' + "400": + description: 参数错误 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 同步历史空 slug + 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/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.VideoTagInfoUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个标签管理 + tags: + - 标签管理 + /api/web/videotopic/add: + post: + consumes: + - application/json + description: 添加一个视频专题 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/videotopicmod.VideoTopicModel' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 添加一个视频专题 + tags: + - 视频专题 + /api/web/videotopic/del: + post: + consumes: + - application/json + description: 删除一个视频专题 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videotopic.VideoTopicDelReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 删除一个视频专题 + tags: + - 视频专题 + /api/web/videotopic/list: + post: + consumes: + - application/json + description: 分组分页获取视频专题 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videotopic.VideoTopicReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videotopic.VideoTopicResp' + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 分组分页获取视频专题 + tags: + - 视频专题 + /api/web/videotopic/update: + post: + consumes: + - application/json + description: 更新一个视频专题 + parameters: + - description: 参数列表 + in: body + name: param + required: true + schema: + $ref: '#/definitions/srv_videotopic.VideoTopicUpdateReq' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + type: string + "400": + description: 获取失败的返回结果 + schema: + type: string + security: + - ApiKeyAuth: [] + summary: 更新一个视频专题 + tags: + - 视频专题 + /wbapi/cgdj/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/cgdj/ipstat: + get: + consumes: + - application/json + description: 返回 app 端同步到 MongoDB 的每日访问 IP 去重数 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_videotag.IpStatResp' + summary: 查询今日和昨日 IP 去重统计 + tags: + - 标签管理 + /wbapi/hgdj/recharge/summary: + get: + consumes: + - application/json + description: 按北京时间的开始/结束时间汇总支付成功订单金额与笔数 + parameters: + - description: 开始时间,北京时间,支持 2006-01-02、2006-01-02 15:04:05、RFC3339 + in: query + name: startTime + required: true + type: string + - description: 结束时间,北京时间,支持 2006-01-02、2006-01-02 15:04:05、RFC3339 + in: query + name: endTime + required: true + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + $ref: '#/definitions/srv_viporder.RechargeSummaryResp' + "400": + description: 失败 + schema: + type: string + summary: 统计充值汇总 + tags: + - wbapi统计 +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..52fb99f --- /dev/null +++ b/web/middleware/auth/http.go @@ -0,0 +1,283 @@ +package auth + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "net/http" + "strings" + "sync" + "videoweb/pub/common" + "videoweb/pub/common/errors" + "videoweb/pub/common/ipaddr" + "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/newenkey", + "/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", + "/wbapi", + "/api/web/third/newpaycenter/recharge/callback", + } +) + +// 读取角色信息 +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 +} + +// 验证后台账号IP +func authMwIP(ctx *gin.Context) (int, error) { + + cIP := ipaddr.ResolveClientIP(ctx) + 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 + // 对指定 group 跳过 + if strings.HasPrefix(ctx.Request.URL.Path, "/mdps/mdcom/api/web") { + ctx.Next() + 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) + } + + // 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 := ipaddr.ResolveClientIP(ctx) + 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..9b45f44 --- /dev/null +++ b/web/middleware/auth/log_list.go @@ -0,0 +1,102 @@ +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/announcement/add", "公告-增加"}, + {"/api/web/announcement/del", "公告-删除"}, + {"/api/web/announcement/update", "公告-更新"}, + {"/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/setFakeWatchTimes", "设置视频假观看次数"}, + {"/api/web/videoinfo/category/set", "视频-设置分类"}, + {"/api/web/videocollection/media/option", "修改合集里的视频列表"}, + {"/api/web/videocollection/media/rank", "修改合集里的视频排序"}, + {"/api/web/videoinfo/addcoll", "新增一个视频合集"}, + {"/api/web/actress/add", "添加一个女优信息"}, + {"/api/web/actress/del", "删除一个女优信息"}, + {"/api/web/actress/update", "更新一个女优信息"}, + {"/api/web/user/updatetype", "修改用户类型"}, + {"/api/web/user/giftcoin", "赠送/扣除用户金币"}, + {"/api/web/coin/recharge/add", "新增金币充值配置"}, + {"/api/web/coin/recharge/del", "删除金币充值配置"}, + {"/api/web/coin/recharge/update", "更新金币充值配置"}, + {"/api/web/merchant/list", "手动拉取商家列表"}, + {"/api/web/merchant/detail", "手动拉取商家详情"}, + {"/api/web/feedback/handle", "处理用户意见反馈"}, + {"/api/web/contactus/handle", "处理联系我们"}, + {"/api/web/home/banner/add", "首页banner-新增"}, + {"/api/web/home/banner/update", "首页banner-更新"}, + {"/api/web/home/banner/del", "首页banner-删除"}, + {"/api/web/home/config/add", "首页配置-新增"}, + {"/api/web/home/config/update", "首页配置-更新"}, + {"/api/web/home/config/del", "首页配置-删除"}, + // 短剧 + {"/api/web/playlet/add", "短剧-新增"}, + {"/api/web/playlet/update", "短剧-更新"}, + {"/api/web/playlet/status/set", "短剧-设置上下架"}, + {"/api/web/playlet/recommend/set", "短剧-设置推荐"}, + {"/api/web/playlet/featured/set", "短剧-设置爆剧"}, + {"/api/web/playlet/operate-type/set", "短剧-设置运营类型"}, + {"/api/web/playlet/banner/set", "短剧-设置banner展示"}, + {"/api/web/playlet/theme/set", "短剧-设置题材"}, + {"/api/web/playlet/theme/recommend/set", "短剧题材-设置推荐"}, + {"/api/web/playlet/tag/set", "短剧-设置标签"}, + {"/api/web/playlet/category/set", "短剧-设置分类"}, + {"/api/web/playlet/pay-type/set", "短剧-设置支付方式"}, + {"/api/web/playlet/watermark/reset", "短剧-重置水印"}, + {"/api/web/playlet/slug/sync", "短剧-同步历史slug"}, + {"/api/web/videoinfo/slug/sync", "视频-同步历史slug"}, + {"/api/web/playlet/chapter/add", "短剧剧集-新增"}, + {"/api/web/playlet/chapter/update", "短剧剧集-更新"}, + {"/api/web/playlet/chapter/del", "短剧剧集-删除"}, + {"/api/web/playlet/chapter/status/fill", "短剧剧集-回填上架状态"}, + {"/api/web/playlet/chapter/status/set", "短剧剧集-设置上架状态"}, + {"/api/web/playlet/chapter/pay-type/set", "短剧剧集-设置支付方式"}, + {"/api/web/playlet/chapter/sync", "短剧剧集-手动同步"}, + {"/api/web/playlet/theme/add", "短剧题材-新增"}, + {"/api/web/playlet/theme/update", "短剧题材-更新"}, + {"/api/web/playlet/theme/del", "短剧题材-删除"}, + {"/api/web/playlet/tag/add", "短剧标签-新增"}, + {"/api/web/playlet/tag/update", "短剧标签-更新"}, + {"/api/web/playlet/tag/del", "短剧标签-删除"}, + {"/api/web/resource/import", "短剧媒资-入库"}, + {"/api/web/card/order/district/sync", "充值订单-同步历史渠道码"}, +} 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..5f1c685 --- /dev/null +++ b/web/middleware/ipblacklist/ipblacklist.go @@ -0,0 +1,18 @@ +package ipblacklist + +import ( + "errors" + "net/http" + "videoweb/pub/common/ipaddr" + "videoweb/web/service/srv_ipblack" + + "github.com/gin-gonic/gin" +) + +func IpBlackListMiddleware(ctx *gin.Context) { + ip := ipaddr.ResolveClientIP(ctx) + if srv_ipblack.CheckIp(ip) { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, errors.New("login limit")) + return + } +} diff --git a/web/middleware/limitHandler/login.go b/web/middleware/limitHandler/login.go new file mode 100644 index 0000000..48053fa --- /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( + "[91猎艳]\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/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/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/card.go b/web/router/card.go new file mode 100644 index 0000000..aa94290 --- /dev/null +++ b/web/router/card.go @@ -0,0 +1,15 @@ +package router + +import ( + "videoweb/web/api/web_card" + + "github.com/gin-gonic/gin" +) + +func cardRouter(router *gin.RouterGroup) { + card := router.Group("/card") + { + card.POST("/order/list", web_card.GetOrders) + card.POST("/order/district/sync", web_card.SyncDistrictCode) + } +} diff --git a/web/router/coin.go b/web/router/coin.go new file mode 100644 index 0000000..3064325 --- /dev/null +++ b/web/router/coin.go @@ -0,0 +1,14 @@ +package router + +import ( + "videoweb/web/api/web_coin" + + "github.com/gin-gonic/gin" +) + +func coinRouter(router *gin.RouterGroup) { + coin := router.Group("/coin") + { + coin.POST("/trans", web_coin.GetCoinTrans) + } +} diff --git a/web/router/commend.go b/web/router/commend.go new file mode 100644 index 0000000..7b4ada0 --- /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) + } +} diff --git a/web/router/contactus.go b/web/router/contactus.go new file mode 100644 index 0000000..39ef148 --- /dev/null +++ b/web/router/contactus.go @@ -0,0 +1,15 @@ +package router + +import ( + "videoweb/web/api/web_contactus" + + "github.com/gin-gonic/gin" +) + +func contactUsRouter(router *gin.RouterGroup) { + group := router.Group("/contactus") + { + group.GET("/list", web_contactus.List) + group.POST("/handle", web_contactus.Handle) + } +} 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/feedback.go b/web/router/feedback.go new file mode 100644 index 0000000..57a090f --- /dev/null +++ b/web/router/feedback.go @@ -0,0 +1,15 @@ +package router + +import ( + "videoweb/web/api/web_feedback" + + "github.com/gin-gonic/gin" +) + +func feedbackRouter(router *gin.RouterGroup) { + group := router.Group("/feedback") + { + group.GET("/list", web_feedback.List) + group.POST("/handle", web_feedback.Handle) + } +} 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..91027ef --- /dev/null +++ b/web/router/gin.go @@ -0,0 +1,121 @@ +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) + richTextContent(rootRouter) + commentRouter(rootRouter) + ipBlackRouter(rootRouter) + detailNavRouter(rootRouter) + announcementRouter(rootRouter) + cardRouter(rootRouter) + coinRouter(rootRouter) + rechargePackageRouter(rootRouter) + userRouter(rootRouter) + translationRouter(rootRouter) + mediaResourceRouter(rootRouter) + playletRouter(rootRouter) + homeBannerRouter(rootRouter) + homeConfigRouter(rootRouter) + feedbackRouter(rootRouter) + contactUsRouter(rootRouter) + } + // 第三方回调路由(免鉴权,挂在 engine 上而非 /api/web 分组) + thirdRouter(router) +} 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/home_config.go b/web/router/home_config.go new file mode 100644 index 0000000..4960f65 --- /dev/null +++ b/web/router/home_config.go @@ -0,0 +1,17 @@ +package router + +import ( + "videoweb/web/api/web_homeconfig" + + "github.com/gin-gonic/gin" +) + +func homeConfigRouter(router *gin.RouterGroup) { + group := router.Group("/home/config") + { + group.GET("/list", web_homeconfig.List) + group.POST("/add", web_homeconfig.Add) + group.POST("/update", web_homeconfig.Update) + group.POST("/del", web_homeconfig.Del) + } +} diff --git a/web/router/ipblack.go b/web/router/ipblack.go new file mode 100644 index 0000000..b633a35 --- /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) + } +} diff --git a/web/router/mediaresource.go b/web/router/mediaresource.go new file mode 100644 index 0000000..06ee43d --- /dev/null +++ b/web/router/mediaresource.go @@ -0,0 +1,19 @@ +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("/jh/list", web_mediaresource.ResourceJHList) + group.GET("/jh/chapters", web_mediaresource.ResourceJHChapters) + group.POST("/jh/import", web_mediaresource.ResourceJHImport) + 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..da173b4 --- /dev/null +++ b/web/router/playlet.go @@ -0,0 +1,53 @@ +package router + +import ( + "videoweb/web/api/web_playlet" + + "github.com/gin-gonic/gin" +) + +func playletRouter(router *gin.RouterGroup) { + group := router.Group("/playlet") + { + group.GET("/home-config/topic/options", web_playlet.HomeConfigTopicOptions) + group.GET("/list", web_playlet.List) + group.GET("/sales", web_playlet.Sales) + 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("/online-at/fill", web_playlet.FillOnlineAt) + group.POST("/recommend/set", web_playlet.SetRecommend) + group.POST("/featured/set", web_playlet.SetFeatured) + group.POST("/operate-type/set", web_playlet.SetOperateType) + group.POST("/banner/set", web_playlet.SetBanner) + group.POST("/theme/set", web_playlet.SetTheme) + group.POST("/tag/set", web_playlet.SetTag) + group.POST("/category/set", web_playlet.SetCategory) + group.POST("/like/virtual/set", web_playlet.SetVirtualLike) + group.POST("/pay-type/set", web_playlet.SetPayType) + group.POST("/watermark/reset", web_playlet.ResetWatermark) + group.POST("/slug/sync", web_playlet.SyncSlug) + 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/status/fill", web_playlet.ChapterFillStatus) + group.POST("/chapter/status/set", web_playlet.ChapterSetStatus) + group.POST("/chapter/pay-type/set", web_playlet.ChapterSetPayType) + group.POST("/chapter/sync", web_playlet.ChapterSync) + group.GET("/theme/list", web_playlet.ThemeList) + group.GET("/topic-group/list", web_playlet.TopicGroupList) + group.POST("/topic-group/add", web_playlet.TopicGroupAdd) + group.POST("/topic-group/update", web_playlet.TopicGroupUpdate) + group.POST("/topic-group/del", web_playlet.TopicGroupDel) + group.POST("/theme/add", web_playlet.ThemeAdd) + group.POST("/theme/update", web_playlet.ThemeUpdate) + group.POST("/theme/recommend/set", web_playlet.SetThemeRecommend) + group.POST("/theme/del", web_playlet.ThemeDel) + 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) + } +} diff --git a/web/router/rechargepackage.go b/web/router/rechargepackage.go new file mode 100644 index 0000000..22acb5b --- /dev/null +++ b/web/router/rechargepackage.go @@ -0,0 +1,19 @@ +package router + +import ( + "videoweb/web/api/web_rechargepackage" + + "github.com/gin-gonic/gin" +) + +func rechargePackageRouter(router *gin.RouterGroup) { + group := router.Group("/recharge/package") + { + group.POST("/list", web_rechargepackage.List) + group.POST("/vip/list", web_rechargepackage.VipList) + group.POST("/add", web_rechargepackage.Add) + group.POST("/update", web_rechargepackage.Update) + group.POST("/del", web_rechargepackage.Delete) + group.POST("/status/set", web_rechargepackage.SetStatus) + } +} diff --git a/web/router/richtextcontent.go b/web/router/richtextcontent.go new file mode 100644 index 0000000..45d704e --- /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) + } +} 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/third.go b/web/router/third.go new file mode 100644 index 0000000..42db33f --- /dev/null +++ b/web/router/third.go @@ -0,0 +1,14 @@ +package router + +import ( + "videoweb/web/api/web_recharge" + + "github.com/gin-gonic/gin" +) + +func thirdRouter(r *gin.Engine) { + thirdGroup := r.Group("/api/web/third") + { + thirdGroup.POST("/newpaycenter/recharge/callback", web_recharge.NewPayCenterRchgCallBack) + } +} 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..b4233f3 --- /dev/null +++ b/web/router/user.go @@ -0,0 +1,18 @@ +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) + user.POST("/giftcoin", web_user.GiftCoin) + user.POST("/vip/update", web_user.UpdateVip) + user.POST("/resetPassword", web_user.ResetPassword) + } +} diff --git a/web/router/video.go b/web/router/video.go new file mode 100644 index 0000000..eb28822 --- /dev/null +++ b/web/router/video.go @@ -0,0 +1,58 @@ +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("/addcoll", web_video.AddOneVideoCollection) + advitise.POST("/setFakeWatchTimes", web_video.SetVideoFakeWatchTimes) + advitise.POST("/category/set", web_video.SetCategory) + advitise.POST("/transcode", web_video.TranscodeVideo) + advitise.POST("/slug/sync", web_video.SyncSlug) + } + 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) + } + videoTopic := router.Group("/videotopic") + { + videoTopic.POST("/list", web_video.GetVideoTopic) + videoTopic.POST("/update", web_video.UpdateOneVideoTopic) + videoTopic.POST("/del", web_video.DelOneVideoTopic) + videoTopic.POST("/add", web_video.AddOneVideoTopic) + } + + router.GET("/m3u8/*key", web_video.Play) + router.GET("/transm3u8/*key", web_video.TranscodeM3u8) + router.GET("/enkey", web_video.Enkey) + router.GET("/newenkey", web_video.NewEnkey) + router.GET("/lsjenkey", web_video.LsjEnkey) + router.GET("/videotag/hottag", web_video.GetVideoPlayData) +} diff --git a/web/router/wbvideodata.go b/web/router/wbvideodata.go new file mode 100644 index 0000000..af11e26 --- /dev/null +++ b/web/router/wbvideodata.go @@ -0,0 +1,20 @@ +package router + +import ( + "videoweb/web/api/web_recharge" + "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/sgdjweb") + { + router.Use(limiter.Middleware()) + //router.GET("/hottag", web_video.GetHotTagByPlayData) + router.GET("/ipstat", web_video.GetIpStatData) + router.GET("/recharge/summary", web_recharge.RechargeSummary) + } +} diff --git a/web/server/server.go b/web/server/server.go new file mode 100644 index 0000000..80975bb --- /dev/null +++ b/web/server/server.go @@ -0,0 +1,213 @@ +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/payCenter" + "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.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 + } + payCenter.Init(webg.Conf.NewPayCenter) + + 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..f0db02d --- /dev/null +++ b/web/service/services.go @@ -0,0 +1,25 @@ +package service + +import ( + "videoweb/web/service/srv_aicomment" + "videoweb/web/service/srv_ipblack" + "videoweb/web/service/srv_mediaresource" + "videoweb/web/skd" + "videoweb/web/webg" +) + +func Start() { + url, appID, secret := webg.Conf.AiComment.Url, webg.Conf.AiComment.AppId, webg.Conf.AiComment.Secret + if url == "" && appID == "" && secret == "" { + url, appID, secret = webg.Conf.Customer.Url, webg.Conf.Customer.AppId, webg.Conf.Customer.Secret + } + srv_aicomment.Init(url, appID, secret) + skd.Start() + srv_ipblack.Init() + srv_mediaresource.Start() +} + +func Stop() { + srv_mediaresource.Stop() + skd.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_aicomment/client.go b/web/service/srv_aicomment/client.go new file mode 100644 index 0000000..6443b09 --- /dev/null +++ b/web/service/srv_aicomment/client.go @@ -0,0 +1,456 @@ +package srv_aicomment + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "videoweb/pub/common/crypt" + "videoweb/pub/common/log" + "videoweb/pub/models/aicommentrecord" + "videoweb/pub/models/playletmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + minCommentLengthLimit = 20 + maxCommentLengthLimit = 50 + maxSubmitVideoCount = 100 + maxVideoTitleRunes = 100 + maxVideoDescriptionRunes = 100 + maxVideoSubtitleRunes = 500 + maxVideoTags = 20 + minSubmitVideoCommentCount = 1 + minSubmitRequestInterval = 10 * time.Second + objectTypePlaylet = 10 +) + +type VideoItem struct { + VideoId string `json:"videoId"` + Title string `json:"title"` + Tags []string `json:"tags,omitempty"` + Description string `json:"description,omitempty"` + Subtitle string `json:"subtitle,omitempty"` + CommentCount int `json:"commentCount"` + ObjType int64 `json:"-"` + RetainCount int `json:"-"` // 本次任务结果需要保留的评论数 + TargetCount int `json:"-"` // 当前内容固定的目标AI评论总数 + CommentTotal int `json:"-"` // 本次续传还需要生成的评论数 + CurrentCount int64 `json:"-"` // 内容当前评论数,用于保留已有真实互动 +} + +type SubmitRequest struct { + AppID string `json:"appId"` + Sign string `json:"sign"` + Videos []VideoItem `json:"videos"` +} + +type SubmitResult struct { + ID string `json:"id"` + VideoId string `json:"videoId"` +} + +type SubmitResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Results []SubmitResult `json:"results"` + } `json:"data"` +} + +type ResultRequest struct { + AppID string `json:"appId"` + Sign string `json:"sign"` + IDs []string `json:"ids"` +} + +type Comment struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type ResultItem struct { + ID string `json:"id"` + VideoId string `json:"videoId"` + Status int `json:"status,omitempty"` + Comments []Comment `json:"comments"` + ErrorMsg string `json:"errorMsg,omitempty"` +} + +type ResultResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Results []ResultItem `json:"results"` + } `json:"data"` +} + +type AiCommentService struct { + baseURL string + appID string + appSecret string + client *http.Client + signMu sync.Mutex + lastSignUnixTime int64 + submitMu sync.Mutex + lastSubmitAt time.Time +} + +var aiCommentService *AiCommentService + +func Init(baseURL, appID, secret string) { + aiCommentService = &AiCommentService{ + baseURL: normalizeBaseURL(baseURL), + appID: strings.TrimSpace(appID), + appSecret: strings.TrimSpace(secret), + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } + if aiCommentService.Configured() { + log.Info("自动评论服务初始化完成", log.Any("baseURL", aiCommentService.baseURL), log.Any("appId", aiCommentService.appID)) + return + } + log.Warn("自动评论服务配置不完整,已跳过启用", log.Any("baseURL", aiCommentService.baseURL), log.Any("appId", aiCommentService.appID)) +} + +func GetAiCommentService() *AiCommentService { + if aiCommentService == nil { + Init("", "", "") + } + return aiCommentService +} + +func (s *AiCommentService) Configured() bool { + return s != nil && s.baseURL != "" && s.appID != "" && s.appSecret != "" +} + +func normalizeBaseURL(baseURL string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return "" + } + if strings.HasSuffix(baseURL, "/app/customer") { + return baseURL + } + if strings.HasSuffix(baseURL, "/app") { + return baseURL + "/customer" + } + return baseURL + "/app/customer" +} + +func (s *AiCommentService) generateSign(ctx context.Context) (string, int64, error) { + s.signMu.Lock() + defer s.signMu.Unlock() + + timestamp := time.Now().Unix() + if timestamp <= s.lastSignUnixTime { + timer := time.NewTimer(time.Until(time.Unix(s.lastSignUnixTime+1, 0))) + defer timer.Stop() + select { + case <-ctx.Done(): + return "", 0, ctx.Err() + case <-timer.C: + } + timestamp = time.Now().Unix() + } + params := url.Values{ + "appId": {s.appID}, + "timestamp": {strconv.FormatInt(timestamp, 10)}, + } + cipherBytes, err := crypt.CoreAesEncrypt([]byte(params.Encode()), s.appSecret) + if err != nil { + return "", 0, fmt.Errorf("AES加密失败: %w", err) + } + s.lastSignUnixTime = timestamp + return hex.EncodeToString(cipherBytes), timestamp, nil +} + +func (s *AiCommentService) postJSON(ctx context.Context, path string, reqBody interface{}, respBody interface{}) error { + if !s.Configured() { + return errors.New("自动评论服务未配置") + } + jsonData, err := json.Marshal(reqBody) + if err != nil { + return err + } + reqURL := s.baseURL + path + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + log.Info("自动评论HTTP请求", log.Any("url", reqURL), log.Any("bodySize", len(jsonData))) + resp, err := s.client.Do(req) + if err != nil { + log.Error("自动评论HTTP请求失败", log.E(err), log.Any("url", reqURL)) + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + log.Info("自动评论HTTP响应", log.Any("url", reqURL), log.Any("statusCode", resp.StatusCode), log.Any("body", string(body))) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("自动评论HTTP状态异常: %d", resp.StatusCode) + } + if err = json.Unmarshal(body, respBody); err != nil { + return err + } + return nil +} + +func (s *AiCommentService) SubmitVideos(ctx context.Context, videos []VideoItem) ([]SubmitResult, error) { + videos = normalizeVideoItems(videos) + if len(videos) == 0 { + return []SubmitResult{}, nil + } + if len(videos) > maxSubmitVideoCount { + return nil, fmt.Errorf("单次提交最多100个视频,当前%d个", len(videos)) + } + if err := s.waitForSubmitSlot(ctx); err != nil { + return nil, err + } + sign, timestamp, err := s.generateSign(ctx) + if err != nil { + log.Error("自动评论生成签名失败", log.E(err)) + return nil, err + } + reqBody := SubmitRequest{AppID: s.appID, Sign: sign, Videos: videos} + var submitResp SubmitResponse + if err = s.postJSON(ctx, "/videoComments/submit", reqBody, &submitResp); err != nil { + return nil, err + } + if submitResp.Code != http.StatusOK { + return nil, fmt.Errorf("提交任务失败: code=%d, msg=%s, timestamp=%d", submitResp.Code, submitResp.Msg, timestamp) + } + return submitResp.Data.Results, nil +} + +func (s *AiCommentService) waitForSubmitSlot(ctx context.Context) error { + s.submitMu.Lock() + defer s.submitMu.Unlock() + + wait := time.Until(s.lastSubmitAt.Add(minSubmitRequestInterval)) + if wait > 0 { + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + } + } + s.lastSubmitAt = time.Now() + return nil +} + +func (s *AiCommentService) QueryResults(ctx context.Context, taskIDs []string) ([]ResultItem, error) { + if len(taskIDs) == 0 { + return []ResultItem{}, nil + } + if len(taskIDs) > 500 { + return nil, fmt.Errorf("单次查询最多500个任务,当前%d个", len(taskIDs)) + } + sign, timestamp, err := s.generateSign(ctx) + if err != nil { + return nil, err + } + reqBody := ResultRequest{AppID: s.appID, Sign: sign, IDs: taskIDs} + var resultResp ResultResponse + if err = s.postJSON(ctx, "/videoComments/result", reqBody, &resultResp); err != nil { + return nil, err + } + if resultResp.Code != http.StatusOK { + return nil, fmt.Errorf("查询结果失败: code=%d, msg=%s, timestamp=%d", resultResp.Code, resultResp.Msg, timestamp) + } + return resultResp.Data.Results, nil +} + +func (s *AiCommentService) SubmitAndRecord(ctx context.Context, videos []VideoItem) error { + videos = normalizeVideoItems(videos) + if len(videos) == 0 { + return nil + } + reserved := make([]VideoItem, 0, len(videos)) + for _, video := range videos { + record := &aicommentrecord.AiCommentRecord{ + Key: aiCommentRecordKey(video), + Vid: video.VideoId, + Title: video.Title, + ObjType: video.ObjType, + RetainCount: video.RetainCount, + TargetCount: video.TargetCount, + Desc: video.Description, + } + claimed, serr := aicommentrecord.Reserve(ctx, record) + if serr != nil { + return fmt.Errorf("预留自动评论任务失败: %w", serr) + } + if claimed { + reserved = append(reserved, video) + } + } + if len(reserved) == 0 { + return nil + } + results, err := s.SubmitVideos(ctx, reserved) + if err != nil { + for _, video := range reserved { + _ = aicommentrecord.SetSubmission(ctx, aiCommentRecordKey(video), "", aicommentrecord.StatusFailed, err.Error()) + } + return err + } + resultByVid := make(map[string]string, len(results)) + for _, result := range results { + resultByVid[result.VideoId] = result.ID + } + missing := 0 + for _, video := range reserved { + taskID := resultByVid[video.VideoId] + status := aicommentrecord.StatusSubmitted + desc := video.Description + if taskID == "" { + status = aicommentrecord.StatusFailed + desc = "自动评论服务未返回任务ID" + missing++ + } + if serr := aicommentrecord.SetSubmission(ctx, aiCommentRecordKey(video), taskID, status, desc); serr != nil { + return fmt.Errorf("保存自动评论任务记录失败: %w", serr) + } + } + if missing > 0 { + return fmt.Errorf("自动评论服务缺少%d个任务ID", missing) + } + log.Info("自动评论提交并记录成功", log.Any("videoCount", len(reserved))) + return nil +} + +func aiCommentRecordKey(video VideoItem) string { + return strconv.FormatInt(video.ObjType, 10) + ":" + video.VideoId +} + +func AutoProcessUnsubmittedPlaylets(ctx context.Context, playletIDs []int64) error { + if len(playletIDs) == 0 { + return nil + } + service := GetAiCommentService() + if !service.Configured() { + log.Warn("自动评论服务未配置,跳过提交", log.Any("playletIDs", playletIDs)) + return nil + } + videos, err := buildPendingPlayletVideoItems(ctx, playletIDs) + if err != nil { + return err + } + if err = submitAndRecordInBatches(ctx, service, videos); err != nil { + return fmt.Errorf("提交AI评论任务失败: %w", err) + } + log.Info("自动评论处理短剧完成", log.Any("requested", len(playletIDs)), log.Any("pending", len(videos))) + return nil +} + +func buildPendingPlayletVideoItems(ctx context.Context, playletIDs []int64) ([]VideoItem, error) { + if len(playletIDs) == 0 { + return []VideoItem{}, nil + } + playlets, serr := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": playletIDs}, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }, options.Find().SetProjection(homepagePlayletProjection)) + if serr != nil { + return nil, serr + } + pending, serr := buildHomepagePlayletVideoItems(ctx, playlets) + if serr != nil { + return nil, serr + } + return pending, nil +} + +func normalizeVideoItems(videos []VideoItem) []VideoItem { + if len(videos) == 0 { + return []VideoItem{} + } + result := make([]VideoItem, 0, len(videos)) + for _, video := range videos { + video = normalizeVideoItem(video) + if video.VideoId == "" { + continue + } + result = append(result, video) + } + return result +} + +func normalizeVideoItem(video VideoItem) VideoItem { + video.VideoId = strings.TrimSpace(video.VideoId) + video.Title = truncateRunes(strings.TrimSpace(video.Title), maxVideoTitleRunes) + if video.Title == "" { + video.Title = video.VideoId + } + video.Description = truncateRunes(strings.TrimSpace(video.Description), maxVideoDescriptionRunes) + video.Subtitle = truncateRunes(strings.TrimSpace(video.Subtitle), maxVideoSubtitleRunes) + video.Tags = normalizeVideoTags(video.Tags) + if video.CommentCount < minSubmitVideoCommentCount { + video.CommentCount = minSubmitVideoCommentCount + } + if video.CommentCount > maxCommentLengthLimit { + video.CommentCount = maxCommentLengthLimit + } + if video.ObjType <= 0 { + video.ObjType = objectTypePlaylet + } + return video +} + +func randomCommentLengthLimit() int { + return randomIntInclusive(minCommentLengthLimit, maxCommentLengthLimit) +} + +func normalizeVideoTags(tags []string) []string { + if len(tags) == 0 { + return nil + } + result := make([]string, 0, len(tags)) + seen := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + if _, ok := seen[tag]; ok { + continue + } + seen[tag] = struct{}{} + result = append(result, tag) + if len(result) >= maxVideoTags { + break + } + } + return result +} + +func truncateRunes(text string, maxLen int) string { + if maxLen <= 0 { + return "" + } + runes := []rune(text) + if len(runes) <= maxLen { + return text + } + return string(runes[:maxLen]) +} diff --git a/web/service/srv_aicomment/homepage.go b/web/service/srv_aicomment/homepage.go new file mode 100644 index 0000000..0182043 --- /dev/null +++ b/web/service/srv_aicomment/homepage.go @@ -0,0 +1,355 @@ +package srv_aicomment + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync/atomic" + + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/pub/models/playletthememod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + homepagePlayletCandidateLimit int64 = 60 + homepagePlayletFeedThemeLimit int64 = 30 + maxSubmitRequestsPerRun = 7 +) + +var homepagePlayletProjection = bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.Title, Value: 1}, + {Key: playletmod.FN.Playlet.Desc, Value: 1}, + {Key: playletmod.FN.Playlet.TagIds, Value: 1}, + {Key: playletmod.FN.Playlet.OnlineAt, Value: 1}, + {Key: playletmod.FN.Playlet.CreatedAt, 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.TopRank, Value: 1}, + {Key: playletmod.FN.Playlet.RecommendRank, Value: 1}, + {Key: playletmod.FN.Playlet.CountBrowse, Value: 1}, + {Key: playletmod.FN.Playlet.CountComment, Value: 1}, +} + +var homepageSubmitRunning atomic.Bool + +func AutoProcessHomepageVisibleContent(ctx context.Context) error { + if !homepageSubmitRunning.CompareAndSwap(false, true) { + log.Debug("首页内容自动评论补提交正在执行中,本次跳过") + return nil + } + defer homepageSubmitRunning.Store(false) + + service := GetAiCommentService() + if !service.Configured() { + log.Warn("自动评论服务未配置,跳过首页内容补提交") + return nil + } + + playlets, serr := collectHomepagePlaylets(ctx) + if serr != nil { + return fmt.Errorf("查询首页短剧失败: %w", serr) + } + videos, serr := buildHomepagePlayletVideoItems(ctx, playlets) + if serr != nil { + return fmt.Errorf("组装首页短剧AI评论参数失败: %w", serr) + } + if err := submitAndRecordInBatches(ctx, service, videos); err != nil { + return fmt.Errorf("提交首页短剧AI评论任务失败: %w", err) + } + log.Info("自动评论首页内容补提交完成", + log.Any("playletCandidates", len(playlets)), + log.Any("playletPending", len(videos)), + ) + return nil +} + +func submitAndRecordInBatches(ctx context.Context, service *AiCommentService, videos []VideoItem) error { + batches, deferred := buildAICommentSubmissionBatches(videos, maxSubmitRequestsPerRun) + for _, batch := range batches { + if err := service.SubmitAndRecord(ctx, batch); err != nil { + return err + } + } + if deferred { + log.Info("自动评论达到单轮提交请求上限,剩余任务等待下次续传", + log.Any("requestLimit", maxSubmitRequestsPerRun), + log.Any("videoCount", len(videos))) + } + return nil +} + +func buildAICommentSubmissionBatches(videos []VideoItem, requestLimit int) ([][]VideoItem, bool) { + batches := make([][]VideoItem, 0) + for _, round := range buildAICommentSubmissionRounds(videos) { + for start := 0; start < len(round); start += maxSubmitVideoCount { + end := start + maxSubmitVideoCount + if end > len(round) { + end = len(round) + } + batches = append(batches, round[start:end]) + } + } + if requestLimit <= 0 || len(batches) <= requestLimit { + return batches, false + } + return batches[:requestLimit], true +} + +func buildAICommentSubmissionRounds(videos []VideoItem) [][]VideoItem { + pending := append([]VideoItem(nil), videos...) + rounds := make([][]VideoItem, 0) + for len(pending) > 0 { + round := make([]VideoItem, 0, len(pending)) + next := make([]VideoItem, 0, len(pending)) + for _, video := range pending { + remaining := video.CommentTotal + if remaining <= 0 { + remaining = commentsPerGenerationTask + } + retainCount := min(remaining, commentsPerGenerationTask) + taskVideo := video + taskVideo.CommentCount = randomCommentLengthLimit() + taskVideo.RetainCount = retainCount + taskVideo.CommentTotal = 0 + round = append(round, taskVideo) + + remaining -= retainCount + if remaining > 0 { + video.CommentTotal = remaining + next = append(next, video) + } + } + rounds = append(rounds, round) + pending = next + } + return rounds +} + +func collectHomepagePlaylets(ctx context.Context) (playletmod.PlayletList, *srvdef.SrvErr) { + playlets := make(playletmod.PlayletList, 0, maxSubmitVideoCount) + seen := make(map[int64]struct{}) + if serr := appendHomepagePlaylets(ctx, &playlets, seen, + homepagePlayletFilter(bson.M{playletmod.FN.Playlet.ShowOnBanner: true}), + bson.D{ + {Key: playletmod.FN.Playlet.TopRank, Value: -1}, + {Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }, + 6, + ); serr != nil { + return nil, serr + } + if serr := appendHomepageFeedThemePlaylets(ctx, &playlets, seen); serr != nil { + return nil, serr + } + queries := []struct { + filter bson.M + sort bson.D + limit int64 + }{ + { + filter: homepagePlayletFilter(bson.M{playletmod.FN.Playlet.IsRecommend: true}), + sort: bson.D{ + {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, + {Key: playletmod.FN.Playlet.CreatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }, + limit: homepagePlayletCandidateLimit, + }, + { + filter: homepagePlayletFilter(nil), + sort: bson.D{ + {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, + {Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }, + limit: homepagePlayletCandidateLimit, + }, + { + filter: homepagePlayletFilter(nil), + sort: bson.D{ + {Key: playletmod.FN.Playlet.CountCollect, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }, + limit: homepagePlayletCandidateLimit, + }, + { + filter: homepagePlayletFilter(nil), + sort: bson.D{ + {Key: playletmod.FN.Playlet.UpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }, + limit: homepagePlayletCandidateLimit, + }, + { + filter: homepagePlayletFilter(nil), + sort: bson.D{ + {Key: playletmod.FN.Playlet.CountBrowse, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }, + limit: homepagePlayletCandidateLimit, + }, + } + for _, query := range queries { + if serr := appendHomepagePlaylets(ctx, &playlets, seen, query.filter, query.sort, query.limit); serr != nil { + return nil, serr + } + } + return playlets, nil +} + +func appendHomepageFeedThemePlaylets(ctx context.Context, playlets *playletmod.PlayletList, seen map[int64]struct{}) *srvdef.SrvErr { + themes, serr := playletthememod.FindManyPlayletTheme(ctx, bson.M{ + playletthememod.FN.PlayletTheme.Status: true, + }, options.Find(). + SetProjection(bson.D{{Key: playletthememod.FN.PlayletTheme.Id, Value: 1}}). + SetSort(bson.D{ + {Key: playletthememod.FN.PlayletTheme.Rank, Value: -1}, + {Key: playletthememod.FN.PlayletTheme.Id, Value: -1}, + }), + ) + if serr != nil { + return serr + } + sort := bson.D{ + {Key: playletmod.FN.Playlet.RecommendRank, Value: -1}, + {Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } + for _, theme := range themes { + if theme == nil || theme.Id <= 0 { + continue + } + filter := homepagePlayletFilter(bson.M{ + playletmod.FN.Playlet.ThemeIds: bson.M{"$in": []int64{theme.Id}}, + }) + if serr := appendHomepagePlaylets(ctx, playlets, seen, filter, sort, homepagePlayletFeedThemeLimit); serr != nil { + return serr + } + } + return nil +} + +func appendHomepagePlaylets(ctx context.Context, playlets *playletmod.PlayletList, seen map[int64]struct{}, filter bson.M, sort bson.D, limit int64) *srvdef.SrvErr { + list, serr := playletmod.FindManyPlaylet(ctx, filter, options.Find(). + SetProjection(homepagePlayletProjection). + SetSort(sort). + SetLimit(limit), + ) + if serr != nil { + return serr + } + for _, playlet := range list { + if playlet == nil || playlet.Id <= 0 { + continue + } + if _, ok := seen[playlet.Id]; ok { + continue + } + seen[playlet.Id] = struct{}{} + *playlets = append(*playlets, playlet) + } + return nil +} + +func buildHomepagePlayletVideoItems(ctx context.Context, playlets playletmod.PlayletList) ([]VideoItem, *srvdef.SrvErr) { + tagNames := loadPlayletTagNames(ctx, playlets) + videos := make([]VideoItem, 0, len(playlets)) + for _, playlet := range playlets { + if playlet == nil { + continue + } + videos = append(videos, VideoItem{ + VideoId: strconv.FormatInt(playlet.Id, 10), + Title: playlet.Title, + Tags: playletTagNames(playlet.TagIds, tagNames), + Description: playlet.Desc, + CommentCount: randomCommentLengthLimit(), + ObjType: objectTypePlaylet, + CommentTotal: aiCommentPlayletTotal, + CurrentCount: playlet.CountComment, + }) + } + pending, serr := preparePendingAICommentVideos(ctx, videos) + if serr != nil { + return nil, serr + } + return pending, nil +} + +func homepagePlayletFilter(extra bson.M) bson.M { + filter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp} + for key, value := range extra { + filter[key] = value + } + return filter +} + +func loadPlayletTagNames(ctx context.Context, playlets playletmod.PlayletList) map[int64]string { + tagIDs := make([]int64, 0) + seen := make(map[int64]struct{}) + for _, playlet := range playlets { + if playlet == nil { + continue + } + for _, tagID := range playlet.TagIds { + if tagID <= 0 { + continue + } + if _, ok := seen[tagID]; ok { + continue + } + seen[tagID] = struct{}{} + tagIDs = append(tagIDs, tagID) + } + } + if len(tagIDs) == 0 { + return map[int64]string{} + } + tags, serr := playlettagmod.FindManyPlayletTag(ctx, bson.M{ + playlettagmod.FN.PlayletTag.Id: bson.M{"$in": tagIDs}, + }, options.Find().SetProjection(bson.D{ + {Key: playlettagmod.FN.PlayletTag.Id, Value: 1}, + {Key: playlettagmod.FN.PlayletTag.Name, Value: 1}, + })) + if serr != nil { + log.Warn("自动评论查询短剧标签失败", log.E(serr), log.Any("tagIDs", tagIDs)) + return map[int64]string{} + } + result := make(map[int64]string, len(tags)) + for _, tag := range tags { + if tag != nil { + result[tag.Id] = tag.Name + } + } + return result +} + +func playletTagNames(tagIDs []int64, tagNames map[int64]string) []string { + result := make([]string, 0, len(tagIDs)) + seen := make(map[string]struct{}, len(tagIDs)) + for _, tagID := range tagIDs { + name := strings.TrimSpace(tagNames[tagID]) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + result = append(result, name) + if len(result) >= maxVideoTags { + break + } + } + return result +} diff --git a/web/service/srv_aicomment/process.go b/web/service/srv_aicomment/process.go new file mode 100644 index 0000000..07548b1 --- /dev/null +++ b/web/service/srv_aicomment/process.go @@ -0,0 +1,299 @@ +package srv_aicomment + +import ( + "context" + "math/rand" + "strconv" + "strings" + "sync/atomic" + "time" + "unicode/utf8" + + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/aicommentrecord" + "videoweb/pub/models/commentmod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/usermod" + "videoweb/pub/mongos" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var pollingRunning atomic.Bool + +const ( + maxPollTaskCount int64 = 500 + commentLevelFirst int64 = 1 + commentReviewPass int = 1 +) + +func PollAiCommentResults(ctx context.Context) { + service := GetAiCommentService() + if !service.Configured() { + return + } + if !pollingRunning.CompareAndSwap(false, true) { + log.Debug("自动评论轮询正在执行中,本次跳过") + return + } + defer pollingRunning.Store(false) + + if serr := expireStaleSubmittedTasks(ctx); serr != nil { + log.Error("自动评论轮询释放超时任务失败", log.E(serr)) + return + } + records, serr := aicommentrecord.FindByStatus(ctx, aicommentrecord.StatusSubmitted, maxPollTaskCount) + if serr != nil { + log.Error("自动评论轮询查询任务记录失败", log.E(serr)) + return + } + if len(records) == 0 { + return + } + taskIDs := make([]string, 0, len(records)) + recordMap := make(map[string]*aicommentrecord.AiCommentRecord, len(records)) + for _, record := range records { + if record.TaskId == "" { + continue + } + taskIDs = append(taskIDs, record.TaskId) + recordMap[record.TaskId] = record + } + results, err := service.QueryResults(ctx, taskIDs) + if err != nil { + log.Error("自动评论轮询查询生成结果失败", log.E(err), log.Any("taskIDs", taskIDs)) + return + } + processAndInsertAiComments(ctx, results, recordMap) +} + +func processAndInsertAiComments(ctx context.Context, results []ResultItem, recordMap map[string]*aicommentrecord.AiCommentRecord) { + users, err := robotUsers(ctx) + if err != nil || len(users) == 0 { + log.Error("自动评论机器人用户池为空或查询失败", log.E(err)) + return + } + playletMap := loadPlayletsByResults(ctx, results, recordMap) + successTaskIDs := make([]string, 0) + + for _, result := range results { + record, ok := recordMap[result.ID] + if !ok { + log.Warn("自动评论轮询未找到任务记录", log.Any("taskID", result.ID)) + continue + } + if result.ErrorMsg != "" || result.Status == 2 { + _ = aicommentrecord.UpdateStatusAndDescByTaskID(ctx, result.ID, aicommentrecord.StatusFailed, result.ErrorMsg) + log.Error("自动评论生成结果失败", log.Any("taskID", result.ID), log.Any("videoId", result.VideoId), log.Any("errorMsg", result.ErrorMsg)) + continue + } + if len(result.Comments) == 0 { + continue + } + playletID, err := strconv.ParseInt(record.Vid, 10, 64) + if err != nil || playletID <= 0 { + log.Warn("自动评论短剧ID无效", log.Any("taskID", result.ID), log.Any("vid", record.Vid), log.E(err)) + continue + } + playlet := playletMap[playletID] + if playlet == nil || playlet.Status != playletmod.StatusUp { + _ = aicommentrecord.UpdateStatusAndDescByTaskID(ctx, result.ID, aicommentrecord.StatusAbandoned, "短剧已下架或不存在") + continue + } + objectType := record.ObjType + if objectType <= 0 { + objectType = objectTypePlaylet + } + retainCount := record.RetainCount + if retainCount <= 0 { + retainCount = len(result.Comments) + } + selectedComments := selectAIComments(result.Comments, retainCount) + currentCount, serr := commentmod.CountComments(ctx, bson.M{ + commentmod.FN.Comment.ObjectId: playletID, + commentmod.FN.Comment.ObjectType: objectTypePlaylet, + commentmod.FN.Comment.ReviewStatus: commentReviewPass, + commentmod.FN.Comment.ParentsId: 0, + }) + if serr != nil { + log.Error("自动评论统计现有评论失败", log.E(serr), log.Any("playletID", playletID)) + continue + } + insertedCount := 0 + now := time.Now() + publishedAt := PlayletPublishedAt(playlet) + objectName := "" + if playlet != nil { + objectName = playlet.Title + } + if objectName == "" { + objectName = record.Title + } + for index, comment := range selectedComments { + if currentCount >= int64(aiCommentPlayletTotal) { + break + } + comment.Text = strings.TrimSpace(comment.Text) + if comment.Text == "" || utf8.RuneCountInString(comment.Text) > 200 { + continue + } + user := users[rand.Intn(len(users))] + commentID := mongos.Lid.Comment.Get() + item := &commentmod.CommentModel{ + Id: commentID, + ObjectId: playletID, + ObjectType: objectType, + ObjectName: objectName, + UserId: user.Id, + UserName: commentUserName(user), + UserAvatar: user.Avatar, + Level: commentLevelFirst, + Text: comment.Text, + ReviewStatus: commentReviewPass, + IsAi: true, + AiTaskId: result.ID, + AiIndex: index, + CreatedAt: ScatteredCommentTime(publishedAt, now, playletID, commentID, index, len(selectedComments)), + UpdatedAt: now, + } + inserted, insertErr := commentmod.InsertAICommentOnce(ctx, item) + if insertErr != nil { + log.Error("自动评论写入失败", log.E(insertErr), log.Any("taskID", result.ID), log.Any("index", index)) + return + } + if inserted { + currentCount++ + insertedCount++ + } + } + if serr := refreshPlayletCommentCount(ctx, playletID); serr != nil { + continue + } + if currentCount < int64(aiCommentPlayletTotal) { + _ = aicommentrecord.UpdateStatusAndDescByTaskID(ctx, result.ID, aicommentrecord.StatusFailed, "生成评论不足,等待重试") + continue + } + successTaskIDs = append(successTaskIDs, result.ID) + log.Info("自动评论写入完成", log.Any("taskID", result.ID), log.Any("vid", record.Vid), log.Any("count", insertedCount)) + } + + if len(successTaskIDs) > 0 { + if serr := aicommentrecord.BatchUpdateStatus(ctx, successTaskIDs, aicommentrecord.StatusSuccess); serr != nil { + log.Error("自动评论更新成功任务状态失败", log.E(serr), log.Any("taskIDs", successTaskIDs)) + } + } +} + +func loadPlayletsByResults(ctx context.Context, results []ResultItem, recordMap map[string]*aicommentrecord.AiCommentRecord) map[int64]*playletmod.Playlet { + ids := make([]int64, 0, len(results)) + seen := make(map[int64]struct{}) + for _, result := range results { + if len(result.Comments) == 0 { + continue + } + record := recordMap[result.ID] + if record == nil { + continue + } + id, err := strconv.ParseInt(record.Vid, 10, 64) + if err != nil || id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return map[int64]*playletmod.Playlet{} + } + list, serr := playletmod.FindManyPlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}) + if serr != nil { + log.Warn("自动评论查询短剧上架时间失败", log.E(serr), log.Any("ids", ids)) + return map[int64]*playletmod.Playlet{} + } + res := make(map[int64]*playletmod.Playlet, len(list)) + for _, playlet := range list { + res[playlet.Id] = playlet + } + return res +} + +func robotUsers(ctx context.Context) (usermod.UserList, error) { + users, serr := usermod.FindManyUser(ctx, bson.M{ + usermod.FN.User.Status: pubdef.UserStatusNormal, + usermod.FN.User.Id: bson.M{ + "$gte": pubdef.RobotId_Min, + "$lte": pubdef.RobotId_Max, + }, + }, options.Find().SetLimit(1000)) + if serr != nil { + return nil, serr + } + if len(users) > 0 { + return users, nil + } + user := createFallbackRobotUser() + if serr = usermod.InsertOneUser(ctx, user); serr != nil { + return nil, serr + } + return usermod.UserList{user}, nil +} + +func createFallbackRobotUser() *usermod.User { + now := time.Now() + return &usermod.User{ + Id: mongos.Lid.User.Get(), + Type: pubdef.UserTypeGuest, + Status: pubdef.UserStatusNormal, + NickName: "游客" + strconv.FormatInt(now.Unix(), 10), + Device: "robot", + SysType: "ios", + LoginType: pubdef.LoginTypeDevice, + LastLoginAt: now, + CreatedAt: now, + UpdatedAt: now, + } +} + +func commentUserName(user *usermod.User) string { + if user == nil { + return "" + } + if name := strings.TrimSpace(user.NickName); name != "" { + return name + } + if name := strings.TrimSpace(user.AccountNo); name != "" { + return name + } + return "用户" + strconv.FormatInt(user.Id, 10) +} + +func refreshPlayletCommentCount(ctx context.Context, playletID int64) *srvdef.SrvErr { + if playletID <= 0 { + return nil + } + count, serr := commentmod.CountComments(ctx, bson.M{ + commentmod.FN.Comment.ObjectId: playletID, + commentmod.FN.Comment.ObjectType: objectTypePlaylet, + commentmod.FN.Comment.ReviewStatus: commentReviewPass, + commentmod.FN.Comment.ParentsId: 0, + }) + if serr != nil { + log.Warn("自动评论统计短剧评论数失败", log.E(serr), log.Any("playletId", playletID)) + return serr + } + if serr = playletmod.UpdateOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: playletID, + }, bson.M{ + "$set": bson.M{playletmod.FN.Playlet.CountComment: count}, + }); serr != nil { + log.Warn("自动评论更新短剧评论数失败", log.E(serr), log.Any("playletId", playletID)) + return serr + } + return nil +} diff --git a/web/service/srv_aicomment/progress.go b/web/service/srv_aicomment/progress.go new file mode 100644 index 0000000..8256bb8 --- /dev/null +++ b/web/service/srv_aicomment/progress.go @@ -0,0 +1,100 @@ +package srv_aicomment + +import ( + "context" + "time" + + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/aicommentrecord" +) + +const ( + staleSubmittedTimeout = 60 * time.Minute + staleSubmittedDesc = "自动评论任务提交超过60分钟未返回,已释放并等待补交" +) + +type aiCommentContentKey struct { + objType int64 + vid string +} + +func preparePendingAICommentVideos(ctx context.Context, videos []VideoItem) ([]VideoItem, *srvdef.SrvErr) { + if serr := expireStaleSubmittedTasks(ctx); serr != nil { + return nil, serr + } + vidsByType := make(map[int64][]string) + for _, video := range videos { + if video.VideoId != "" { + vidsByType[video.ObjType] = append(vidsByType[video.ObjType], video.VideoId) + } + } + + recordsByContent := make(map[aiCommentContentKey]aicommentrecord.AiCommentRecordList) + for objType, vids := range vidsByType { + records, serr := aicommentrecord.FindByVIDsAndObjType(ctx, objType, vids) + if serr != nil { + return nil, serr + } + for _, record := range records { + if record == nil { + continue + } + key := aiCommentContentKey{objType: objType, vid: record.Vid} + recordsByContent[key] = append(recordsByContent[key], record) + } + } + + pending := make([]VideoItem, 0, len(videos)) + for _, video := range videos { + key := aiCommentContentKey{objType: video.ObjType, vid: video.VideoId} + if resumed, ok := resumeAICommentVideo(video, recordsByContent[key]); ok { + pending = append(pending, resumed) + } + } + return pending, nil +} + +func expireStaleSubmittedTasks(ctx context.Context) *srvdef.SrvErr { + deadline := time.Now().Add(-staleSubmittedTimeout) + expired, serr := aicommentrecord.ExpireSubmittedBefore(ctx, deadline, staleSubmittedDesc) + if serr != nil { + return serr + } + if expired > 0 { + log.Warn("自动评论释放超时提交任务", log.Any("count", expired), log.Any("deadline", deadline)) + } + return nil +} + +func resumeAICommentVideo(video VideoItem, records aicommentrecord.AiCommentRecordList) (VideoItem, bool) { + targetCount := aiCommentPlayletTotal + if video.CurrentCount >= int64(targetCount) { + return VideoItem{}, false + } + + accountedCount := 0 + for _, record := range records { + if record == nil { + continue + } + if record.Status != aicommentrecord.StatusSubmitted && record.Status != aicommentrecord.StatusSuccess { + continue + } + retainCount := record.RetainCount + if retainCount <= 0 { + retainCount = commentsPerGenerationTask + } + accountedCount += retainCount + } + if video.CurrentCount > 0 && accountedCount < int(video.CurrentCount) { + accountedCount = int(video.CurrentCount) + } + if accountedCount >= targetCount { + return VideoItem{}, false + } + + video.TargetCount = targetCount + video.CommentTotal = targetCount - accountedCount + return video, true +} diff --git a/web/service/srv_aicomment/progress_test.go b/web/service/srv_aicomment/progress_test.go new file mode 100644 index 0000000..fa75f49 --- /dev/null +++ b/web/service/srv_aicomment/progress_test.go @@ -0,0 +1,31 @@ +package srv_aicomment + +import ( + "testing" + + "videoweb/pub/models/aicommentrecord" +) + +func TestResumeAICommentVideo(t *testing.T) { + tests := []struct { + name string + current int64 + records aicommentrecord.AiCommentRecordList + want int + ok bool + }{ + {name: "new playlet", want: 3, ok: true}, + {name: "existing comments", current: 2, want: 1, ok: true}, + {name: "already full", current: 3}, + {name: "submitted task", records: aicommentrecord.AiCommentRecordList{{Status: aicommentrecord.StatusSubmitted, RetainCount: 3}}}, + {name: "failed task can retry", records: aicommentrecord.AiCommentRecordList{{Status: aicommentrecord.StatusFailed, RetainCount: 3}}, want: 3, ok: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := resumeAICommentVideo(VideoItem{CurrentCount: tt.current}, tt.records) + if ok != tt.ok || (ok && got.CommentTotal != tt.want) { + t.Fatalf("resumeAICommentVideo() = (%d, %v), want (%d, %v)", got.CommentTotal, ok, tt.want, tt.ok) + } + }) + } +} diff --git a/web/service/srv_aicomment/retry.go b/web/service/srv_aicomment/retry.go new file mode 100644 index 0000000..6b5b943 --- /dev/null +++ b/web/service/srv_aicomment/retry.go @@ -0,0 +1,66 @@ +package srv_aicomment + +import ( + "context" + "strconv" + + "videoweb/pub/common/log" + "videoweb/pub/models/aicommentrecord" + "videoweb/pub/models/playletmod" + + "go.mongodb.org/mongo-driver/bson" +) + +// RetryFailedPlaylets retries a bounded batch, oldest failure first. +func RetryFailedPlaylets(ctx context.Context) { + if !GetAiCommentService().Configured() { + return + } + records, serr := aicommentrecord.FindFailedForRetry(ctx, 100) + if serr != nil || len(records) == 0 { + return + } + ids := make([]int64, 0, len(records)) + for _, record := range records { + id, err := strconv.ParseInt(record.Vid, 10, 64) + if err != nil || id <= 0 { + _ = aicommentrecord.SetStatusByKey(ctx, record.Key, aicommentrecord.StatusAbandoned, "短剧ID无效") + continue + } + ids = append(ids, id) + } + if len(ids) == 0 { + return + } + playlets, serr := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": ids}, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }) + if serr != nil { + log.Warn("查询待重试短剧失败", log.E(serr)) + return + } + active := make(map[int64]struct{}, len(playlets)) + for _, playlet := range playlets { + active[playlet.Id] = struct{}{} + } + for _, record := range records { + id, err := strconv.ParseInt(record.Vid, 10, 64) + if err != nil || id <= 0 { + continue + } + if _, ok := active[id]; !ok { + _ = aicommentrecord.SetStatusByKey(ctx, record.Key, aicommentrecord.StatusAbandoned, "短剧已下架或不存在") + } + } + if len(active) == 0 { + return + } + activeIDs := make([]int64, 0, len(active)) + for id := range active { + activeIDs = append(activeIDs, id) + } + if err := AutoProcessUnsubmittedPlaylets(ctx, activeIDs); err != nil { + log.Warn("重试短剧自动评论失败", log.E(err), log.Any("count", len(activeIDs))) + } +} diff --git a/web/service/srv_aicomment/timeline.go b/web/service/srv_aicomment/timeline.go new file mode 100644 index 0000000..b2f554c --- /dev/null +++ b/web/service/srv_aicomment/timeline.go @@ -0,0 +1,55 @@ +package srv_aicomment + +import ( + "time" + + "videoweb/pub/models/playletmod" +) + +func PlayletPublishedAt(playlet *playletmod.Playlet) time.Time { + if playlet == nil { + return time.Time{} + } + if !playlet.OnlineAt.IsZero() { + return playlet.OnlineAt + } + if !playlet.ReviewAt.IsZero() { + return playlet.ReviewAt + } + return playlet.CreatedAt +} + +func ScatteredCommentTime(publishedAt, endAt time.Time, objectID, commentID int64, index, total int) time.Time { + if total <= 0 || index < 0 || index >= total || publishedAt.IsZero() || !publishedAt.Before(endAt) { + return endAt + } + + span := endAt.Sub(publishedAt) + segment := span / time.Duration(total) + segmentStart := segment * time.Duration(index) + segmentEnd := segment * time.Duration(index+1) + if index == total-1 { + segmentEnd = span + } + width := segmentEnd - segmentStart + if width <= 0 { + return publishedAt.Add(segmentStart) + } + + seed := scatterSeed(objectID, commentID, int64(index)) + offset := time.Duration(seed % uint64(width)) + return publishedAt.Add(segmentStart + offset) +} + +func scatterSeed(values ...int64) uint64 { + const ( + offsetBasis = uint64(1469598103934665603) + prime = uint64(1099511628211) + ) + hash := offsetBasis + for _, value := range values { + hash ^= uint64(value) + hash *= prime + } + return hash +} diff --git a/web/service/srv_aicomment/volume.go b/web/service/srv_aicomment/volume.go new file mode 100644 index 0000000..608cb4d --- /dev/null +++ b/web/service/srv_aicomment/volume.go @@ -0,0 +1,33 @@ +package srv_aicomment + +import ( + "math/rand" +) + +const ( + commentsPerGenerationTask = 8 + aiCommentPlayletTotal = 3 +) + +func selectAIComments(comments []Comment, count int) []Comment { + available := make([]Comment, 0, len(comments)) + for _, comment := range comments { + if comment.Text != "" { + available = append(available, comment) + } + } + if count <= 0 || len(available) <= count { + return available + } + rand.Shuffle(len(available), func(i, j int) { + available[i], available[j] = available[j], available[i] + }) + return available[:count] +} + +func randomIntInclusive(minValue, maxValue int) int { + if maxValue <= minValue { + return minValue + } + return minValue + rand.Intn(maxValue-minValue+1) +} diff --git a/web/service/srv_announcement/announcement.go b/web/service/srv_announcement/announcement.go new file mode 100644 index 0000000..14a900d --- /dev/null +++ b/web/service/srv_announcement/announcement.go @@ -0,0 +1,101 @@ +package srv_announcement + +import ( + "context" + "time" + "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, + } + return announcementmod.InsertOneAnnouncement(ctx, doc) +} + +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 + } + return announcementmod.UpdateOneAnnouncement(ctx, filter, bson.M{"$set": updateFields}) +} + +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, "", "") + } + 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..b9a314d --- /dev/null +++ b/web/service/srv_app/app.go @@ -0,0 +1,43 @@ +package srv_app + +import ( + "time" +) + +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"` // 排序 +} 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..6db85ac --- /dev/null +++ b/web/service/srv_cfg/define.go @@ -0,0 +1,90 @@ +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,omitempty"` // 回家的路PDF + ImgAppCdn *string `json:"imgAppCdn" bson:"imgAppCdn,omitempty"` // 图片CDN(app使用) + ImgAppUsCdn *string `json:"imgAppUsCdn" bson:"imgAppUsCdn,omitempty"` // 图片CDN(海外app使用) + ImgWebCdn *string `json:"imgWebCdn" bson:"imgWebCdn,omitempty"` // 图片CDN(web使用) + MediaWebCdn *string `json:"mediaWebCdn" bson:"mediaWebCdn,omitempty"` // 视频CDN(web使用) + MediaAppCdn *string `json:"mediaAppCdn" bson:"mediaAppCdn,omitempty"` // 视频CDN(app使用) + MediaAppUsCdn *string `json:"mediaAppUsCdn" bson:"mediaAppUsCdn,omitempty"` // 视频CDN(海外app使用) + WatermarkImage *string `json:"watermarkImage" bson:"watermarkImage,omitempty"` // 图片水印文件 + WatermarkMedia *string `json:"watermarkMedia" bson:"watermarkMedia,omitempty"` // 视频水印文件(底部右边) + WatermarkMediaTop *string `json:"watermarkMediaTop" bson:"watermarkMediaTop,omitempty"` // 视频水印文件(顶部左边) + WatermarkMediaTopRight *string `json:"watermarkMediaTopRight" bson:"watermarkMediaTopRight,omitempty"` // 视频水印文件(顶部右边) + WatermarkMediaBottomLeft *string `json:"watermarkMediaBottomLeft" bson:"watermarkMediaBottomLeft,omitempty"` // 视频水印文件(底部左边) + Mp4Addr *string `json:"mp4Addr" bson:"mp4Addr,omitempty"` // mp4地址(web使用) + CustomerServiceHost *string `json:"customerServiceHost" bson:"customerServiceHost,omitempty"` // 在线客服 + NewUrlAddr *string `json:"newUrlAddr" bson:"newUrlAddr,omitempty"` //最新地址 + NewUrlAddr1 *string `json:"newUrlAddr1" bson:"newUrlAddr1,omitempty"` //最新地址(备用地址1) + NewUrlAddr2 *string `json:"newUrlAddr2" bson:"newUrlAddr2,omitempty"` //最新地址(备用地址2) + ForeverUrlAddr *string `json:"foreverUrlAddr" bson:"foreverUrlAddr,omitempty"` //永久地址 + GmailAddr *string `json:"gmailAddr" bson:"gmailAddr,omitempty"` //邮箱地址 + AppAddr *string `json:"appAddr" bson:"appAddr,omitempty"` //APP下载地址(落地页) + OfficialAppAddr *string `json:"officialAppAddr" bson:"officialAppAddr,omitempty"` //官网app地址 + WeiChart *string `json:"weiChart" bson:"weiChart,omitempty"` //微信群 + QQ *string `json:"qq" bson:"qq,omitempty"` //qq群 + TgGroup *string `json:"tgGroup" bson:"tgGroup,omitempty"` //tg群 + PotaoGroup *string `json:"potaoGroup" bson:"potaoGroup,omitempty"` //土豆群 + GitAddr *string `json:"gitAddr" bson:"gitAddr,omitempty"` //git地址 + TwitterAddr *string `json:"twitterAddr" bson:"twitterAddr,omitempty"` //推特地址 + Telegram *string `json:"telegram" bson:"telegram,omitempty"` //商务tg + TelegramAdd *string `json:"telegramAdd" bson:"telegramAdd,omitempty"` //加入我们tg + TranslationAddr *string `json:"translationAddr" bson:"translationAddr,omitempty"` //应用导航 + //SEO + WebTitle *string `json:"webTitle" bson:"webTitle,omitempty"` // 网站SEO标题 + WebKeyworld *[]string `json:"webKeyworld" bson:"webKeyworld,omitempty"` // 网站SEO关键字 + WebDescription *string `json:"webDescription" bson:"webDescription,omitempty"` // 网站SEO描述 + WebSiteName *string `json:"webSiteName" bson:"webSiteName,omitempty"` // 网站SEO网站名称 + WebAddr *string `json:"webAddr" bson:"webAddr,omitempty"` // 网站SEO网站地址 + DHNewUrlAddr1 *string `json:"dhNewUrlAddr1" bson:"dhNewUrlAddr1,omitempty"` // 导航页-线路一 + DHNewUrlAddr2 *string `json:"dhNewUrlAddr2" bson:"dhNewUrlAddr2,omitempty"` // 导航页-线路2 + DHNewUrlAddr3 *string `json:"dhNewUrlAddr3" bson:"dhNewUrlAddr3,omitempty"` // 导航页-线路3 + FreeVPN *string `json:"freeVPN" bson:"freeVPN,omitempty"` // 免费vpn链接 + UseAddress *[]string `json:"useAddress" bson:"useAddress,omitempty"` // 所有使用中的域名 + AdvitiesTopicNum *int64 `json:"advitiesTopicNum" bson:"advitiesTopicNum,omitempty"` // 广告间隔 + DHTitle *string `json:"dhTitle" bson:"dhTitle,omitempty"` // 导航SEO标题 + DHKeyworld *[]string `json:"dhKeyworld" bson:"dhKeyworld,omitempty"` // 导航SEO关键字 + DHDescription *string `json:"dhDescription" bson:"dhDescription,omitempty"` // 导航SEO描述 + DHSiteName *string `json:"dhSiteName" bson:"dhSiteName,omitempty"` // 导航SEO网站名称 + ActressBackGround *string `json:"actressBackGround" bson:"actressBackGround,omitempty"` // 女优通用背景图 + ChannelCode *string `json:"channelCode" bson:"channelCode,omitempty"` // 渠道码 + EnableH265 *bool `json:"enableH265" bson:"enableH265,omitempty"` // 是否允许客户端使用 H265 地址播放 +} +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_cointrans/define.go b/web/service/srv_cointrans/define.go new file mode 100644 index 0000000..7debf5e --- /dev/null +++ b/web/service/srv_cointrans/define.go @@ -0,0 +1,26 @@ +package srv_cointrans + +import ( + "time" + "videoweb/pub/models/cointransmod" + "videoweb/pub/pubdef" +) + +type CoinTransReq struct { + pubdef.Page + UserId *int64 `json:"userId"` // 用户ID + FlowDir *cointransmod.FlowDir `json:"flowDir"` // 收支方向:1收入 -1支出 + TranType *cointransmod.TranType `json:"tranType"` // 交易类型 + StartTime *string `json:"startTime"` // 开始时间 2006-01-02 15:04:05 + EndTime *string `json:"endTime"` // 结束时间 2006-01-02 15:04:05 +} + +type CoinTransResp struct { + Total int64 `json:"total"` + List cointransmod.CoinTransList `json:"list"` +} + +func parseTime(s string) (time.Time, bool) { + t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local) + return t, err == nil +} diff --git a/web/service/srv_cointrans/trans.go b/web/service/srv_cointrans/trans.go new file mode 100644 index 0000000..98dd5e5 --- /dev/null +++ b/web/service/srv_cointrans/trans.go @@ -0,0 +1,62 @@ +package srv_cointrans + +import ( + "context" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/cointransmod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetCoinTrans 分页查询金币交易流水 +func GetCoinTrans(ctx context.Context, req *CoinTransReq) (*CoinTransResp, *srvdef.SrvErr) { + filter := bson.M{} + + if req.UserId != nil && *req.UserId > 0 { + filter[cointransmod.FN.CoinTrans.UserId] = *req.UserId + } + if req.FlowDir != nil { + filter[cointransmod.FN.CoinTrans.FlowDir] = *req.FlowDir + } + if req.TranType != nil { + filter[cointransmod.FN.CoinTrans.TranType] = *req.TranType + } + + timeFilter := bson.M{} + if req.StartTime != nil && *req.StartTime != "" { + if t, ok := parseTime(*req.StartTime); ok { + timeFilter["$gte"] = t + } + } + if req.EndTime != nil && *req.EndTime != "" { + if t, ok := parseTime(*req.EndTime); ok { + timeFilter["$lte"] = t + } + } + if len(timeFilter) > 0 { + filter[cointransmod.FN.CoinTrans.CreatedAt] = timeFilter + } + + total, serr := cointransmod.CountCoinTrans(ctx, filter) + if serr != nil { + return nil, serr + } + resp := &CoinTransResp{Total: total} + if total == 0 { + resp.List = cointransmod.CoinTransList{} + return resp, nil + } + + opt := options.Find(). + SetSort(bson.D{{Key: cointransmod.FN.CoinTrans.CreatedAt, Value: -1}}). + SetSkip((req.PageNum - 1) * req.PageSize). + SetLimit(req.PageSize) + + list, serr := cointransmod.FindManyCoinTrans(ctx, filter, opt) + if serr != nil { + return nil, serr + } + resp.List = list + return resp, nil +} diff --git a/web/service/srv_commend/commend.go b/web/service/srv_commend/commend.go new file mode 100644 index 0000000..9a2e5ff --- /dev/null +++ b/web/service/srv_commend/commend.go @@ -0,0 +1,429 @@ +package srv_commend + +import ( + "context" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/commentmod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + commentObjectTypePlaylet int64 = 10 // 短剧评论 + commentObjectTypePlayletChapter int64 = 11 // 短剧剧集评论 + commentObjectTypeVideo int64 = 20 // 视频评论 + commentReviewPass int = 1 // 审核通过 + commentLevelFirst int64 = 1 // 一级评论 +) + +// SearchComandList 查询后台评论列表 +func SearchComandList(ctx context.Context, param *CommendSearch) (*CommendSearchResp, *srvdef.SrvErr) { + + filter := bson.M{} + opt := &options.FindOptions{} + + // objectId 只表示业务对象ID,必须配合 objectType 才能准确区分短剧评论和剧集评论,避免不同集合ID碰撞。 + if param.ObjectId != nil { + filter[commentmod.FN.Comment.ObjectId] = *param.ObjectId + } + if param.ObjectType != nil { + filter[commentmod.FN.Comment.ObjectType] = *param.ObjectType + } + if param.UserId != nil && *param.UserId > 0 { + filter[commentmod.FN.Comment.UserId] = *param.UserId + } + + if param.Level != nil { + filter[commentmod.FN.Comment.Level] = *param.Level + } + + if param.ObjectName != nil { + filter[commentmod.FN.Comment.ObjectName] = bson.M{"$regex": *param.ObjectName} + } + 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), + } + + // countLike 是短剧评论点赞数的新字段,likes 保留旧字段兼容后台旧页面读取。 + for _, commendInfo := range commends { + tmp := &PostCommentInfo{ + Id: commendInfo.Id, + ObjectId: commendInfo.ObjectId, + ObjectType: commendInfo.ObjectType, + ObjectName: commendInfo.ObjectName, + UserId: commendInfo.UserId, + UserName: commendInfo.UserName, + UserAvatar: commendInfo.UserAvatar, + Level: commendInfo.Level, + ParentsId: commendInfo.ParentsId, + ReplyCommentId: commendInfo.ReplyCommentId, + ReplyUserName: commendInfo.ReplyUserName, + Text: commendInfo.Text, + ReviewStatus: commendInfo.ReviewStatus, + UpdatedAt: commendInfo.UpdatedAt, + Ip: commendInfo.Ip, + CreatedAt: commendInfo.CreatedAt, + CountLike: commendInfo.CountLike, + Likes: commendInfo.CountLike, + } + resp.List = append(resp.List, tmp) + } + + return resp, nil +} + +// DelCommend 删除一条评论 +func DelCommend(ctx context.Context, commendId int64) *srvdef.SrvErr { + // 先查出评论旧状态,再删除;后续扣减评论数需要依赖旧的 reviewStatus、level、objectType。 + 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 + } + + // 删除后按当前已审核通过评论数重算计数,避免历史增量失败或重复操作导致 countComment 不准。 + if serr = refreshCommentCountByComment(ctx, commendInfo); serr != nil { + return serr + } + + return nil +} + +// 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 + } + + commendId := make([]int64, 0) + // 删除用户全部评论后逐条重算受影响对象的计数;重复刷新同一对象只会 set 成相同值,不会重复扣减。 + for _, commendInfo := range commendList { + if serr = refreshCommentCountByComment(ctx, commendInfo); serr != nil { + return serr + } + commendId = append(commendId, commendInfo.Id) + } + return nil +} + +// AddPostComment 发布评论 +func AddPostComment(ctx context.Context, req *CommendReplay) *srvdef.SrvErr { + now := time.Now() + if req.Score > 10 { + log.Error("后台回复评论参数错误", log.Any("评分", req.Score)) + return srvdef.New(srvdef.ErrParamError, "", "") + } + + // 后台回复评论沿用旧的帖子评论逻辑,默认审核通过;短剧/剧集评论由 app 评论入口写入。 + replyCommentId := int64(0) + if req.ParentsId > 0 { + replyCommentId = req.ReplyCommentId + if replyCommentId <= 0 { + replyCommentId = req.ParentsId + } + } + commentInfo := &commentmod.CommentModel{ + Id: mongos.Lid.Comment.Get(), + ObjectId: req.ObjectId, + UserName: req.UserName, + ParentsId: req.ParentsId, + ReplyCommentId: replyCommentId, + Text: req.Text, + Level: 1, + UpdatedAt: now, + CreatedAt: now, + } + + if commentInfo.ParentsId > 0 { + commentInfo.Level = 2 + } else { + commentInfo.Level = 1 + } + commentInfo.ReviewStatus = 1 // 管理端的默认审核通过 + + // 插入回复评论成功后,再异步增加父评论回复数,避免回复失败时污染父评论计数。 + serr := commentmod.InsertOneComment(ctx, commentInfo) + if serr != nil { + log.Error("后台回复评论入库失败", log.Any("请求参数", req), log.Any("err", serr)) + return serr + } + + go func() { + 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("后台回复评论更新父评论回复数失败", log.Any("请求参数", req), log.Any("err", serr)) + return + } + } + + }() + return nil +} + +// 置顶评论 +func TopPostComment(ctx context.Context, id int64) *srvdef.SrvErr { + // 置顶排序采用递增 topRank,新的置顶评论排在当前最高 topRank 前面。 + filter := bson.M{ + commentmod.FN.Comment.Id: id, + } + comment, serr := commentmod.FindOneComment(ctx, filter) + if serr != nil { + return serr + } + // 获取当前对象下已置顶排序最高的一条评论,用于生成新的 topRank。 + 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 + } + return nil +} + +func getTopRankComment(ctx context.Context, objectId int64) (*commentmod.CommentModel, *srvdef.SrvErr) { + // 只按 objectId 查是历史逻辑;短剧/剧集评论如果后续支持置顶,建议再补 objectType 避免ID碰撞。 + 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, serr := commentmod.FindOneComment(ctx, bson.M{commentmod.FN.Comment.Id: id}) + if serr != nil { + return serr + } + 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), log.Any("err", serr)) + return serr + } + comment.ReviewStatus = reviewStatus + // 审核后按当前已通过评论数重算,重复审核同一状态也能修正历史漏加或漏减的 countComment。 + if serr = refreshCommentCountByComment(ctx, comment); serr != nil { + return serr + } + + return nil +} + +// ReviewCommendBatch 批量更新 +func ReviewCommendBatch(ctx context.Context, commendIds *[]int64, reviewStatus int) *srvdef.SrvErr { + if commendIds == nil || len(*commendIds) == 0 { + return srvdef.New(srvdef.ErrParamError, "评论ID不能为空", "") + } + // 批量审核也要先查旧数据,统一用每条评论的旧审核状态计算评论数增减。 + commentList, serr := commentmod.FindManyComment(ctx, bson.M{ + commentmod.FN.Comment.Id: bson.M{"$in": *commendIds}, + }) + if serr != nil { + return serr + } + 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("评论ID集合", *commendIds), log.Any("审核状态", reviewStatus), log.Any("err", serr)) + return serr + } + // 审核状态更新成功后再按对象重算计数,避免状态未更新成功时提前改动短剧/剧集评论数。 + for _, comment := range commentList { + if comment == nil { + continue + } + if serr = refreshCommentCountByComment(ctx, comment); serr != nil { + return serr + } + } + + return nil +} + +func refreshCommentCountByComment(ctx context.Context, comment *commentmod.CommentModel) *srvdef.SrvErr { + // 根据评论归属重算对应计数:二级评论刷新父评论回复数,一级短剧/剧集评论刷新业务对象评论数。 + if comment == nil { + return nil + } + if comment.Level > commentLevelFirst && comment.ParentsId > 0 { + return refreshParentCommentCount(ctx, comment.ParentsId) + } + if isSupportedCommentType(comment.ObjectType) { + return refreshCommentTargetCount(ctx, comment.ObjectType, comment.ObjectId) + } + return nil +} + +func refreshParentCommentCount(ctx context.Context, parentId int64) *srvdef.SrvErr { + // 父评论回复数以当前审核通过的子评论数量为准,直接 set 可以修正历史增量不一致问题。 + if parentId <= 0 { + return nil + } + count, serr := commentmod.CountComments(ctx, bson.M{ + commentmod.FN.Comment.ParentsId: parentId, + commentmod.FN.Comment.ReviewStatus: commentReviewPass, + }) + if serr != nil { + log.Error("统计父评论回复数失败", log.Any("父评论ID", parentId), log.E(serr)) + return serr + } + serr = commentmod.UpdateOneComment(ctx, bson.M{ + commentmod.FN.Comment.Id: parentId, + }, bson.M{ + "$set": bson.M{commentmod.FN.Comment.CommentCount: count}, + }) + if serr != nil { + if serr.Code == srvdef.ErrNoData { + log.Warn("父评论不存在,跳过刷新回复数", log.Any("父评论ID", parentId), log.Any("回复数", count)) + return nil + } + log.Error("刷新父评论回复数失败", log.Any("父评论ID", parentId), log.Any("回复数", count), log.E(serr)) + return serr + } + return nil +} + +func refreshCommentTargetCount(ctx context.Context, objectType, objectId int64) *srvdef.SrvErr { + // 评论数以当前审核通过的一级评论数量为准,审核通过、驳回和删除都走同一套重算逻辑。 + count, serr := commentmod.CountComments(ctx, bson.M{ + commentmod.FN.Comment.ObjectId: objectId, + commentmod.FN.Comment.ObjectType: objectType, + commentmod.FN.Comment.ReviewStatus: commentReviewPass, + commentmod.FN.Comment.ParentsId: 0, + }) + if serr != nil { + log.Error("统计评论数失败", log.Any("对象类型", objectType), log.Any("对象ID", objectId), log.E(serr)) + return serr + } + return setCommentTargetCount(ctx, objectType, objectId, count) +} + +func setCommentTargetCount(ctx context.Context, objectType, objectId int64, count int64) *srvdef.SrvErr { + // 根据对象类型把重算后的评论数写回业务集合。 + switch objectType { + case commentObjectTypePlaylet: + serr := playletmod.UpdateOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: objectId, + }, bson.M{ + "$set": bson.M{playletmod.FN.Playlet.CountComment: count}, + }) + if serr != nil { + log.Error("刷新短剧评论数失败", log.Any("短剧ID", objectId), log.Any("评论数", count), log.E(serr)) + return serr + } + case commentObjectTypePlayletChapter: + serr := playletchaptermod.UpdateOnePlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.Id: objectId, + }, bson.M{ + "$set": bson.M{playletchaptermod.FN.PlayletChapter.CountComment: count}, + }) + if serr != nil { + log.Error("刷新剧集评论数失败", log.Any("剧集ID", objectId), log.Any("评论数", count), log.E(serr)) + return serr + } + case commentObjectTypeVideo: + serr := videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{ + videoinformationmod.FN.VideoInformation.Id: objectId, + }, bson.M{ + "$set": bson.M{videoinformationmod.FN.VideoInformation.Real.Comments: count}, + }) + if serr != nil { + log.Error("刷新视频评论数失败", log.Any("视频ID", objectId), log.Any("评论数", count), log.E(serr)) + return serr + } + } + return nil +} + +func isPlayletCommentType(objectType int64) bool { + return objectType == commentObjectTypePlaylet || objectType == commentObjectTypePlayletChapter +} + +func isSupportedCommentType(objectType int64) bool { + return isPlayletCommentType(objectType) || objectType == commentObjectTypeVideo +} diff --git a/web/service/srv_commend/define.go b/web/service/srv_commend/define.go new file mode 100644 index 0000000..3f21af4 --- /dev/null +++ b/web/service/srv_commend/define.go @@ -0,0 +1,80 @@ +package srv_commend + +import ( + "time" + "videoweb/pub/pubdef" +) + +// CommendSearch 查询评论 +type CommendSearch struct { + ObjectId *int64 `json:"objectId" bson:"objectId"` // 对象ID + ObjectType *int64 `json:"objectType"` // 对象类型 10-短剧 11-短剧剧集 20-视频 + ObjectName *string `json:"objectName" bson:"objectName"` // 对象名(模糊) + UserId *int64 `json:"userId"` // 评论用户ID + 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 + ObjectId int64 `json:"objectId" bson:"objectId"` // 对象ID + ObjectType int64 `json:"objectType" bson:"objectType"` // 对象类型 10-短剧 11-短剧剧集 20-视频 + ObjectName string `json:"objectName" bson:"objectName"` // 对象名 + UserId int64 `json:"userId" bson:"userId"` // 评论用户ID + UserName string `json:"userName" bson:"userName"` // 用户名称 + UserAvatar string `json:"userAvatar" bson:"userAvatar"` // 评论用户头像 + Level int64 `json:"level" bson:"level"` // 评论级别 + ParentsId int64 `json:"parentsId" bson:"parentsId"` // 上级评论ID + ReplyCommentId int64 `json:"replyCommentId" bson:"replyCommentId"` // 被回复评论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"` // 点赞数 + CountLike int64 `json:"countLike" bson:"countLike"` // 评论点赞数 + 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 + ReplyCommentId int64 `json:"replyCommentId"` // 被回复评论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..759e450 --- /dev/null +++ b/web/service/srv_contactus/contactus.go @@ -0,0 +1,273 @@ +package srv_contactus + +import ( + "context" + "regexp" + "strings" + "time" + "videoweb/pub/common/response" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/contactusmod" + "videoweb/pub/models/usermod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + // contactUsDefaultPageSize web端联系我们列表默认每页数量 + contactUsDefaultPageSize int64 = 10 + // contactUsMaxPageSize web端联系我们列表最大每页数量,避免单次请求拉取过多数据 + contactUsMaxPageSize int64 = 100 +) + +var ( + // contactUsWebProjection 只读取web联系我们列表需要展示的字段,减少MongoDB传输开销。 + contactUsWebProjection = bson.D{ + {Key: contactusmod.FN.ContactUs.Id, Value: 1}, + {Key: contactusmod.FN.ContactUs.UserId, Value: 1}, + {Key: contactusmod.FN.ContactUs.Email, Value: 1}, + {Key: contactusmod.FN.ContactUs.QuestionType, Value: 1}, + {Key: contactusmod.FN.ContactUs.Title, Value: 1}, + {Key: contactusmod.FN.ContactUs.Content, Value: 1}, + {Key: contactusmod.FN.ContactUs.Attachments, Value: 1}, + {Key: contactusmod.FN.ContactUs.Status, Value: 1}, + {Key: contactusmod.FN.ContactUs.Remark, Value: 1}, + {Key: contactusmod.FN.ContactUs.CreatedAt, Value: 1}, + {Key: contactusmod.FN.ContactUs.UpdatedAt, Value: 1}, + } + + // contactUsWebSort web端联系我们列表按创建时间倒序返回。 + contactUsWebSort = bson.D{ + {Key: contactusmod.FN.ContactUs.CreatedAt, Value: -1}, + {Key: contactusmod.FN.ContactUs.Id, Value: -1}, + } +) + +// ListContactUs 分页查询联系我们列表 +func ListContactUs(ctx context.Context, req *proto.ContactUsListReq) (*proto.ContactUsListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter, filterErr := buildContactUsListFilter(req) + if filterErr != nil { + return nil, filterErr + } + pageNum, pageSize := normalizeContactUsPage(req.PageNum, req.PageSize) + totalCount, err := contactusmod.CountContactUs(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.ContactUsListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.ContactUsListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + list, err := contactusmod.FindManyContactUs(ctx, filter, options.Find(). + SetProjection(contactUsWebProjection). + SetSort(contactUsWebSort). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + userNameMap, err := buildContactUsUserNameMap(ctx, list) + if err != nil { + return nil, err + } + resp.List = contactUsListToWebResp(list, userNameMap) + return resp, nil +} + +// HandleContactUs 批量更新联系我们处理状态 +func HandleContactUs(ctx context.Context, req *proto.ContactUsHandleReq) (*proto.ContactUsHandleResp, *srvdef.SrvErr) { + if req == nil || len(req.Ids) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空") + } + if req.Status != 0 && req.Status != 1 { + return nil, srvdef.New(srvdef.ErrParamError, "status无效") + } + ids, err := normalizeContactUsIds(req.Ids) + if err != nil { + return nil, err + } + matchedCount, modifiedCount, err := contactusmod.UpdateManyContactUsWithResult(ctx, bson.M{ + contactusmod.FN.ContactUs.Id: bson.M{"$in": ids}, + }, bson.M{ + "$set": bson.M{ + contactusmod.FN.ContactUs.Status: req.Status, + contactusmod.FN.ContactUs.Remark: strings.TrimSpace(req.Remark), + contactusmod.FN.ContactUs.UpdatedAt: time.Now(), + }, + }) + if err != nil { + return nil, err + } + if matchedCount == 0 { + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &proto.ContactUsHandleResp{ + MatchedCount: matchedCount, + ModifiedCount: modifiedCount, + }, nil +} + +// buildContactUsListFilter 构造web端联系我们列表查询条件 +func buildContactUsListFilter(req *proto.ContactUsListReq) (bson.M, *srvdef.SrvErr) { + filter := bson.M{} + if req.UserId != nil && *req.UserId > 0 { + filter[contactusmod.FN.ContactUs.UserId] = *req.UserId + } + if email := strings.TrimSpace(req.Email); email != "" { + filter[contactusmod.FN.ContactUs.Email] = bson.M{"$regex": regexp.QuoteMeta(email)} + } + if req.Type != nil { + if !isValidContactUsType(*req.Type) { + return nil, srvdef.New(srvdef.ErrParamError, "type无效") + } + filter[contactusmod.FN.ContactUs.QuestionType] = *req.Type + } + if req.Status != nil { + if *req.Status != 0 && *req.Status != 1 { + return nil, srvdef.New(srvdef.ErrParamError, "status无效") + } + filter[contactusmod.FN.ContactUs.Status] = *req.Status + } + createdAt, err := buildContactUsTimeFilter(req.CreateStartTime, req.CreateEndTime) + if err != nil { + return nil, err + } + if len(createdAt) > 0 { + filter[contactusmod.FN.ContactUs.CreatedAt] = createdAt + } + return filter, nil +} + +// buildContactUsTimeFilter 按开始时间和结束时间构造createdAt范围查询 +func buildContactUsTimeFilter(startTime, endTime string) (bson.M, *srvdef.SrvErr) { + filter := bson.M{} + startTime = strings.TrimSpace(startTime) + endTime = strings.TrimSpace(endTime) + if startTime != "" { + startAt, err := parseContactUsWebTime(startTime) + if err != nil { + return nil, srvdef.New(srvdef.ErrParamError, "createStartTime格式错误") + } + filter["$gte"] = startAt + } + if endTime != "" { + endAt, err := parseContactUsWebTime(endTime) + if err != nil { + return nil, srvdef.New(srvdef.ErrParamError, "createEndTime格式错误") + } + filter["$lte"] = endAt + } + return filter, nil +} + +func parseContactUsWebTime(value string) (time.Time, error) { + return time.ParseInLocation("2006-01-02 15:04:05", value, time.Local) +} + +func normalizeContactUsPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = contactUsDefaultPageSize + } + if pageSize > contactUsMaxPageSize { + pageSize = contactUsMaxPageSize + } + return pageNum, pageSize +} + +func normalizeContactUsIds(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 +} + +func isValidContactUsType(questionType int32) bool { + return questionType >= 1 && questionType <= 5 +} + +// buildContactUsUserNameMap 批量查询用户名称,避免列表转换时逐条查询用户。 +func buildContactUsUserNameMap(ctx context.Context, list contactusmod.ContactUsList) (map[int64]string, *srvdef.SrvErr) { + userIds := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, item := range list { + if item == nil || item.UserId <= 0 { + continue + } + if _, ok := seen[item.UserId]; ok { + continue + } + seen[item.UserId] = struct{}{} + userIds = append(userIds, item.UserId) + } + userNameMap := make(map[int64]string, len(userIds)) + if len(userIds) == 0 { + return userNameMap, nil + } + users, err := usermod.FindManyUser(ctx, bson.M{ + usermod.FN.User.Id: bson.M{"$in": userIds}, + }, options.Find().SetProjection(bson.M{ + usermod.FN.User.Id: 1, + usermod.FN.User.NickName: 1, + })) + if err != nil { + return nil, err + } + for _, user := range users { + if user != nil { + userNameMap[user.Id] = user.NickName + } + } + return userNameMap, nil +} + +// contactUsListToWebResp 转换联系我们列表响应 +func contactUsListToWebResp(list contactusmod.ContactUsList, userNameMap map[int64]string) []*proto.ContactUsListResp { + resp := make([]*proto.ContactUsListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + resp = append(resp, &proto.ContactUsListResp{ + Id: item.Id, + UserId: item.UserId, + UserName: userNameMap[item.UserId], + Email: item.Email, + Type: item.QuestionType, + Title: item.Title, + Content: item.Content, + Attachments: item.Attachments, + Status: item.Status, + Remark: item.Remark, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }) + } + return resp +} 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_feedback/feedback.go b/web/service/srv_feedback/feedback.go new file mode 100644 index 0000000..0f6dc3c --- /dev/null +++ b/web/service/srv_feedback/feedback.go @@ -0,0 +1,271 @@ +package srv_feedback + +import ( + "context" + "strings" + "time" + "videoweb/pub/common/response" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/feedbackmod" + "videoweb/pub/models/usermod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + // feedbackDefaultPageSize web端意见反馈列表默认每页数量 + feedbackDefaultPageSize int64 = 10 + // feedbackMaxPageSize web端意见反馈列表最大每页数量,避免单次请求拉取过多数据 + feedbackMaxPageSize int64 = 100 +) + +var ( + // feedbackWebProjection 只读取web意见反馈列表需要展示的字段,减少MongoDB传输开销。 + feedbackWebProjection = bson.D{ + {Key: feedbackmod.FN.Feedback.Id, Value: 1}, + {Key: feedbackmod.FN.Feedback.UserId, Value: 1}, + {Key: feedbackmod.FN.Feedback.FeedbackType, Value: 1}, + {Key: feedbackmod.FN.Feedback.Content, Value: 1}, + {Key: feedbackmod.FN.Feedback.Screenshots, Value: 1}, + {Key: feedbackmod.FN.Feedback.Contact, Value: 1}, + {Key: feedbackmod.FN.Feedback.Status, Value: 1}, + {Key: feedbackmod.FN.Feedback.Remark, Value: 1}, + {Key: feedbackmod.FN.Feedback.CreatedAt, Value: 1}, + {Key: feedbackmod.FN.Feedback.UpdatedAt, Value: 1}, + } + + // feedbackWebSort web端意见反馈列表按创建时间倒序返回。 + feedbackWebSort = bson.D{ + {Key: feedbackmod.FN.Feedback.CreatedAt, Value: -1}, + {Key: feedbackmod.FN.Feedback.Id, Value: -1}, + } +) + +// ListFeedback 分页查询意见反馈列表 +func ListFeedback(ctx context.Context, req *proto.FeedbackListReq) (*proto.FeedbackListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter, filterErr := buildFeedbackListFilter(req) + if filterErr != nil { + return nil, filterErr + } + pageNum, pageSize := normalizeFeedbackPage(req.PageNum, req.PageSize) + totalCount, err := feedbackmod.CountFeedback(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.FeedbackListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.FeedbackListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + list, err := feedbackmod.FindManyFeedback(ctx, filter, options.Find(). + SetProjection(feedbackWebProjection). + SetSort(feedbackWebSort). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + userNameMap, err := buildFeedbackUserNameMap(ctx, list) + if err != nil { + return nil, err + } + resp.List = feedbackListToWebResp(list, userNameMap) + return resp, nil +} + +// HandleFeedback 批量更新意见反馈处理状态 +func HandleFeedback(ctx context.Context, req *proto.FeedbackHandleReq) (*proto.FeedbackHandleResp, *srvdef.SrvErr) { + if req == nil || len(req.Ids) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "ids不能为空") + } + if req.Status != 0 && req.Status != 1 { + return nil, srvdef.New(srvdef.ErrParamError, "status无效") + } + ids, err := normalizeFeedbackIds(req.Ids) + if err != nil { + return nil, err + } + matchedCount, modifiedCount, err := feedbackmod.UpdateManyFeedbackWithResult(ctx, bson.M{ + feedbackmod.FN.Feedback.Id: bson.M{"$in": ids}, + }, bson.M{ + "$set": bson.M{ + feedbackmod.FN.Feedback.Status: req.Status, + feedbackmod.FN.Feedback.Remark: strings.TrimSpace(req.Remark), + feedbackmod.FN.Feedback.UpdatedAt: time.Now(), + }, + }) + if err != nil { + return nil, err + } + if matchedCount == 0 { + return nil, srvdef.New(srvdef.ErrNoData, "", "") + } + return &proto.FeedbackHandleResp{ + MatchedCount: matchedCount, + ModifiedCount: modifiedCount, + }, nil +} + +// buildFeedbackListFilter 构造web端意见反馈列表查询条件 +func buildFeedbackListFilter(req *proto.FeedbackListReq) (bson.M, *srvdef.SrvErr) { + filter := bson.M{} + if req.UserId != nil && *req.UserId > 0 { + filter[feedbackmod.FN.Feedback.UserId] = *req.UserId + } + if req.Type != nil { + if !isValidFeedbackType(*req.Type) { + return nil, srvdef.New(srvdef.ErrParamError, "type无效") + } + filter[feedbackmod.FN.Feedback.FeedbackType] = *req.Type + } + if req.Status != nil { + if *req.Status != 0 && *req.Status != 1 { + return nil, srvdef.New(srvdef.ErrParamError, "status无效") + } + filter[feedbackmod.FN.Feedback.Status] = *req.Status + } + createdAt, err := buildFeedbackTimeFilter(req.CreateStartTime, req.CreateEndTime) + if err != nil { + return nil, err + } + if len(createdAt) > 0 { + filter[feedbackmod.FN.Feedback.CreatedAt] = createdAt + } + return filter, nil +} + +// buildFeedbackTimeFilter 按开始时间和结束时间构造createdAt范围查询 +func buildFeedbackTimeFilter(startTime, endTime string) (bson.M, *srvdef.SrvErr) { + filter := bson.M{} + startTime = strings.TrimSpace(startTime) + endTime = strings.TrimSpace(endTime) + if startTime != "" { + startAt, err := parseFeedbackWebTime(startTime) + if err != nil { + return nil, srvdef.New(srvdef.ErrParamError, "createStartTime格式错误") + } + filter["$gte"] = startAt + } + if endTime != "" { + endAt, err := parseFeedbackWebTime(endTime) + if err != nil { + return nil, srvdef.New(srvdef.ErrParamError, "createEndTime格式错误") + } + filter["$lte"] = endAt + } + return filter, nil +} + +func parseFeedbackWebTime(value string) (time.Time, error) { + return time.ParseInLocation("2006-01-02 15:04:05", value, time.Local) +} + +func normalizeFeedbackPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = feedbackDefaultPageSize + } + if pageSize > feedbackMaxPageSize { + pageSize = feedbackMaxPageSize + } + return pageNum, pageSize +} + +func normalizeFeedbackIds(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 +} + +func isValidFeedbackType(feedbackType int32) bool { + return feedbackType >= 1 && feedbackType <= 5 +} + +// buildFeedbackUserNameMap 批量查询反馈用户名称,避免列表转换时逐条查询用户。 +func buildFeedbackUserNameMap(ctx context.Context, list feedbackmod.FeedbackList) (map[int64]string, *srvdef.SrvErr) { + userIds := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, item := range list { + if item == nil || item.UserId <= 0 { + continue + } + if _, ok := seen[item.UserId]; ok { + continue + } + seen[item.UserId] = struct{}{} + userIds = append(userIds, item.UserId) + } + userNameMap := make(map[int64]string, len(userIds)) + if len(userIds) == 0 { + return userNameMap, nil + } + users, err := usermod.FindManyUser(ctx, bson.M{ + usermod.FN.User.Id: bson.M{"$in": userIds}, + }, options.Find().SetProjection(bson.M{ + usermod.FN.User.Id: 1, + usermod.FN.User.NickName: 1, + })) + if err != nil { + return nil, err + } + for _, user := range users { + if user != nil { + userNameMap[user.Id] = user.NickName + } + } + return userNameMap, nil +} + +// feedbackListToWebResp 转换意见反馈列表响应 +func feedbackListToWebResp(list feedbackmod.FeedbackList, userNameMap map[int64]string) []*proto.FeedbackListResp { + resp := make([]*proto.FeedbackListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + screenshots := item.Screenshots + if screenshots == nil { + screenshots = make([]string, 0) + } + resp = append(resp, &proto.FeedbackListResp{ + Id: item.Id, + UserId: item.UserId, + UserName: userNameMap[item.UserId], + Type: item.FeedbackType, + Content: item.Content, + Screenshots: screenshots, + Contact: item.Contact, + Status: item.Status, + Remark: item.Remark, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }) + } + return resp +} diff --git a/web/service/srv_homebanner/banner.go b/web/service/srv_homebanner/banner.go new file mode 100644 index 0000000..c387168 --- /dev/null +++ b/web/service/srv_homebanner/banner.go @@ -0,0 +1,238 @@ +package srv_homebanner + +import ( + "context" + "regexp" + "strings" + "time" + "videoweb/pub/common/playletcache" + "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 + } + clearHomeBannerCache() + 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 + if err := homebannermod.UpdateOneHomeBanner(ctx, + bson.M{homebannermod.FN.HomeBanner.Id: req.Id}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearHomeBannerCache() + return nil +} + +// 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, "", "") + } + clearHomeBannerCache() + 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 +} + +// clearHomeBannerCache 清除短剧首页缓存 +func clearHomeBannerCache() { + playletcache.ClearHomeCache() +} diff --git a/web/service/srv_homeconfig/home_config.go b/web/service/srv_homeconfig/home_config.go new file mode 100644 index 0000000..4746a33 --- /dev/null +++ b/web/service/srv_homeconfig/home_config.go @@ -0,0 +1,368 @@ +package srv_homeconfig + +import ( + "context" + "regexp" + "strings" + "time" + "videoweb/pub/common/playletcache" + "videoweb/pub/common/response" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/homeconfigmod" + "videoweb/pub/pubdef" + "videoweb/pub/seo/sitemapdirty" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + defaultHomeConfigPageSize int64 = 10 + maxHomeConfigPageSize int64 = 100 +) + +var homeConfigWebProjection = bson.D{ + {Key: homeconfigmod.FN.HomeConfig.Id, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Name, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Slug, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Desc, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Keyword, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Label, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Style, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.CardBackgroundImage, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.TopicIds, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.CategoryId, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.RecommendType, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Type, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Rank, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Limit, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.SortType, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.Status, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.SeoInfo.Title, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.SeoInfo.Description, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.SeoInfo.Keyworld, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.CreatedAt, Value: 1}, + {Key: homeconfigmod.FN.HomeConfig.UpdatedAt, Value: 1}, +} + +// ListHomeConfig 分页查询首页配置列表 +func ListHomeConfig(ctx context.Context, req *proto.HomeConfigListReq) (*proto.HomeConfigListPageResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + filter := bson.M{ + homeconfigmod.FN.HomeConfig.Type: bson.M{"$in": []int32{homeconfigmod.TypePlaylet, homeconfigmod.TypeTheme}}, + } + if req.Id != nil && *req.Id > 0 { + filter[homeconfigmod.FN.HomeConfig.Id] = *req.Id + } + if req.Type != nil { + if !validHomeConfigType(*req.Type) { + return nil, srvdef.New(srvdef.ErrParamError, "类型无效", "") + } + filter[homeconfigmod.FN.HomeConfig.Type] = *req.Type + } + if req.SortType != nil { + if !validHomeConfigSortType(*req.SortType) { + return nil, srvdef.New(srvdef.ErrParamError, "排序字段无效", "") + } + filter[homeconfigmod.FN.HomeConfig.SortType] = *req.SortType + } + if req.Status != nil { + filter[homeconfigmod.FN.HomeConfig.Status] = *req.Status + } + if name := strings.TrimSpace(req.Name); name != "" { + filter[homeconfigmod.FN.HomeConfig.Name] = bson.M{"$regex": regexp.QuoteMeta(name)} + } + if slug := strings.TrimSpace(req.Slug); slug != "" { + filter[homeconfigmod.FN.HomeConfig.Slug] = slug + } + pageNum, pageSize := normalizeHomeConfigPage(req.PageNum, req.PageSize) + totalCount, err := homeconfigmod.CountHomeConfig(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.HomeConfigListPageResp{ + BasePageResp: response.BasePageResp{ + TotalCount: totalCount, + PageSize: int(pageSize), + PageNum: int(pageNum), + List: make([]*proto.HomeConfigListResp, 0), + }, + } + if totalCount == 0 { + return resp, nil + } + list, err := homeconfigmod.FindManyHomeConfig(ctx, filter, options.Find(). + SetProjection(homeConfigWebProjection). + SetSort(bson.D{ + {Key: homeconfigmod.FN.HomeConfig.Rank, Value: -1}, + {Key: homeconfigmod.FN.HomeConfig.UpdatedAt, Value: -1}, + {Key: homeconfigmod.FN.HomeConfig.Id, Value: -1}, + }). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + items := make([]*proto.HomeConfigListResp, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + items = append(items, &proto.HomeConfigListResp{ + Id: item.Id, + Name: item.Name, + Slug: item.Slug, + Desc: item.Desc, + Keyword: homeconfigmod.NormalizeKeywords(item.Keyword), + Label: item.Label, + Style: item.Style, + CardBackgroundImage: item.CardBackgroundImage, + TopicIds: item.TopicIds, + CategoryId: item.CategoryId, + RecommendType: item.RecommendType, + Type: item.Type, + Rank: item.Rank, + Limit: item.Limit, + SortType: item.SortType, + Status: item.Status, + SeoInfo: normalizeHomeConfigSeoValue(item.SeoInfo), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }) + } + resp.List = items + return resp, nil +} + +// AddHomeConfig 新增首页配置 +func AddHomeConfig(ctx context.Context, req *proto.HomeConfigAddReq) (*proto.HomeConfigAddResp, *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 !validHomeConfigType(req.Type) { + return nil, srvdef.New(srvdef.ErrParamError, "类型无效", "") + } + if !validHomeConfigSortType(req.SortType) { + return nil, srvdef.New(srvdef.ErrParamError, "排序字段无效", "") + } + if req.Limit < 0 { + return nil, srvdef.New(srvdef.ErrParamError, "展示数量不能小于0", "") + } + if req.Status == nil { + return nil, srvdef.New(srvdef.ErrParamError, "状态不能为空", "") + } + doc := &homeconfigmod.HomeConfig{ + Name: name, + Slug: strings.TrimSpace(req.Slug), + Desc: strings.TrimSpace(req.Desc), + Keyword: homeconfigmod.NormalizeKeywords(req.Keyword), + Label: strings.TrimSpace(req.Label), + Style: strings.ToUpper(strings.TrimSpace(req.Style)), + CardBackgroundImage: strings.TrimSpace(req.CardBackgroundImage), + TopicIds: req.TopicIds, + CategoryId: req.CategoryId, + RecommendType: req.RecommendType, + Type: req.Type, + Rank: req.Rank, + Limit: req.Limit, + SortType: req.SortType, + Status: *req.Status, + SeoInfo: normalizeHomeConfigSeoValue(req.SeoInfo), + } + if err := ensureHomeConfigSlug(ctx, doc, 0); err != nil { + return nil, err + } + if err := validateHomeConfigTopics(ctx, doc); err != nil { + return nil, err + } + if err := homeconfigmod.InsertOneHomeConfig(ctx, doc); err != nil { + return nil, err + } + clearHomeConfigCache() + sitemapdirty.Mark(ctx) + return &proto.HomeConfigAddResp{Id: doc.Id}, nil +} + +// UpdateHomeConfig 更新首页配置 +func UpdateHomeConfig(ctx context.Context, req *proto.HomeConfigUpdateReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "首页配置 ID无效", "") + } + info, err := homeconfigmod.FindOneHomeConfig(ctx, bson.M{homeconfigmod.FN.HomeConfig.Id: req.Id}) + if err != nil { + return err + } + setDoc := bson.M{homeconfigmod.FN.HomeConfig.UpdatedAt: time.Now()} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return srvdef.New(srvdef.ErrParamError, "名称不能为空", "") + } + setDoc[homeconfigmod.FN.HomeConfig.Name] = name + } + if req.Desc != nil { + setDoc[homeconfigmod.FN.HomeConfig.Desc] = strings.TrimSpace(*req.Desc) + } + if req.Keyword != nil { + setDoc[homeconfigmod.FN.HomeConfig.Keyword] = homeconfigmod.NormalizeKeywords(*req.Keyword) + } + if req.Label != nil { + setDoc[homeconfigmod.FN.HomeConfig.Label] = strings.TrimSpace(*req.Label) + } + if req.Style != nil { + setDoc[homeconfigmod.FN.HomeConfig.Style] = strings.ToUpper(strings.TrimSpace(*req.Style)) + } + if req.CardBackgroundImage != nil { + setDoc[homeconfigmod.FN.HomeConfig.CardBackgroundImage] = strings.TrimSpace(*req.CardBackgroundImage) + } + if req.TopicIds != nil { + setDoc[homeconfigmod.FN.HomeConfig.TopicIds] = *req.TopicIds + } + if req.CategoryId != nil { + setDoc[homeconfigmod.FN.HomeConfig.CategoryId] = *req.CategoryId + } + if req.RecommendType != nil { + setDoc[homeconfigmod.FN.HomeConfig.RecommendType] = *req.RecommendType + } + if req.Type != nil { + if !validHomeConfigType(*req.Type) { + return srvdef.New(srvdef.ErrParamError, "类型无效", "") + } + setDoc[homeconfigmod.FN.HomeConfig.Type] = *req.Type + } + if req.Rank != nil { + setDoc[homeconfigmod.FN.HomeConfig.Rank] = *req.Rank + } + if req.Limit != nil { + if *req.Limit < 0 { + return srvdef.New(srvdef.ErrParamError, "展示数量不能小于0", "") + } + setDoc[homeconfigmod.FN.HomeConfig.Limit] = *req.Limit + } + if req.SortType != nil { + if !validHomeConfigSortType(*req.SortType) { + return srvdef.New(srvdef.ErrParamError, "排序字段无效", "") + } + setDoc[homeconfigmod.FN.HomeConfig.SortType] = *req.SortType + } + if req.Status != nil { + setDoc[homeconfigmod.FN.HomeConfig.Status] = *req.Status + } + if req.SeoInfo != nil { + setDoc["seoInfo"] = normalizeHomeConfigSeoValue(*req.SeoInfo) + } + if serr := prepareHomeConfigSlugOnUpdate(ctx, req.Id, info, req, setDoc); serr != nil { + return serr + } + merged := *info + if v, ok := setDoc[homeconfigmod.FN.HomeConfig.Type]; ok { + merged.Type = v.(int32) + } + if v, ok := setDoc[homeconfigmod.FN.HomeConfig.Status]; ok { + merged.Status = v.(bool) + } + if v, ok := setDoc[homeconfigmod.FN.HomeConfig.TopicIds]; ok { + merged.TopicIds = v.([]int64) + } + if v, ok := setDoc[homeconfigmod.FN.HomeConfig.Style]; ok { + merged.Style = v.(string) + } + if v, ok := setDoc[homeconfigmod.FN.HomeConfig.CategoryId]; ok { + merged.CategoryId = v.(int64) + } + if v, ok := setDoc[homeconfigmod.FN.HomeConfig.RecommendType]; ok { + merged.RecommendType = v.(int64) + } + if err := validateHomeConfigTopics(ctx, &merged); err != nil { + return err + } + if req.TopicIds != nil { + setDoc[homeconfigmod.FN.HomeConfig.TopicIds] = merged.TopicIds + } + if len(setDoc) <= 1 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + if err := homeconfigmod.UpdateOneHomeConfig(ctx, + bson.M{homeconfigmod.FN.HomeConfig.Id: req.Id}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearHomeConfigCache() + sitemapdirty.Mark(ctx) + return nil +} + +// DelHomeConfig 删除首页配置 +func DelHomeConfig(ctx context.Context, req *proto.HomeConfigDelReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "首页配置 ID无效", "") + } + count, err := homeconfigmod.DelOneHomeConfig(ctx, bson.M{homeconfigmod.FN.HomeConfig.Id: req.Id}) + if err != nil { + return err + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + clearHomeConfigCache() + sitemapdirty.Mark(ctx) + return nil +} + +func validHomeConfigType(typ int32) bool { + return typ == homeconfigmod.TypePlaylet || typ == homeconfigmod.TypeTheme +} + +func validHomeConfigSortType(sortType int32) bool { + return sortType == homeconfigmod.SortTypeRecommend || + sortType == homeconfigmod.SortTypeHot || + sortType == homeconfigmod.SortTypeLatest || + sortType == homeconfigmod.SortTypeOnlineAt || + sortType == homeconfigmod.SortTypeCategory +} + +func clearHomeConfigCache() { + playletcache.ClearHomeCache() +} + +// normalizeHomeConfigPage 规范分页参数,限制单页数量避免大分页拖慢查询 +func normalizeHomeConfigPage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = 1 + } + if pageSize <= 0 { + pageSize = defaultHomeConfigPageSize + } + if pageSize > maxHomeConfigPageSize { + pageSize = maxHomeConfigPageSize + } + return pageNum, pageSize +} + +// normalizeHomeConfigSeoValue 规范化 SEO 字段,避免 keywords 为 null +func normalizeHomeConfigSeoValue(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 +} diff --git a/web/service/srv_homeconfig/slug.go b/web/service/srv_homeconfig/slug.go new file mode 100644 index 0000000..78b37df --- /dev/null +++ b/web/service/srv_homeconfig/slug.go @@ -0,0 +1,132 @@ +package srv_homeconfig + +import ( + "context" + "strings" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/homeconfigmod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func ensureHomeConfigSlug(ctx context.Context, ac *homeconfigmod.HomeConfig, excludeId int64) *srvdef.SrvErr { + ac.Slug = utils.SanitizeSlug(ac.Slug) + if ac.Slug != "" { + if utils.IsSlugAllDigits(ac.Slug) { + return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字") + } + filter := bson.M{homeconfigmod.FN.HomeConfig.Slug: ac.Slug} + if excludeId > 0 { + filter[homeconfigmod.FN.HomeConfig.Id] = bson.M{"$ne": excludeId} + } + exist, _ := homeconfigmod.FindOneHomeConfig(ctx, filter) + if exist != nil { + return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在") + } + return nil + } + source := utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Name) + ac.Slug = utils.MakeUniqueSlug(source, func(slug string) bool { + filter := bson.M{homeconfigmod.FN.HomeConfig.Slug: slug} + if excludeId > 0 { + filter[homeconfigmod.FN.HomeConfig.Id] = bson.M{"$ne": excludeId} + } + exist, _ := homeconfigmod.FindOneHomeConfig(ctx, filter) + return exist != nil + }) + return nil +} + +func prepareHomeConfigSlugOnUpdate(ctx context.Context, id int64, info *homeconfigmod.HomeConfig, req *proto.HomeConfigUpdateReq, setDoc bson.M) *srvdef.SrvErr { + slug := info.Slug + if req.Slug != nil { + slug = utils.SanitizeSlug(*req.Slug) + } + if slug != "" { + if req.Slug != nil { + if utils.IsSlugAllDigits(slug) { + return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字") + } + exist, _ := homeconfigmod.FindOneHomeConfig(ctx, bson.M{ + homeconfigmod.FN.HomeConfig.Slug: slug, + homeconfigmod.FN.HomeConfig.Id: bson.M{"$ne": id}, + }) + if exist != nil { + return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在") + } + setDoc[homeconfigmod.FN.HomeConfig.Slug] = slug + } + return nil + } + name := info.Name + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + } + keywords := info.SeoInfo.Keyworld + if req.SeoInfo != nil { + keywords = normalizeHomeConfigSeoValue(*req.SeoInfo).Keyworld + } + source := utils.SlugSourceFromKeywordsOrTitle(keywords, name) + setDoc[homeconfigmod.FN.HomeConfig.Slug] = utils.MakeUniqueSlug(source, func(s string) bool { + exist, _ := homeconfigmod.FindOneHomeConfig(ctx, bson.M{ + homeconfigmod.FN.HomeConfig.Slug: s, + homeconfigmod.FN.HomeConfig.Id: bson.M{"$ne": id}, + }) + return exist != nil + }) + return nil +} + +func emptyHomeConfigSlugFilter() bson.M { + return bson.M{ + "$or": []bson.M{ + {homeconfigmod.FN.HomeConfig.Slug: ""}, + {homeconfigmod.FN.HomeConfig.Slug: bson.M{"$exists": false}}, + }, + } +} + +// SyncEmptySlugs 为首页配置中空 slug 自动生成。 +func SyncEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + if maxSize <= 0 { + return 0, false, nil + } + list, err := homeconfigmod.FindManyHomeConfig(ctx, emptyHomeConfigSlugFilter(), options.Find(). + SetSort(bson.D{{Key: homeconfigmod.FN.HomeConfig.Id, Value: 1}}). + SetLimit(maxSize+1)) + if err != nil { + return 0, false, err + } + hasMore := int64(len(list)) > maxSize + if hasMore { + list = list[:maxSize] + } + var updated int64 + for _, item := range list { + if item == nil { + continue + } + if serr := ensureHomeConfigSlug(ctx, item, item.Id); serr != nil { + return updated, hasMore, serr + } + if strings.TrimSpace(item.Slug) == "" { + continue + } + if serr := homeconfigmod.UpdateOneHomeConfig(ctx, + bson.M{homeconfigmod.FN.HomeConfig.Id: item.Id}, + bson.M{"$set": bson.M{homeconfigmod.FN.HomeConfig.Slug: item.Slug}}, + ); serr != nil { + return updated, hasMore, serr + } + updated++ + } + return updated, hasMore, nil +} + +func HasEmptySlug(ctx context.Context) bool { + count, _ := homeconfigmod.CountHomeConfig(ctx, emptyHomeConfigSlugFilter()) + return count > 0 +} diff --git a/web/service/srv_homeconfig/topic_options.go b/web/service/srv_homeconfig/topic_options.go new file mode 100644 index 0000000..ef483c1 --- /dev/null +++ b/web/service/srv_homeconfig/topic_options.go @@ -0,0 +1,142 @@ +package srv_homeconfig + +import ( + "context" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/homeconfigmod" + "videoweb/pub/models/playletthememod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// validateHomeConfigTopics 校验题材配置、去重,并阻止启用模块重复占用题材 +func validateHomeConfigTopics(ctx context.Context, config *homeconfigmod.HomeConfig) *srvdef.SrvErr { + if config.Style != "" && (len(config.Style) != 1 || config.Style[0] < 'A' || config.Style[0] > 'H') { + return srvdef.New(srvdef.ErrParamError, "样式仅支持 A-H", "") + } + if config.CategoryId < 0 || config.RecommendType < 0 || config.RecommendType > 5 { + return srvdef.New(srvdef.ErrParamError, "推荐配置无效", "") + } + if config.RecommendType == 5 && config.CategoryId == 0 { + return srvdef.New(srvdef.ErrParamError, "按分类推荐时必须填写分类ID", "") + } + if config.Type != homeconfigmod.TypeTheme { + if len(config.TopicIds) != 0 { + return srvdef.New(srvdef.ErrParamError, "短剧模块不能配置题材ID", "") + } + return nil + } + seen := map[int64]bool{} + ids := make([]int64, 0, len(config.TopicIds)) + for _, id := range config.TopicIds { + if id <= 0 { + return srvdef.New(srvdef.ErrParamError, "题材ID无效", "") + } + if !seen[id] { + ids = append(ids, id) + seen[id] = true + } + } + config.TopicIds = ids + if !config.Status { + return nil + } + if len(ids) == 0 { + if config.Id == 0 { + return srvdef.New(srvdef.ErrParamError, "启用的题材模块必须配置题材ID", "") + } + return nil // 历史题材配置沿用自动选题材 + } + 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, "题材不存在或未上架", "") + } + others, err := homeconfigmod.FindManyHomeConfig(ctx, bson.M{homeconfigmod.FN.HomeConfig.Status: true, homeconfigmod.FN.HomeConfig.Type: homeconfigmod.TypeTheme, homeconfigmod.FN.HomeConfig.TopicIds: bson.M{"$in": ids}}) + if err != nil { + return err + } + for _, other := range others { + if other != nil && other.Id != config.Id { + return srvdef.New(srvdef.ErrParamError, "题材已被其他启用模块使用", "") + } + } + return nil +} + +// ListHomeTopicOptions 分页查询可选的已上架短剧题材,并回显当前配置已选题材 +func ListHomeTopicOptions(ctx context.Context, req *proto.HomeTopicOptionsReq) (*proto.HomeTopicOptionsResp, *srvdef.SrvErr) { + if req == nil || req.ConfigId < 0 { + return nil, srvdef.New(srvdef.ErrParamError, "配置ID无效", "") + } + pageNum, pageSize := normalizeHomeConfigPage(req.PageNum, req.PageSize) + resp := &proto.HomeTopicOptionsResp{List: make([]*proto.HomeTopicOption, 0), SelectedList: make([]*proto.HomeTopicOption, 0), PageNum: pageNum, PageSize: pageSize} + var selected []int64 + if req.ConfigId > 0 { + current, err := homeconfigmod.FindOneHomeConfig(ctx, bson.M{homeconfigmod.FN.HomeConfig.Id: req.ConfigId}) + if err != nil { + return nil, err + } + if current.Type != homeconfigmod.TypeTheme { + return nil, srvdef.New(srvdef.ErrParamError, "当前配置不是题材模块", "") + } + selected = current.TopicIds + } + occupied := map[int64]bool{} + configs, err := homeconfigmod.FindManyHomeConfig(ctx, bson.M{homeconfigmod.FN.HomeConfig.Status: true, homeconfigmod.FN.HomeConfig.Type: homeconfigmod.TypeTheme}) + if err != nil { + return nil, err + } + for _, config := range configs { + if config != nil && config.Id != req.ConfigId { + for _, id := range config.TopicIds { + occupied[id] = true + } + } + } + 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().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 + } + for _, item := range list { + if item != nil { + resp.List = append(resp.List, &proto.HomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status}) + } + } + if len(selected) > 0 { + themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{playletthememod.FN.PlayletTheme.Id: bson.M{"$in": selected}}) + if err != nil { + return nil, err + } + byID := map[int64]*proto.HomeTopicOption{} + for _, item := range themes { + if item != nil { + byID[item.Id] = &proto.HomeTopicOption{Id: item.Id, Name: item.Name, Status: item.Status} + } + } + for _, id := range selected { + if item := byID[id]; item != nil { + resp.SelectedList = append(resp.SelectedList, item) + } else { + resp.SelectedList = append(resp.SelectedList, &proto.HomeTopicOption{Id: id}) + } + } + } + return resp, nil +} diff --git a/web/service/srv_ipblack/define.go b/web/service/srv_ipblack/define.go new file mode 100644 index 0000000..228c65c --- /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"` +} diff --git a/web/service/srv_ipblack/ipblack.go b/web/service/srv_ipblack/ipblack.go new file mode 100644 index 0000000..b3b47bb --- /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 +} 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..3bc1b85 --- /dev/null +++ b/web/service/srv_mediaresource/cover_compress.go @@ -0,0 +1,228 @@ +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/common/playletcache" + "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 + playletcache.ClearHomeCache() + 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 +} + +// FillPlayletCompressedHorizontalCover 为指定短剧补写压缩横版封面,已有值则跳过。 +func FillPlayletCompressedHorizontalCover(ctx context.Context, playlet *playletmod.Playlet) bool { + if playlet == nil || playlet.Id <= 0 { + return false + } + if strings.TrimSpace(playlet.CompressedHorizontalCover) != "" { + return false + } + cover := strings.TrimSpace(playlet.HorizontalCover) + 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.CompressedHorizontalCover: bson.M{"$exists": false}}, + {playletmod.FN.Playlet.CompressedHorizontalCover: ""}, + {playletmod.FN.Playlet.CompressedHorizontalCover: nil}, + }, + }, + bson.M{"$set": bson.M{ + playletmod.FN.Playlet.CompressedHorizontalCover: pathStr, + playletmod.FN.Playlet.UpdatedAt: now, + }}, + ); err != nil { + log.Warn("回写短剧压缩横版封面失败", log.Any("playletId", playlet.Id), log.E(err)) + return false + } + playlet.CompressedHorizontalCover = pathStr + playletcache.ClearHomeCache() + 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.UploadAutopublishImage(outName, compressed) + if err != nil { + log.Warn("media上传压缩封面失败,回退图床", log.Any("cover", cover), log.E(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; cgdj-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..48c8798 --- /dev/null +++ b/web/service/srv_mediaresource/import_worker.go @@ -0,0 +1,267 @@ +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" + playletResourceJHImportQueueKeyFmt = "playlet:resource:jh:import:queue:%d" + playletResourceJHImportLockKeyFmt = "playlet:resource:jh: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) +} + +func playletResourceJHImportQueueKey() string { + return fmt.Sprintf(playletResourceJHImportQueueKeyFmt, pubdef.AppId) +} + +func playletResourceJHImportLockKey(mediaId uint) string { + return fmt.Sprintf(playletResourceJHImportLockKeyFmt, 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 runPlayletResourceJHImportWorker(ctx) + go runPlayletSerialSyncWorker(ctx) + go runPlayletCoverCompressWorker(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 +} + +// pushPlayletResourceJHImportJob 投递嘉华短剧媒资导入任务 +func pushPlayletResourceJHImportJob(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(playletResourceJHImportQueueKey(), 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]) + } +} + +// runPlayletResourceJHImportWorker 循环消费嘉华短剧媒资导入队列 +func runPlayletResourceJHImportWorker(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + } + result, err := redis.Client().BLPop(playletResourceImportPopWait, playletResourceJHImportQueueKey()).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 + } + handlePlayletResourceJHImportPayload(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)) + } +} + +// lockPlayletResourceJHImportMedia 锁定单条嘉华媒资导入 +func lockPlayletResourceJHImportMedia(mediaId uint) (bool, error) { + return redis.Setnx_NewOK(playletResourceJHImportLockKey(mediaId), true, playletResourceImportLockTTL) +} + +// unlockPlayletResourceJHImportMedia 释放单条嘉华媒资导入锁 +func unlockPlayletResourceJHImportMedia(mediaId uint) { + if err := redis.Del(playletResourceJHImportLockKey(mediaId)); err != nil { + log.Error("释放嘉华短剧媒资导入锁失败", log.Any("mediaId", mediaId), log.E(err)) + } +} + +// handlePlayletResourceJHImportPayload 处理单个嘉华短剧媒资导入任务 +func handlePlayletResourceJHImportPayload(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 := importPlayletJHResourceBatch(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)) +} diff --git a/web/service/srv_mediaresource/media_center.go b/web/service/srv_mediaresource/media_center.go new file mode 100644 index 0000000..33b3725 --- /dev/null +++ b/web/service/srv_mediaresource/media_center.go @@ -0,0 +1,249 @@ +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 短剧媒资列表接口统一错误文案 + playletMediaListErrMsg = "短剧媒资列表查询失败" + // playletMediaSyncErrMsg 短剧媒资同步接口统一错误文案 + playletMediaSyncErrMsg = "短剧媒资同步失败" + // playletMediaDeletedCode 媒资中心资源已删除业务码 + 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"` // 分页信息 +} + +// playletMediaJHListResp 媒资中心嘉华短剧列表响应 data 节点 +type playletMediaJHListResp struct { + Data []*proto.PlayletResourceJHListItem `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 +} + +// mediaJHList 查询嘉华短剧媒资列表 +func (m *playletMediaCenterService) mediaJHList(p *playletMediaListReq) (result playletMediaJHListResp, 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..5dadcdd --- /dev/null +++ b/web/service/srv_mediaresource/resource.go @@ -0,0 +1,1494 @@ +package srv_mediaresource + +import ( + "context" + "math/rand" + "regexp" + "strings" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/mwacc" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/web/api/proto" + "videoweb/web/middleware/auth" + "videoweb/web/webg" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + defaultResourceSyncType = 1 + defaultResourcePageNum = 1 + defaultResourcePageSize = 20 + maxResourcePageSize = 100 + maxResourceImportIds = 100 + resourceAddStatusScanMaxPages = 20 + resourceAddedIDBatchSize = 100 + playletResourceImportMsg = "同步任务已提交,后台正在处理" + playletHeatMinValue = 1000 + playletHeatMaxValue = 5000 + playletVirtualLikeMinValue = 10 + playletVirtualLikeMaxValue = 20 + + playletResourceMediaTypeAnime = 1 // 动漫 + playletResourceMediaTypeManga = 2 // 漫画 + playletResourceMediaTypeComic = 4 // 漫画短剧 + playletResourceMediaTypeShort = 5 // 短剧 + playletResourceMediaTypeNormal = 6 // 普通短剧 +) + +// ListPlayletResource 分页查询短剧媒资列表 +func ListPlayletResource(ctx context.Context, req *proto.PlayletResourceListReq) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + syncType := defaultResourceSyncType + if req.SyncType != nil { + syncType = *req.SyncType + } + pageNumber := req.PageNumber + if pageNumber <= 0 { + pageNumber = defaultResourcePageNum + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = defaultResourcePageSize + } + if pageSize > maxResourcePageSize { + pageSize = maxResourcePageSize + } + mediaType, srvErr := resolvePlayletResourceMediaType(req.MediaType) + if srvErr != nil { + return nil, srvErr + } + // MediaCenterService 当前只保存配置值,按请求创建成本很低,也避免全局对象在配置热更新场景下读到旧配置 + ms := newPlayletMediaCenterFromConfig() + if req.IsAdd != nil { + return listPlayletResourceByAddStatus(ctx, ms, req, mediaType, syncType, pageNumber, pageSize) + } + data, err := ms.mediaList(&playletMediaListReq{ + MediaType: mediaType, + Title: req.Title, + Tag: req.Tag, + SyncType: syncType, + Page: uint(pageNumber), + Size: uint(pageSize), + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + if err := fillPlayletResourceAddStatus(ctx, data.Data); err != nil { + return nil, err + } + return &proto.PlayletResourceListResp{ + List: data.Data, + Total: data.Pagination.Count, + }, nil +} + +// ListPlayletJHResource 分页查询嘉华短剧媒资列表 +func ListPlayletJHResource(ctx context.Context, req *proto.PlayletResourceJHListReq) (*proto.PlayletResourceJHListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + syncType := defaultResourceSyncType + if req.SyncType != nil { + syncType = *req.SyncType + } + pageNumber := req.PageNumber + if pageNumber <= 0 { + pageNumber = defaultResourcePageNum + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = defaultResourcePageSize + } + ms := newPlayletJHMediaCenterFromConfig() + if req.IsAdd != nil { + return listPlayletJHResourceByAddStatus(ctx, ms, req, syncType, pageNumber, pageSize) + } + data, err := ms.mediaJHList(&playletMediaListReq{ + MediaType: playletResourceMediaTypeShort, + Title: req.Title, + Tag: req.Tag, + SyncType: syncType, + Page: uint(pageNumber), + Size: uint(pageSize), + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + if err := fillPlayletJHResourceAddStatus(ctx, data.Data); err != nil { + return nil, err + } + return &proto.PlayletResourceJHListResp{ + List: data.Data, + Total: data.Pagination.Count, + }, nil +} + +// ListPlayletResourceChapters 查询短剧媒资剧集信息(复用入库时的 mediaSync) +func ListPlayletResourceChapters(ctx context.Context, req *proto.PlayletResourceChapterListReq) (*proto.PlayletResourceChapterListResp, *srvdef.SrvErr) { + if req == nil || req.Id == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "") + } + ms := newPlayletMediaCenterFromConfig() + result, err := ms.mediaSync(&playletMediaSyncReq{ + Id: req.Id, + SyncTime: 0, + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + if result.IsDelete || result.Media == nil { + return nil, srvdef.New(srvdef.ErrNoData, "媒资不存在或已删除", "") + } + list := make([]*proto.PlayletResourceChapterItem, 0, len(result.MediaContentList)) + for _, item := range result.MediaContentList { + videoUrl := strings.TrimSpace(item.M3u8Url) + if videoUrl == "" { + videoUrl = strings.TrimSpace(item.Urls) + } + list = append(list, &proto.PlayletResourceChapterItem{ + Id: item.ID, + EpisodeNumber: item.EpisodeNumber, + Title: strings.TrimSpace(item.Title), + Cover: item.Cover, + VideoUrl: videoUrl, + Height: item.Height, + Width: item.Width, + MediaSize: item.MediaSize, + PlayTime: item.PlayTime, + SaleCoin: item.SaleCoin, + HashId: item.HashId, + CreateTime: item.CreateTime, + UpdateTime: item.UpdateTime, + }) + } + return &proto.PlayletResourceChapterListResp{ + MediaId: req.Id, + Title: result.Media.Title, + List: list, + Total: len(list), + }, nil +} + +// ListPlayletJHResourceChapters 查询嘉华短剧媒资剧集信息 +func ListPlayletJHResourceChapters(ctx context.Context, req *proto.PlayletResourceChapterListReq) (*proto.PlayletResourceChapterListResp, *srvdef.SrvErr) { + if req == nil || req.Id == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "") + } + ms := newPlayletJHMediaCenterFromConfig() + result, err := ms.mediaSync(&playletMediaSyncReq{ + Id: req.Id, + SyncTime: 0, + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + if result.IsDelete || result.Media == nil { + return nil, srvdef.New(srvdef.ErrNoData, "媒资不存在或已删除", "") + } + list := make([]*proto.PlayletResourceChapterItem, 0, len(result.MediaContentList)) + for _, item := range result.MediaContentList { + videoUrl := strings.TrimSpace(item.M3u8Url) + if videoUrl == "" { + videoUrl = strings.TrimSpace(item.Urls) + } + list = append(list, &proto.PlayletResourceChapterItem{ + Id: item.ID, + EpisodeNumber: item.EpisodeNumber, + Title: strings.TrimSpace(item.Title), + Cover: item.Cover, + VideoUrl: videoUrl, + Height: item.Height, + Width: item.Width, + MediaSize: item.MediaSize, + PlayTime: item.PlayTime, + SaleCoin: item.SaleCoin, + HashId: item.HashId, + CreateTime: item.CreateTime, + UpdateTime: item.UpdateTime, + }) + } + return &proto.PlayletResourceChapterListResp{ + MediaId: req.Id, + Title: result.Media.Title, + List: list, + Total: len(list), + }, nil +} + +// resolvePlayletResourceMediaType 校验后台显式传入的媒资类型。 +func resolvePlayletResourceMediaType(mediaType *int) (int, *srvdef.SrvErr) { + if mediaType == nil { + return 0, srvdef.New(srvdef.ErrParamError, "媒体类型不能为空", "媒体类型不能为空") + } + if !validPlayletResourceMediaType(*mediaType) { + return 0, srvdef.New(srvdef.ErrParamError, "媒体类型无效,仅支持1-动漫、2-漫画、4-漫画短剧、5-短剧、6-普通短剧", "媒体类型无效,仅支持1-动漫、2-漫画、4-漫画短剧、5-短剧、6-普通短剧") + } + return *mediaType, nil +} + +// validPlayletResourceMediaType 媒资列表当前允许的媒体类型。 +func validPlayletResourceMediaType(mediaType int) bool { + switch mediaType { + case playletResourceMediaTypeAnime, playletResourceMediaTypeManga, playletResourceMediaTypeComic, playletResourceMediaTypeShort, playletResourceMediaTypeNormal: + return true + default: + return false + } +} + +// validPlayletImportMediaType 允许入库的媒资类型。 +func validPlayletImportMediaType(mediaType int) bool { + switch mediaType { + case playletResourceMediaTypeAnime, + playletResourceMediaTypeManga, playletResourceMediaTypeComic, playletResourceMediaTypeShort, playletResourceMediaTypeNormal: + return true + default: + return false + } +} + +// contentTypeFromMediaType 媒资中心 media_type 映射为本项目 contentType。 +// 1-动漫 → 3;2-漫画 → 4;4-漫画短剧 → 2;5-短剧 → 0;6-普通短剧 → 1。 +func contentTypeFromMediaType(mediaType int) playletmod.ContentType { + switch mediaType { + case playletResourceMediaTypeAnime: + return playletmod.ContentTypeAnime + case playletResourceMediaTypeManga: + return playletmod.ContentTypeManga + case playletResourceMediaTypeComic: + return playletmod.ContentTypeComic + case playletResourceMediaTypeNormal: + return playletmod.ContentTypeAlbum + default: + return playletmod.ContentTypeTheater + } +} + +// fillPlayletResourceAddStatus 回填媒资列表是否已加入后台短剧库 +func fillPlayletResourceAddStatus(ctx context.Context, list []*proto.PlayletResourceListItem) *srvdef.SrvErr { + mediaIds := make([]uint, 0, len(list)) + seen := make(map[uint]struct{}, len(list)) + for _, item := range list { + // 只收集当前页有效媒资ID,空数据和无效ID不参与本地查询。 + if item == nil || item.ID == 0 { + continue + } + // 当前页可能出现重复媒资ID,去重后可以减少 Mongo $in 查询条件。 + if _, ok := seen[item.ID]; ok { + continue + } + seen[item.ID] = struct{}{} + mediaIds = append(mediaIds, item.ID) + } + if len(mediaIds) == 0 { + return nil + } + // 按当前页媒资ID批量查询本地短剧库,避免逐条查询影响列表性能。 + filter := applyPlayletMediaSourceFilter(bson.M{ + playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": mediaIds}, + }, playletmod.MediaSourceHG) + playletList, err := playletmod.FindManyPlaylet(ctx, filter) + if err != nil { + log.Error("查询短剧媒资加入状态失败", log.Any("mediaIds", mediaIds), log.Any("err", err)) + return err + } + addedMap := make(map[uint]bool, len(playletList)) + for _, item := range playletList { + // mediaCenterId 是本地短剧表保存的媒资中心ID,用它判断当前媒资是否已入库。 + if item == nil || item.MediaCenterId == 0 { + continue + } + addedMap[item.MediaCenterId] = true + } + for _, item := range list { + if item == nil { + continue + } + // map 没命中时 bool 零值为 false,表示未加入后台短剧库。 + item.IsAdd = addedMap[item.ID] + } + return nil +} + +// fillPlayletJHResourceAddStatus 回填嘉华媒资列表是否已入库 +func fillPlayletJHResourceAddStatus(ctx context.Context, list []*proto.PlayletResourceJHListItem) *srvdef.SrvErr { + mediaIds := make([]uint, 0, len(list)) + seen := make(map[uint]struct{}, len(list)) + for _, item := range list { + if item == nil || item.ID == 0 { + continue + } + if _, ok := seen[item.ID]; ok { + continue + } + seen[item.ID] = struct{}{} + mediaIds = append(mediaIds, item.ID) + } + if len(mediaIds) == 0 { + return nil + } + importedMap, err := findImportedPlayletResourceMap(ctx, mediaIds, playletmod.MediaSourceDefault) + if err != nil { + return err + } + for _, item := range list { + if item == nil { + continue + } + item.IsAdd = importedMap[item.ID] + } + return nil +} + +// listPlayletJHResourceByAddStatus 按是否已入库过滤嘉华媒资列表 +func listPlayletJHResourceByAddStatus( + ctx context.Context, + ms *playletMediaCenterService, + req *proto.PlayletResourceJHListReq, + syncType int, + pageNumber int, + pageSize int, +) (*proto.PlayletResourceJHListResp, *srvdef.SrvErr) { + if *req.IsAdd && !playletJHResourceRequiresUpstreamAddStatusScan(req, syncType) { + return listAddedPlayletJHResourceByLocalFilter(ctx, ms, req, syncType, pageNumber, pageSize) + } + return scanPlayletJHResourceByAddStatus(ctx, ms, req, syncType, pageNumber, pageSize) +} + +// playletJHResourceRequiresUpstreamAddStatusScan 判断嘉华列表是否必须扫描上游才能保证筛选结果准确。 +func playletJHResourceRequiresUpstreamAddStatusScan(req *proto.PlayletResourceJHListReq, syncType int) bool { + if syncType != defaultResourceSyncType { + return true + } + return req != nil && req.Tag != nil && strings.TrimSpace(*req.Tag) != "" +} + +// listAddedPlayletJHResourceByLocalFilter 查询已入库嘉华媒资,优先走本地playlet表分页 +func listAddedPlayletJHResourceByLocalFilter( + ctx context.Context, + ms *playletMediaCenterService, + req *proto.PlayletResourceJHListReq, + syncType int, + pageNumber int, + pageSize int, +) (*proto.PlayletResourceJHListResp, *srvdef.SrvErr) { + filter := buildAddedPlayletJHResourceFilter(req) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletResourceJHListResp{ + List: make([]*proto.PlayletResourceJHListItem, 0, pageSize), + Total: uint(totalCount), + } + if totalCount == 0 { + return resp, nil + } + start := int64((pageNumber - 1) * pageSize) + needCount := int64(pageNumber * pageSize) + matchedCount := int64(0) + for batchStart := int64(0); batchStart < totalCount && matchedCount < needCount; batchStart += resourceAddedIDBatchSize { + list, err := queryAddedPlayletResourceIds(ctx, filter, batchStart) + if err != nil { + return nil, err + } + if len(list) == 0 { + break + } + items, err := queryAddedPlayletJHResourceItems(ms, list, syncType) + if err != nil { + return nil, err + } + for _, item := range items { + if item == nil { + continue + } + if matchedCount >= start && len(resp.List) < pageSize { + resp.List = append(resp.List, item) + } + matchedCount++ + if matchedCount >= needCount { + break + } + } + } + return resp, nil +} + +// buildAddedPlayletJHResourceFilter 构建本地已入库嘉华媒资查询条件。 +func buildAddedPlayletJHResourceFilter(req *proto.PlayletResourceJHListReq) bson.M { + filter := applyPlayletMediaSourceFilter(bson.M{ + playletmod.FN.Playlet.MediaCenterId: bson.M{"$gt": 0}, + playletmod.FN.Playlet.ContentType: contentTypeFromMediaType(playletResourceMediaTypeShort), + }, playletmod.MediaSourceDefault) + if req != nil && req.Title != nil { + if title := strings.TrimSpace(*req.Title); title != "" { + filter[playletmod.FN.Playlet.Title] = bson.M{"$regex": regexp.QuoteMeta(title)} + } + } + return filter +} + +// queryAddedPlayletJHResourceItems 按本地短剧的媒资中心ID回查嘉华媒资数据。 +func queryAddedPlayletJHResourceItems(ms *playletMediaCenterService, list playletmod.PlayletList, syncType int) ([]*proto.PlayletResourceJHListItem, *srvdef.SrvErr) { + result := make([]*proto.PlayletResourceJHListItem, 0, len(list)) + for _, item := range list { + if item == nil || item.MediaCenterId == 0 { + continue + } + media, err := queryPlayletJHResourceByMediaCenterId(ms, item.MediaCenterId, syncType) + if err != nil { + return nil, err + } + if media == nil { + continue + } + media.IsAdd = true + result = append(result, media) + } + return result, nil +} + +// queryPlayletJHResourceByMediaCenterId 按媒资中心ID查询单条嘉华媒资列表数据。 +func queryPlayletJHResourceByMediaCenterId(ms *playletMediaCenterService, mediaId uint, syncType int) (*proto.PlayletResourceJHListItem, *srvdef.SrvErr) { + data, err := ms.mediaJHList(&playletMediaListReq{ + MediaType: playletResourceMediaTypeShort, + Id: &mediaId, + SyncType: syncType, + Page: 1, + Size: 1, + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + for _, item := range data.Data { + if item != nil && item.ID == mediaId { + return item, nil + } + } + if len(data.Data) > 0 { + return data.Data[0], nil + } + return nil, nil +} + +// scanPlayletJHResourceByAddStatus 扫描嘉华媒资分页并按入库状态精确过滤。 +func scanPlayletJHResourceByAddStatus( + ctx context.Context, + ms *playletMediaCenterService, + req *proto.PlayletResourceJHListReq, + syncType int, + pageNumber int, + pageSize int, +) (*proto.PlayletResourceJHListResp, *srvdef.SrvErr) { + offset := (pageNumber - 1) * pageSize + list := make([]*proto.PlayletResourceJHListItem, 0, pageSize) + matchedCount := 0 + scanPage := uint(1) + scanSize := uint(maxResourcePageSize) + for { + data, err := ms.mediaJHList(&playletMediaListReq{ + MediaType: playletResourceMediaTypeShort, + Title: req.Title, + Tag: req.Tag, + SyncType: syncType, + Page: scanPage, + Size: scanSize, + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + if err := fillPlayletJHResourceAddStatus(ctx, data.Data); err != nil { + return nil, err + } + for _, item := range data.Data { + if item == nil || item.IsAdd != *req.IsAdd { + continue + } + if matchedCount >= offset && len(list) < pageSize { + list = append(list, item) + } + matchedCount++ + } + if data.Pagination.IsLastPage || len(data.Data) == 0 { + break + } + if data.Pagination.Count > 0 && scanPage*scanSize >= data.Pagination.Count { + break + } + scanPage++ + } + return &proto.PlayletResourceJHListResp{ + List: list, + Total: uint(matchedCount), + }, nil +} + +// listPlayletResourceByAddStatus 按是否已加入后台短剧库过滤媒资列表,并按过滤后的结果分页 +func listPlayletResourceByAddStatus( + ctx context.Context, + ms *playletMediaCenterService, + req *proto.PlayletResourceListReq, + mediaType int, + syncType int, + pageNumber int, + pageSize int, +) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { + if *req.IsAdd && !playletResourceRequiresUpstreamAddStatusScan(req, syncType) { + return listAddedPlayletResourceByLocalFilter(ctx, ms, req, mediaType, syncType, pageNumber, pageSize) + } + if !*req.IsAdd && !playletResourceRequiresUpstreamAddStatusScan(req, syncType) { + return listNotAddedPlayletResourceByLocalEstimate(ctx, ms, req, mediaType, syncType, pageNumber, pageSize) + } + return scanPlayletResourceByAddStatus(ctx, ms, req, mediaType, syncType, pageNumber, pageSize) +} + +// listAddedPlayletResourceByLocalFilter 查询已加入后台短剧库的媒资,优先走本地playlet表分页 +func listAddedPlayletResourceByLocalFilter( + ctx context.Context, + ms *playletMediaCenterService, + req *proto.PlayletResourceListReq, + mediaType int, + syncType int, + pageNumber int, + pageSize int, +) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { + filter := buildAddedPlayletResourceFilter(req, mediaType) + totalCount, err := playletmod.CountPlaylet(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletResourceListResp{ + List: make([]*proto.PlayletResourceListItem, 0, pageSize), + Total: uint(totalCount), + } + if totalCount == 0 { + return resp, nil + } + start := int64((pageNumber - 1) * pageSize) + needCount := int64(pageNumber * pageSize) + matchedCount := int64(0) + for batchStart := int64(0); batchStart < totalCount && matchedCount < needCount; batchStart += resourceAddedIDBatchSize { + list, err := queryAddedPlayletResourceIds(ctx, filter, batchStart) + if err != nil { + return nil, err + } + if len(list) == 0 { + break + } + items, err := queryAddedPlayletResourceItems(ms, list, mediaType, syncType) + if err != nil { + return nil, err + } + for _, item := range items { + if item == nil { + continue + } + if matchedCount >= start && len(resp.List) < pageSize { + resp.List = append(resp.List, item) + } + matchedCount++ + if matchedCount >= needCount { + break + } + } + } + return resp, nil +} + +// listNotAddedPlayletResourceByLocalEstimate 查询未加入媒资,total按上游总数减本地已加入数估算。 +func listNotAddedPlayletResourceByLocalEstimate( + ctx context.Context, + ms *playletMediaCenterService, + req *proto.PlayletResourceListReq, + mediaType int, + syncType int, + pageNumber int, + pageSize int, +) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { + scanSize := uint(maxResourcePageSize) + firstPage, err := ms.mediaList(&playletMediaListReq{ + MediaType: mediaType, + Title: req.Title, + SyncType: syncType, + Page: 1, + Size: scanSize, + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + addedTotal, countErr := playletmod.CountPlaylet(ctx, buildAddedPlayletResourceFilter(req, mediaType)) + if countErr != nil { + return nil, countErr + } + total := int64(firstPage.Pagination.Count) - addedTotal + if total < 0 { + total = 0 + } + resp := &proto.PlayletResourceListResp{ + List: make([]*proto.PlayletResourceListItem, 0, pageSize), + Total: uint(total), + } + start := (pageNumber - 1) * pageSize + needCount := start + pageSize + matchedCount := 0 + lastPage := calcPlayletResourceLastPage(firstPage.Pagination.Count, scanSize) + scanLimit := lastPage + if scanLimit > resourceAddStatusScanMaxPages { + scanLimit = resourceAddStatusScanMaxPages + } + for scanPage := uint(1); scanPage <= scanLimit; scanPage++ { + currentPage := firstPage + if scanPage > 1 { + data, err := ms.mediaList(&playletMediaListReq{ + MediaType: mediaType, + Title: req.Title, + SyncType: syncType, + Page: scanPage, + Size: scanSize, + }) + if err != nil { + log.Warn("查询未加入短剧媒资后续分页失败,停止继续扫描", log.Any("页码", scanPage), log.E(err)) + break + } + currentPage = data + } + if len(currentPage.Data) == 0 { + break + } + if err := fillPlayletResourceByAddStatusPage(ctx, currentPage.Data, false, start, pageSize, resp, &matchedCount); err != nil { + return nil, err + } + if matchedCount >= needCount || currentPage.Pagination.IsLastPage { + break + } + } + if scanLimit < lastPage && matchedCount < needCount { + log.Warn("短剧媒资加入状态筛选达到最大扫描页数", log.Any("最大扫描页数", resourceAddStatusScanMaxPages), log.Any("上游总页数", lastPage), log.Any("命中数量", matchedCount), log.Any("需要数量", needCount)) + } + return resp, nil +} + +// scanPlayletResourceByAddStatus 扫描媒资中心分页并按加入状态精确过滤,适用于本地无法独立判断的筛选。 +func scanPlayletResourceByAddStatus( + ctx context.Context, + ms *playletMediaCenterService, + req *proto.PlayletResourceListReq, + mediaType int, + syncType int, + pageNumber int, + pageSize int, +) (*proto.PlayletResourceListResp, *srvdef.SrvErr) { + offset := (pageNumber - 1) * pageSize + list := make([]*proto.PlayletResourceListItem, 0, pageSize) + matchedCount := 0 + scanPage := uint(1) + scanSize := uint(maxResourcePageSize) + for { + data, err := ms.mediaList(&playletMediaListReq{ + MediaType: mediaType, + Title: req.Title, + Tag: req.Tag, + SyncType: syncType, + Page: scanPage, + Size: scanSize, + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + if err := fillPlayletResourceAddStatus(ctx, data.Data); err != nil { + return nil, err + } + for _, item := range data.Data { + if item == nil || item.IsAdd != *req.IsAdd { + continue + } + if matchedCount >= offset && len(list) < pageSize { + list = append(list, item) + } + matchedCount++ + } + if data.Pagination.IsLastPage || len(data.Data) == 0 { + break + } + if data.Pagination.Count > 0 && scanPage*scanSize >= data.Pagination.Count { + break + } + scanPage++ + } + return &proto.PlayletResourceListResp{ + List: list, + Total: uint(matchedCount), + }, nil +} + +// calcPlayletResourceLastPage 计算上游媒资列表最后一页页码 +func calcPlayletResourceLastPage(total uint, pageSize uint) uint { + if total == 0 || pageSize == 0 { + return 0 + } + return (total + pageSize - 1) / pageSize +} + +// playletResourceRequiresUpstreamAddStatusScan 判断是否必须扫描上游才能保证筛选结果准确。 +func playletResourceRequiresUpstreamAddStatusScan(req *proto.PlayletResourceListReq, syncType int) bool { + if syncType != defaultResourceSyncType { + return true + } + return req != nil && req.Tag != nil && strings.TrimSpace(*req.Tag) != "" +} + +// buildAddedPlayletResourceFilter 构建本地已加入短剧媒资查询条件。 +func buildAddedPlayletResourceFilter(req *proto.PlayletResourceListReq, mediaType int) bson.M { + filter := applyPlayletMediaSourceFilter(bson.M{ + playletmod.FN.Playlet.MediaCenterId: bson.M{"$gt": 0}, + playletmod.FN.Playlet.ContentType: contentTypeFromMediaType(mediaType), + }, playletmod.MediaSourceHG) + if req != nil && req.Title != nil { + if title := strings.TrimSpace(*req.Title); title != "" { + filter[playletmod.FN.Playlet.Title] = bson.M{"$regex": regexp.QuoteMeta(title)} + } + } + return filter +} + +// queryAddedPlayletResourceIds 分批查询已加入短剧的媒资中心ID。 +func queryAddedPlayletResourceIds(ctx context.Context, filter bson.M, skip int64) (playletmod.PlayletList, *srvdef.SrvErr) { + list, err := playletmod.FindManyPlaylet(ctx, filter, options.Find(). + SetProjection(bson.D{{Key: playletmod.FN.Playlet.MediaCenterId, Value: 1}}). + SetSort(bson.D{ + {Key: playletmod.FN.Playlet.CreatedAt, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + }). + SetSkip(skip). + SetLimit(resourceAddedIDBatchSize)) + if err != nil { + log.Warn("查询已加入短剧媒资ID失败", log.Any("filter", filter), log.E(err)) + return nil, err + } + return list, nil +} + +// queryAddedPlayletResourceItems 按本地短剧的媒资中心ID回查媒资数据。 +func queryAddedPlayletResourceItems(ms *playletMediaCenterService, list playletmod.PlayletList, mediaType int, syncType int) ([]*proto.PlayletResourceListItem, *srvdef.SrvErr) { + result := make([]*proto.PlayletResourceListItem, 0, len(list)) + for _, item := range list { + if item == nil || item.MediaCenterId == 0 { + continue + } + media, err := queryPlayletResourceByMediaCenterId(ms, item.MediaCenterId, mediaType, syncType) + if err != nil { + return nil, err + } + if media == nil { + continue + } + media.IsAdd = true + result = append(result, media) + } + return result, nil +} + +// queryPlayletResourceByMediaCenterId 按媒资中心ID查询单条媒资列表数据。 +func queryPlayletResourceByMediaCenterId(ms *playletMediaCenterService, mediaId uint, mediaType int, syncType int) (*proto.PlayletResourceListItem, *srvdef.SrvErr) { + data, err := ms.mediaList(&playletMediaListReq{ + MediaType: mediaType, + Id: &mediaId, + SyncType: syncType, + Page: 1, + Size: 1, + }) + if err != nil { + return nil, srvdef.New(srvdef.Failure, err.Error(), "") + } + for _, item := range data.Data { + if item != nil && item.ID == mediaId { + return item, nil + } + } + if len(data.Data) > 0 { + return data.Data[0], nil + } + return nil, nil +} + +// fillPlayletResourceByAddStatusPage 按加入状态累计过滤,并截取请求页数据。 +func fillPlayletResourceByAddStatusPage(ctx context.Context, list []*proto.PlayletResourceListItem, wantAdd bool, start int, pageSize int, resp *proto.PlayletResourceListResp, matchedCount *int) *srvdef.SrvErr { + if err := fillPlayletResourceAddStatus(ctx, list); err != nil { + return err + } + for _, item := range list { + if item == nil || item.IsAdd != wantAdd { + continue + } + if *matchedCount >= start && len(resp.List) < pageSize { + resp.List = append(resp.List, item) + } + *matchedCount = *matchedCount + 1 + } + return nil +} + +// ImportPlayletResource 提交短剧媒资入库任务 +func ImportPlayletResource(ctx context.Context, req *proto.PlayletResourceUseReq) (string, *srvdef.SrvErr) { + // 先做整批请求校验,避免后续产生无效的外部同步请求和数据库写入。 + ids, err := validatePlayletResourceImportReq(req) + if err != nil { + return "", err + } + operatorId, operatorName := resolveImportOperator(ctx) + if err := pushPlayletResourceImportJob(ctx, ids, operatorId, operatorName); err != nil { + return "", err + } + return playletResourceImportMsg, nil +} + +// ImportPlayletJHResource 提交嘉华短剧媒资入库任务 +func ImportPlayletJHResource(ctx context.Context, req *proto.PlayletResourceUseReq) (string, *srvdef.SrvErr) { + ids, err := validatePlayletResourceImportReq(req) + if err != nil { + return "", err + } + operatorId, operatorName := resolveImportOperator(ctx) + if err := pushPlayletResourceJHImportJob(ctx, ids, operatorId, operatorName); err != nil { + return "", err + } + return playletResourceImportMsg, nil +} + +// importPlayletResourceBatch 批量处理短剧媒资入库任务 +func importPlayletResourceBatch(ctx context.Context, ids []uint, operatorId int64, operatorName string) *proto.PlayletResourceUseResp { + // 先批量查询已导入媒资,避免对已存在数据重复调用媒资中心同步接口。 + resp := &proto.PlayletResourceUseResp{ + Success: make([]*proto.PlayletResourceUseSuccess, 0, len(ids)), + Failed: make([]*proto.PlayletResourceUseFailed, 0), + } + importedMap, err := findImportedPlayletResourceMap(ctx, ids, playletmod.MediaSourceHG) + if err != nil { + for _, id := range ids { + resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ + MediaId: id, + Reason: "查询导入状态失败", + }) + } + return resp + } + ms := newPlayletMediaCenterFromConfig() + for _, id := range ids { + // 已入库媒资直接跳过,不再请求媒资中心。 + if importedMap[id] { + resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ + MediaId: id, + Reason: "该媒资已导入", + }) + continue + } + // 单条媒资独立导入,失败不影响本批次剩余媒资;单条内部还有锁和最终去重校验。 + success, reason := importSinglePlayletResource(ctx, ms, id, operatorId, operatorName) + if reason != "" { + resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ + MediaId: id, + Reason: reason, + }) + continue + } + resp.Success = append(resp.Success, success) + } + return resp +} + +// importPlayletJHResourceBatch 批量处理嘉华短剧媒资入库任务 +func importPlayletJHResourceBatch(ctx context.Context, ids []uint, operatorId int64, operatorName string) *proto.PlayletResourceUseResp { + resp := &proto.PlayletResourceUseResp{ + Success: make([]*proto.PlayletResourceUseSuccess, 0, len(ids)), + Failed: make([]*proto.PlayletResourceUseFailed, 0), + } + importedMap, err := findImportedPlayletResourceMap(ctx, ids, playletmod.MediaSourceDefault) + if err != nil { + for _, id := range ids { + resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ + MediaId: id, + Reason: "查询导入状态失败", + }) + } + return resp + } + ms := newPlayletJHMediaCenterFromConfig() + for _, id := range ids { + if importedMap[id] { + resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ + MediaId: id, + Reason: "该媒资已导入", + }) + continue + } + success, reason := importSinglePlayletJHResource(ctx, ms, id, operatorId, operatorName) + if reason != "" { + resp.Failed = append(resp.Failed, &proto.PlayletResourceUseFailed{ + MediaId: id, + Reason: reason, + }) + continue + } + resp.Success = append(resp.Success, success) + } + return resp +} + +// findImportedPlayletResourceMap 批量查询指定来源已经导入的媒资ID +func findImportedPlayletResourceMap(ctx context.Context, ids []uint, mediaSource string) (map[uint]bool, *srvdef.SrvErr) { + result := make(map[uint]bool, len(ids)) + if len(ids) == 0 { + return result, nil + } + filter := applyPlayletMediaSourceFilter(bson.M{ + playletmod.FN.Playlet.MediaCenterId: bson.M{"$in": ids}, + }, mediaSource) + playletList, err := playletmod.FindManyPlaylet(ctx, filter) + if err != nil { + log.Error("批量查询短剧媒资导入状态失败", log.Any("mediaIds", ids), log.Any("err", err)) + return nil, err + } + for _, item := range playletList { + // 只记录有效的媒资中心ID,供批量任务跳过已入库数据。 + if item == nil || item.MediaCenterId == 0 { + continue + } + result[item.MediaCenterId] = true + } + return result, nil +} + +func newPlayletMediaCenterFromConfig() *playletMediaCenterService { + return newPlayletMediaCenterService(playletMediaCenterConfig{ + Url: webg.Conf.MediaResourceDBCfg.BaseUrl, + ProId: webg.Conf.MediaResourceDBCfg.Merchat, + Secret: webg.Conf.MediaResourceDBCfg.Secret, + }) +} + +func newPlayletJHMediaCenterFromConfig() *playletMediaCenterService { + return newPlayletMediaCenterService(playletMediaCenterConfig{ + Url: webg.Conf.PlayletMediaResourceDBCfg.BaseUrl, + ProId: webg.Conf.PlayletMediaResourceDBCfg.Merchat, + Secret: webg.Conf.PlayletMediaResourceDBCfg.Secret, + }) +} + +func newPlayletMediaCenterBySource(mediaSource string) *playletMediaCenterService { + if mediaSource == playletmod.MediaSourceHG { + return newPlayletMediaCenterFromConfig() + } + return newPlayletJHMediaCenterFromConfig() +} + +func applyPlayletMediaSourceFilter(filter bson.M, mediaSource string) bson.M { + if filter == nil { + filter = bson.M{} + } + if mediaSource == playletmod.MediaSourceHG { + filter[playletmod.FN.Playlet.MediaSource] = playletmod.MediaSourceHG + return filter + } + filter["$or"] = []bson.M{ + {playletmod.FN.Playlet.MediaSource: playletmod.MediaSourceDefault}, + {playletmod.FN.Playlet.MediaSource: bson.M{"$exists": false}}, + } + return filter +} + +// validatePlayletResourceImportReq 校验短剧媒资入库请求 +func validatePlayletResourceImportReq(req *proto.PlayletResourceUseReq) ([]uint, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + // 过滤空ID和重复ID,避免重复导入同一条媒资。 + ids := dedupePlayletResourceIds(req.Ids) + if len(ids) == 0 { + return nil, srvdef.New(srvdef.ErrParamError, "媒资ID不能为空", "") + } + // 后台选择入库属于人工操作,限制单次数量可以避免队列任务过大。 + if len(ids) > maxResourceImportIds { + return nil, srvdef.New(srvdef.ErrParamError, "单次最多导入100条媒资", "") + } + return ids, nil +} + +// dedupePlayletResourceIds 去重并过滤无效媒资ID +func dedupePlayletResourceIds(ids []uint) []uint { + result := make([]uint, 0, len(ids)) + seen := make(map[uint]struct{}, len(ids)) + for _, id := range ids { + // 0 不是有效媒资ID,直接丢弃。 + if id == 0 { + continue + } + // 同一批次重复选择的ID只处理一次。 + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +type playletResourceImportLockFunc func(uint) (bool, error) +type playletResourceImportUnlockFunc func(uint) + +// importSinglePlayletResource 导入单条短剧媒资 +func importSinglePlayletResource(ctx context.Context, ms *playletMediaCenterService, mediaId uint, operatorId int64, operatorName string) (*proto.PlayletResourceUseSuccess, string) { + return importSinglePlayletResourceWithLock(ctx, ms, mediaId, operatorId, operatorName, playletmod.MediaSourceHG, lockPlayletResourceImportMedia, unlockPlayletResourceImportMedia) +} + +// importSinglePlayletJHResource 导入单条嘉华短剧媒资 +func importSinglePlayletJHResource(ctx context.Context, ms *playletMediaCenterService, mediaId uint, operatorId int64, operatorName string) (*proto.PlayletResourceUseSuccess, string) { + return importSinglePlayletResourceWithLock(ctx, ms, mediaId, operatorId, operatorName, playletmod.MediaSourceDefault, lockPlayletResourceJHImportMedia, unlockPlayletResourceJHImportMedia) +} + +// importSinglePlayletResourceWithLock 导入单条短剧媒资并使用指定锁 +func importSinglePlayletResourceWithLock(ctx context.Context, ms *playletMediaCenterService, mediaId uint, operatorId int64, operatorName string, mediaSource string, lockFn playletResourceImportLockFunc, unlockFn playletResourceImportUnlockFunc) (*proto.PlayletResourceUseSuccess, string) { + locked, err := lockFn(mediaId) + if err != nil { + log.Error("获取短剧媒资导入锁失败", log.Any("mediaId", mediaId), log.E(err)) + return nil, "获取导入锁失败" + } + if !locked { + return nil, "该媒资正在导入" + } + defer unlockFn(mediaId) + + // 本地已导入的媒资不再请求媒资中心,减少无效网络调用。 + if reason := checkPlayletResourceImported(ctx, mediaId, mediaSource); reason != "" { + return nil, reason + } + // sync_time 传 0,表示拉取该媒资当前完整详情和章节列表。 + result, err := ms.mediaSync(&playletMediaSyncReq{ + Id: mediaId, + SyncTime: 0, + }) + if err != nil { + log.Error("同步短剧媒资失败", log.Any("mediaId", mediaId), log.E(err)) + return nil, "同步媒资失败" + } + if result.IsDelete || result.Media == nil { + return nil, "媒资不存在或已删除" + } + if !validPlayletImportMediaType(result.Media.MediaType) { + return nil, "该媒资类型不支持导入短剧" + } + if len(result.MediaContentList) == 0 { + return nil, "媒资章节为空" + } + + // 标签按媒资中心 tags_text 自动生成或复用,不再由请求参数控制。 + //tagIds, serr := buildPlayletResourceTagIds(ctx, result.Media.TagsText) + //if serr != nil { + // log.Error("处理短剧媒资标签失败", log.Any("mediaId", mediaId), log.Any("err", serr)) + // return nil, "处理标签失败" + //} + // 将媒资中心主资源转换成本项目短剧主表模型。 + playlet := buildPlayletFromMedia(result.Media, nil, operatorId, operatorName) + playlet.MediaSource = mediaSource + if serr := ensureImportedPlayletSlug(ctx, playlet); serr != nil { + log.Error("生成短剧媒资slug失败", log.Any("mediaId", mediaId), log.Any("err", serr)) + return nil, "生成slug失败" + } + playlet.MediaSyncTime = maxPlayletMediaContentUpdateTime(0, result.MediaContentList) + // 原图继续写入 Cover/HorizontalCover;压缩成功才写压缩字段,失败保持空。 + playlet.CompressedCover = compressPlayletCoverOrEmpty(playlet.Cover) + playlet.CompressedHorizontalCover = compressPlayletCoverOrEmpty(playlet.HorizontalCover) + if playlet.Title == "" { + return nil, "媒资标题为空" + } + // 章节转换会过滤没有播放地址的数据,过滤后为空则不写主表。 + chapters := buildPlayletChaptersFromMedia(result.MediaContentList, mediaSource) + if len(chapters) == 0 { + return nil, "媒资章节为空" + } + // 先插入主表,拿到本项目自增 playlet.Id 后再写章节。 + if serr := playletmod.InsertOnePlaylet(ctx, playlet); serr != nil { + log.Error("短剧媒资主表入库失败", log.Any("mediaId", mediaId), log.Any("err", serr)) + return nil, "主资源入库失败" + } + for _, chapter := range chapters { + // 每个章节都绑定刚生成的短剧ID。 + chapter.PlayletId = playlet.Id + } + // 章节批量写入,减少同一部短剧大量章节逐条访问 Mongo 的开销。 + if serr := playletchaptermod.InsertManyPlayletChapter(ctx, chapters); serr != nil { + log.Error("短剧媒资章节批量入库失败", log.Any("mediaId", mediaId), log.Any("playletId", playlet.Id), log.Any("chapterCount", len(chapters)), log.Any("err", serr)) + // 批量写入可能已经落入部分章节,按预分配的章节ID回滚,避免半成品短剧留库。 + rollbackImportedPlayletResource(ctx, playlet.Id, collectPlayletChapterIds(chapters)) + return nil, "章节入库失败" + } + return &proto.PlayletResourceUseSuccess{ + MediaId: mediaId, + PlayletId: playlet.Id, + }, "" +} + +// collectPlayletChapterIds 收集章节ID +func collectPlayletChapterIds(chapters playletchaptermod.PlayletChapterList) []int64 { + ids := make([]int64, 0, len(chapters)) + for _, chapter := range chapters { + // 批量插入前已预分配章节ID,回滚时只删除本次任务生成的数据。 + if chapter == nil || chapter.Id == 0 { + continue + } + ids = append(ids, chapter.Id) + } + return ids +} + +// checkPlayletResourceImported 检查指定来源媒资是否已经导入到本项目短剧表 +func checkPlayletResourceImported(ctx context.Context, mediaId uint, mediaSource string) string { + // mediaCenterId 是媒资中心ID,不是本项目自增ID,用它判断是否导入过。 + filter := applyPlayletMediaSourceFilter(bson.M{playletmod.FN.Playlet.MediaCenterId: mediaId}, mediaSource) + _, err := playletmod.FindOnePlaylet(ctx, filter) + if err == nil { + return "该媒资已导入" + } + if err.Code != srvdef.ErrNoData { + log.Error("查询短剧媒资导入状态失败", log.Any("mediaId", mediaId), log.Any("err", err)) + return "查询导入状态失败" + } + return "" +} + +// buildPlayletResourceTagIds 生成短剧入库标签ID列表 +func buildPlayletResourceTagIds(ctx context.Context, tagsText string) ([]int64, *srvdef.SrvErr) { + tagIds := make([]int64, 0) + seen := make(map[int64]struct{}) + // 解析媒资中心 tags_text,并自动创建或复用本项目标签。 + for _, tagName := range splitPlayletResourceTags(tagsText) { + tagId, err := findOrCreatePlayletTag(ctx, tagName) + if err != nil { + return nil, err + } + if tagId <= 0 { + continue + } + if _, ok := seen[tagId]; ok { + continue + } + seen[tagId] = struct{}{} + tagIds = append(tagIds, tagId) + } + return tagIds, nil +} + +// splitPlayletResourceTags 拆分媒资中心标签文本 +func splitPlayletResourceTags(tagsText string) []string { + if strings.TrimSpace(tagsText) == "" { + return nil + } + // 兼容媒资中心可能返回的多种标签分隔符。 + parts := strings.FieldsFunc(tagsText, func(r rune) bool { + return r == ',' || r == ',' || r == ';' || r == ';' || r == '|' + }) + result := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + tagName := strings.TrimSpace(part) + // 空标签不入库,避免产生脏标签。 + if tagName == "" { + continue + } + // 同一个媒资标签文本里重复的标签只保留一次。 + if _, ok := seen[tagName]; ok { + continue + } + seen[tagName] = struct{}{} + result = append(result, tagName) + } + return result +} + +// findOrCreatePlayletTag 查询或创建短剧标签 +func findOrCreatePlayletTag(ctx context.Context, tagName string) (int64, *srvdef.SrvErr) { + // 先按名称查询,存在则直接复用本项目标签ID。 + tag, err := playlettagmod.FindOnePlayletTag(ctx, bson.M{playlettagmod.FN.PlayletTag.Name: tagName}) + if err == nil { + return tag.Id, nil + } + if err.Code != srvdef.ErrNoData { + return 0, err + } + // 标签不存在时创建默认上架标签,方便导入后的短剧直接关联。 + tag = &playlettagmod.PlayletTag{ + Name: tagName, + Status: true, + } + source := utils.SlugSourceFromKeywordsOrTitle(nil, tag.Name) + tag.Slug = utils.MakeUniqueSlug(source, func(slug string) bool { + exist, _ := playlettagmod.FindOnePlayletTag(ctx, bson.M{ + playlettagmod.FN.PlayletTag.Slug: slug, + }) + return exist != nil + }) + if err = playlettagmod.InsertOnePlayletTag(ctx, tag); err != nil { + return 0, err + } + return tag.Id, nil +} + +// buildPlayletFromMedia 将媒资中心主资源转换为本项目 playlet 主表模型 +func buildPlayletFromMedia(media *proto.PlayletResourceListItem, tagIds []int64, operatorId int64, operatorName string) *playletmod.Playlet { + // 封面优先使用竖图,适配短剧列表和详情页常见展示比例。 + horizontalCover := strings.TrimSpace(media.HorizontalCover) + verticalCover := strings.TrimSpace(media.VerticalCover) + cover := verticalCover + if cover == "" { + // 竖图缺失时回退到横图,避免短剧没有封面。 + cover = horizontalCover + } + return &playletmod.Playlet{ + // 保存媒资中心ID,用于重复导入拦截和后续追溯来源。 + MediaCenterId: uint(media.ID), + MediaCreatedAt: mediaResourceTime(media.CreateTime), + MediaUpdatedAt: mediaResourceTime(media.UpdateTime), + Title: strings.TrimSpace(media.Title), + Desc: strings.TrimSpace(media.Summary), + Anchor: strings.TrimSpace(media.Anchor), + Cover: cover, + HorizontalCover: horizontalCover, + CompressedHorizontalCover: "", + MediaBusiness: playletMediaBusiness(media), + CategoryIds: make([]int64, 0), + TagIds: tagIds, + ContentType: contentTypeFromMediaType(media.MediaType), + OperateType: playletmod.OperateTypePlaylet, + TotalEpisode: int64(media.TotalEpisode), + UpdateStatus: normalizePlayletUpdateStatus(media.UpdateStatus), + HeatValue: randomPlayletHeatValue(), + VirtualCountLike: randomPlayletVirtualLike(), + Status: playletmod.StatusDown, + OperatorId: operatorId, + OperatorName: operatorName, + } +} + +// 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 ensureImportedPlayletSlug(ctx context.Context, playlet *playletmod.Playlet) *srvdef.SrvErr { + if playlet == nil { + return srvdef.New(srvdef.ErrParamError, "短剧数据为空", "") + } + source := utils.SlugSourceFromKeywordsOrTitle(playlet.SeoInfo.Keyworld, playlet.Title) + playlet.Slug = utils.MakeUniqueSlug(source, func(slug string) bool { + exist, _ := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Slug: slug, + }) + return exist != nil + }) + return nil +} + +func randomPlayletHeatValue() int64 { + return int64(rand.Intn(playletHeatMaxValue-playletHeatMinValue+1) + playletHeatMinValue) +} + +func randomPlayletVirtualLike() int64 { + return int64(rand.Intn(playletVirtualLikeMaxValue-playletVirtualLikeMinValue+1) + playletVirtualLikeMinValue) +} + +// mediaResourceTime 转换媒资中心时间戳 +func mediaResourceTime(ts int64) time.Time { + // 媒资中心可能返回秒或毫秒时间戳,这里做兼容转换,避免毫秒时间被当成秒导致年份异常。 + if ts <= 0 { + return time.Time{} + } + if ts > 1e12 { + return time.UnixMilli(ts) + } + return time.Unix(ts, 0) +} + +// buildPlayletChaptersFromMedia 将媒资中心章节资源转换为本项目 playlet_chapter 模型 +func buildPlayletChaptersFromMedia(mediaContentList []playletMediaContent, mediaSource string) playletchaptermod.PlayletChapterList { + chapters := make(playletchaptermod.PlayletChapterList, 0, len(mediaContentList)) + seenMediaContentIds := make(map[uint]struct{}, len(mediaContentList)) + seenEpisodes := make(map[int]struct{}, len(mediaContentList)) + seenHashIds := make(map[string]struct{}, len(mediaContentList)) + seenVideoUrls := make(map[string]struct{}, len(mediaContentList)) + for _, item := range mediaContentList { + videoUrl := strings.TrimSpace(item.M3u8Url) + if videoUrl == "" { + // m3u8_url 缺失时回退到 urls,兼容不同资源格式。 + videoUrl = strings.TrimSpace(item.Urls) + } + if videoUrl == "" { + // 没有播放地址的章节不能正常播放,直接跳过。 + continue + } + if isDuplicatePlayletMediaChapter(item, videoUrl, seenMediaContentIds, seenEpisodes, seenHashIds, seenVideoUrls) { + continue + } + episodeNumber := int64(item.EpisodeNumber) + if episodeNumber <= 0 { + // 媒资中心没给集数时,在去重后按有效章节顺序补齐,保证章节可排序。 + episodeNumber = int64(len(chapters) + 1) + } + height := int64(item.Height) + width := int64(item.Width) + ratio := item.Ratio + if ratio <= 0 && height > 0 { + // 宽高比缺失时按宽高计算,供前端展示使用。 + ratio = float64(width) / float64(height) + } + chapters = append(chapters, &playletchaptermod.PlayletChapter{ + MediaContentId: uint(item.ID), + CurrentEpisode: episodeNumber, + Title: strings.TrimSpace(item.Title), + Hash: item.HashId, + Height: height, + Width: width, + Ratio: ratio, + VideoUrl: videoUrl, + MediaSize: int64(item.MediaSize), + MediaTime: int64(item.PlayTime), + Status: playletchaptermod.DefaultStatusByMediaSource(mediaSource), + PayType: playletchaptermod.PayTypeFree, + SingleCoins: int64(item.SaleCoin), + WatermarkStatus: playletchaptermod.WatermarkStatusPending, + }) + } + return chapters +} + +// isDuplicatePlayletMediaChapter 判断媒资中心章节是否重复 +func isDuplicatePlayletMediaChapter(item playletMediaContent, videoUrl string, seenMediaContentIds map[uint]struct{}, seenEpisodes map[int]struct{}, seenHashIds map[string]struct{}, seenVideoUrls map[string]struct{}) bool { + if item.ID > 0 { + // 媒资中心章节ID是最稳定的来源章节标识,优先用它做幂等去重。 + if _, ok := seenMediaContentIds[item.ID]; ok { + log.Info("跳过重复短剧媒资章节", log.Any("mediaContentId", item.ID), log.Any("episodeNumber", item.EpisodeNumber)) + return true + } + seenMediaContentIds[item.ID] = struct{}{} + return false + } + if item.EpisodeNumber > 0 { + // 老数据如果没有媒资章节ID,则按集数去重,避免同一集重复入库。 + if _, ok := seenEpisodes[item.EpisodeNumber]; ok { + log.Info("跳过重复短剧媒资章节", log.Any("episodeNumber", item.EpisodeNumber)) + return true + } + seenEpisodes[item.EpisodeNumber] = struct{}{} + return false + } + hashId := strings.TrimSpace(item.HashId) + if hashId != "" { + // 集数缺失时使用资源hash兜底。 + if _, ok := seenHashIds[hashId]; ok { + log.Info("跳过重复短剧媒资章节", log.Any("hashId", hashId)) + return true + } + seenHashIds[hashId] = struct{}{} + return false + } + // 最后用播放地址兜底,避免无ID、无集数、无hash的重复资源写入。 + if _, ok := seenVideoUrls[videoUrl]; ok { + log.Info("跳过重复短剧媒资章节", log.Any("videoUrl", videoUrl)) + return true + } + seenVideoUrls[videoUrl] = struct{}{} + return false +} + +// rollbackImportedPlayletResource 回滚单条媒资入库产生的数据 +func rollbackImportedPlayletResource(ctx context.Context, playletId int64, chapterIds []int64) { + for _, chapterId := range chapterIds { + // 只删除本次成功插入的章节,避免误删已有数据。 + if _, err := playletchaptermod.DelOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: chapterId}); err != nil { + log.Error("回滚短剧媒资章节失败", log.Any("playletId", playletId), log.Any("chapterId", chapterId), log.Any("err", err)) + } + } + // 章节失败后删除刚写入的主表,避免后台看到没有章节的短剧。 + if _, err := playletmod.DelOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playletId}); err != nil { + log.Error("回滚短剧媒资主表失败", log.Any("playletId", playletId), log.Any("err", err)) + } +} + +// resolveImportOperator 从后台登录态解析当前操作人,避免与 srv_playlet 循环依赖。 +func resolveImportOperator(ctx context.Context) (int64, string) { + c, ok := ctx.(*gin.Context) + if !ok || c == nil { + return 0, "" + } + accId := auth.GetAccount(c) + if accId <= 0 { + return 0, "" + } + acc, err := mwacc.FindOneAccByFilter(c, bson.M{ + mwacc.FN.MwAcc.Id: accId, + }, options.FindOne().SetProjection(bson.D{ + {Key: mwacc.FN.MwAcc.Id, Value: 1}, + {Key: mwacc.FN.MwAcc.UserName, Value: 1}, + })) + if err != nil || acc == nil { + return accId, "" + } + return acc.Id, acc.UserName +} diff --git a/web/service/srv_mediaresource/serial_sync.go b/web/service/srv_mediaresource/serial_sync.go new file mode 100644 index 0000000..af0b4a8 --- /dev/null +++ b/web/service/srv_mediaresource/serial_sync.go @@ -0,0 +1,528 @@ +package srv_mediaresource + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "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" + playletSerialSyncJHLockKeyFmt = "playlet:serial:sync:jh: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) +} + +func playletSerialSyncJHLockKey(mediaId uint) string { + return fmt.Sprintf(playletSerialSyncJHLockKeyFmt, pubdef.AppId, mediaId) +} + +type playletSerialSyncJob struct { + PlayletId int64 `json:"playletId"` + MediaCenterId uint `json:"mediaCenterId"` + MediaSource string `json:"mediaSource"` + 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, + MediaSource: playlet.MediaSource, + 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.MediaSource, 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, + MediaSource: playlet.MediaSource, + 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) { + lockSource := job.MediaSource + locked, err := lockPlayletSerialSyncBySource(lockSource, job.MediaCenterId) + if err != nil { + log.Error("获取连载短剧同步锁失败", log.Any("job", job), log.E(err)) + return 0, "获取同步锁失败" + } + if !locked { + return 0, "该媒资正在同步" + } + defer unlockPlayletSerialSyncBySource(lockSource, 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, "" + } + + mediaSource := playlet.MediaSource + if mediaSource == "" { + mediaSource = job.MediaSource + } + ms := newPlayletMediaCenterBySource(mediaSource) + 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, mediaSource, 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 playName := strings.TrimSpace(playlet.PlayName); playName != "" { + description := playletmod.BuildPlayNameDescription(playlet.Desc, playName, updateStatus, int64(media.TotalEpisode)) + if description != "" { + setDoc[playletmod.FN.Playlet.Description] = description + setDoc[playletmod.FN.Playlet.SeoInfo.Description] = description + } + } + 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, mediaSource string, mediaContentList []playletMediaContent) (playletchaptermod.PlayletChapterList, string) { + exists, err := loadExistingPlayletChapterKeys(ctx, playletId) + if err != nil { + return nil, "查询已有章节失败" + } + chapters := buildPlayletChaptersFromMedia(mediaContentList, mediaSource) + 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{}{} + } +} + +// 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 +} + +// 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)) + } +} + +// lockPlayletSerialSyncBySource 按媒资来源锁定单个媒资ID +func lockPlayletSerialSyncBySource(mediaSource string, mediaId uint) (bool, error) { + if mediaSource == playletmod.MediaSourceHG { + return lockPlayletSerialSync(mediaId) + } + return redis.Setnx_NewOK(playletSerialSyncJHLockKey(mediaId), true, playletSerialSyncLockTTL) +} + +// unlockPlayletSerialSyncBySource 按媒资来源释放单个媒资ID的同步锁 +func unlockPlayletSerialSyncBySource(mediaSource string, mediaId uint) { + if mediaSource == playletmod.MediaSourceHG { + unlockPlayletSerialSync(mediaId) + return + } + if err := redis.Del(playletSerialSyncJHLockKey(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/cache.go b/web/service/srv_playlet/cache.go new file mode 100644 index 0000000..9050238 --- /dev/null +++ b/web/service/srv_playlet/cache.go @@ -0,0 +1,8 @@ +package srv_playlet + +import "videoweb/pub/common/playletcache" + +// clearAppPlayletHomeCache 清除app首页短剧缓存 +func clearAppPlayletHomeCache() { + playletcache.ClearHomeCache() +} diff --git a/web/service/srv_playlet/operator.go b/web/service/srv_playlet/operator.go new file mode 100644 index 0000000..5b832b2 --- /dev/null +++ b/web/service/srv_playlet/operator.go @@ -0,0 +1,57 @@ +package srv_playlet + +import ( + "context" + "videoweb/pub/models/mwacc" + "videoweb/pub/models/playletmod" + "videoweb/web/middleware/auth" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// ResolveOperator 解析当前后台登录操作人,供短剧写操作与媒资入库共用。 +func ResolveOperator(ctx context.Context) (int64, string) { + return resolvePlayletOperator(ctx) +} + +// resolvePlayletOperator 从后台登录态解析当前操作人。 +func resolvePlayletOperator(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 +} + +// applyPlayletOperatorToSetDoc 将当前操作人写入更新文档。 +func applyPlayletOperatorToSetDoc(ctx context.Context, setDoc bson.M) { + if setDoc == nil { + return + } + operatorId, operatorName := resolvePlayletOperator(ctx) + setDoc[playletmod.FN.Playlet.OperatorId] = operatorId + setDoc[playletmod.FN.Playlet.OperatorName] = operatorName +} + +// applyPlayletOperatorToDoc 将当前操作人写入新增文档。 +func applyPlayletOperatorToDoc(ctx context.Context, doc *playletmod.Playlet) { + if doc == nil { + return + } + doc.OperatorId, doc.OperatorName = resolvePlayletOperator(ctx) +} diff --git a/web/service/srv_playlet/playlet.go b/web/service/srv_playlet/playlet.go new file mode 100644 index 0000000..5736bc0 --- /dev/null +++ b/web/service/srv_playlet/playlet.go @@ -0,0 +1,1946 @@ +package srv_playlet + +import ( + "context" + "math/rand" + "regexp" + "strings" + "time" + "videoweb/app/service/reportser" + "videoweb/pub/common/log" + "videoweb/pub/common/response" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletchaptercollectmod" + "videoweb/pub/models/playletchapterlikemod" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/playletchapterunlockmod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/pub/models/playletthememod" + "videoweb/pub/pubdef" + "videoweb/web/api/proto" + "videoweb/web/service/srv_aicomment" + "videoweb/web/service/srv_mediaresource" + "videoweb/web/service/srv_search" + "videoweb/web/skd/playletwatermark" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + defaultPageSize int64 = 10 + maxPageSize int64 = 100 +) + +// 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 && req.SortType == proto.PlayletListSortRecommendRank { + list, err = playletmod.FindManyPlayletByCategoryRank(ctx, filter, *req.CategoryId, (pageNum-1)*pageSize, pageSize, playletmod.CategoryRankAdminSort(), nil) + } else { + list, err = playletmod.FindManyPlaylet(ctx, filter, options.Find(). + SetSort(buildPlayletListSort(req.SortType)). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + } + 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 + } + items = append(items, playletToWebResp(item, themeNameMap, tagNameMap, req.CategoryId)) + } + resp.List = items + return resp, nil +} + +// AddPlaylet 新增短剧 +func AddPlaylet(ctx context.Context, req *proto.PlayletAddReq) (*proto.PlayletAddResp, *srvdef.SrvErr) { + if req == nil || strings.TrimSpace(req.Title) == "" { + return nil, srvdef.New(srvdef.ErrParamError, "标题不能为空", "") + } + 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 + } + banner := strings.TrimSpace(req.Banner) + if req.ShowOnBanner && banner == "" { + return nil, srvdef.New(srvdef.ErrParamError, "显示在banner时,banner图不能为空", "") + } + if err := validatePlayletTagIds(ctx, req.TagIds); err != nil { + return nil, err + } + categoryBinds, err := resolveAddPlayletCategoryBinds(req.CategoryBinds, req.CategoryIds) + if err != nil { + return nil, err + } + payType, singleCoins, srvErr := normalizePlayletPaySetting(req.PayType, &req.SingleCoins, true) + if srvErr != nil { + return nil, srvErr + } + operateType, srvErr := normalizePlayletOperateType(req.OperateType, true) + if srvErr != nil { + return nil, srvErr + } + doc := &playletmod.Playlet{ + ThemeIds: themeIds, + CategoryIds: playletmod.CategoryIdsFromBinds(categoryBinds), + CategoryBinds: categoryBinds, + Title: strings.TrimSpace(req.Title), + PlayName: strings.TrimSpace(req.PlayName), + Slug: strings.TrimSpace(req.Slug), + Desc: strings.TrimSpace(req.Desc), + Anchor: strings.TrimSpace(req.Anchor), + Cover: req.Cover, + HorizontalCover: strings.TrimSpace(req.HorizontalCover), + Preview: strings.TrimSpace(req.Preview), + TagIds: req.TagIds, + ContentType: req.ContentType, + OperateType: operateType, + PayType: payType, + SingleCoins: singleCoins, + TotalEpisode: req.TotalEpisode, + TopRank: req.TopRank, + RecommendRank: req.RecommendRank, + Status: req.Status, + IsFeatured: req.IsFeatured, + IsOriginal: true, // 管理后台新增的短剧都是原创 + ShowOriginal: req.ShowOriginal, + IsRecommend: req.IsRecommend, + ShowOnBanner: req.ShowOnBanner, + Banner: banner, + MobileBanner: strings.TrimSpace(req.MobileBanner), + SeoInfo: normalizeSeoValue(req.SeoInfo), + } + applyPlayletDescription(doc) + if doc.Status == playletmod.StatusUp { + doc.OnlineAt = resolvePlayletOnlineAt(req.OnlineAt, time.Now()) + } + if doc.TagIds == nil { + doc.TagIds = make([]int64, 0) + } + if doc.CategoryBinds == nil { + doc.CategoryBinds = make([]playletmod.PlayletCategoryBind, 0) + } + doc.CategoryIds = playletmod.CategoryIdsFromBinds(doc.CategoryBinds) + applyPlayletOperatorToDoc(ctx, doc) + if err := ensurePlayletSlug(ctx, doc, 0); err != nil { + return nil, err + } + if err := playletmod.InsertOnePlaylet(ctx, doc); err != nil { + return nil, err + } + go func() { + srv_mediaresource.FillPlayletCompressedCover(context.Background(), doc) + srv_mediaresource.FillPlayletCompressedHorizontalCover(context.Background(), doc) + }() + clearAppPlayletHomeCache() + notifyPlayletSeoAfterWrite(ctx, doc.Id, nil) + // 上架短剧异步写入 ES(含 SEO),避免阻塞后台接口。 + if doc.Status == playletmod.StatusUp { + syncId := doc.Id + submitPlayletAiComments(syncId) + go func() { + if err := srv_search.SyncPlayletStatusToES(context.Background(), []int64{syncId}, playletmod.StatusUp); err != nil { + log.Error("短剧新增同步ES失败", log.Any("短剧ID", syncId), log.E(err)) + } + }() + } + return &proto.PlayletAddResp{Id: doc.Id}, nil +} + +// UpdatePlaylet 更新短剧 +func UpdatePlaylet(ctx context.Context, req *proto.PlayletUpdateReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "id无效", "") + } + if req.ShowOnBanner != nil || req.Banner != nil { + exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) + if err != nil { + return err + } + showOnBanner := exist.ShowOnBanner + banner := exist.Banner + if req.ShowOnBanner != nil { + showOnBanner = *req.ShowOnBanner + } + if req.Banner != nil { + banner = strings.TrimSpace(*req.Banner) + } + if showOnBanner && banner == "" { + return srvdef.New(srvdef.ErrParamError, "显示在banner时,banner图不能为空", "") + } + } + exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) + if err != nil { + return err + } + setDoc := bson.M{playletmod.FN.Playlet.UpdatedAt: time.Now()} + if req.Status != nil { + setDoc[playletmod.FN.Playlet.Status] = *req.Status + setDoc[playletmod.FN.Playlet.ReviewAt] = time.Now() + } + 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.MobileBanner != nil { + setDoc[playletmod.FN.Playlet.MobileBanner] = strings.TrimSpace(*req.MobileBanner) + } + 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.CategoryBinds != nil || req.CategoryIds != nil { + categoryBinds, serr := resolvePlayletCategoryBinds(req.CategoryBinds, req.CategoryIds, playletmod.EffectiveCategoryBinds(exist)) + if serr != nil { + return serr + } + setDoc[playletmod.FN.Playlet.CategoryBinds] = categoryBinds + setDoc[playletmod.FN.Playlet.CategoryIds] = playletmod.CategoryIdsFromBinds(categoryBinds) + } + if req.Title != nil { + setDoc[playletmod.FN.Playlet.Title] = strings.TrimSpace(*req.Title) + } + if req.PlayName != nil { + setDoc[playletmod.FN.Playlet.PlayName] = strings.TrimSpace(*req.PlayName) + } + 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 + horizontalCoverChanged := false + if req.Cover != nil { + newCover := strings.TrimSpace(*req.Cover) + setDoc[playletmod.FN.Playlet.Cover] = newCover + exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) + if err != nil { + return err + } + if exist == nil || strings.TrimSpace(exist.Cover) != newCover { + // 封面变更后清空压缩封面,由后台任务按新原图重新压缩。 + setDoc[playletmod.FN.Playlet.CompressedCover] = "" + coverChanged = true + } + } + if req.HorizontalCover != nil { + newHorizontalCover := strings.TrimSpace(*req.HorizontalCover) + setDoc[playletmod.FN.Playlet.HorizontalCover] = newHorizontalCover + exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) + if err != nil { + return err + } + if exist == nil || strings.TrimSpace(exist.HorizontalCover) != newHorizontalCover { + setDoc[playletmod.FN.Playlet.CompressedHorizontalCover] = "" + horizontalCoverChanged = true + } else if newHorizontalCover != "" && strings.TrimSpace(exist.CompressedHorizontalCover) == "" { + horizontalCoverChanged = true + } + } + if req.Preview != nil { + setDoc[playletmod.FN.Playlet.Preview] = strings.TrimSpace(*req.Preview) + } + if req.TagIds != nil { + if err := validatePlayletTagIds(ctx, *req.TagIds); err != nil { + return err + } + setDoc[playletmod.FN.Playlet.TagIds] = *req.TagIds + } + if req.ContentType != nil { + setDoc[playletmod.FN.Playlet.ContentType] = *req.ContentType + } + if req.OperateType != nil { + operateType, srvErr := normalizePlayletOperateType(*req.OperateType, false) + if srvErr != nil { + return srvErr + } + setDoc[playletmod.FN.Playlet.OperateType] = operateType + } + if req.PayType != nil || req.SingleCoins != nil { + existPayType := playletmod.PayTypeFree + existCoins := int64(0) + if req.PayType == nil || req.SingleCoins == nil { + exist, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: req.Id}) + if err != nil { + return err + } + existPayType = exist.PayType + existCoins = exist.SingleCoins + } + payType := existPayType + if req.PayType != nil { + payType = *req.PayType + } + coins := &existCoins + if req.SingleCoins != nil { + coins = req.SingleCoins + } + normalizedPayType, normalizedCoins, srvErr := normalizePlayletPaySetting(payType, coins, true) + if srvErr != nil { + return srvErr + } + setDoc[playletmod.FN.Playlet.PayType] = normalizedPayType + setDoc[playletmod.FN.Playlet.SingleCoins] = normalizedCoins + } + 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) + } + var prevStatus *playletmod.Status + if exist != nil { + st := exist.Status + prevStatus = &st + } + applyPlayletOnlineAtOnUpdate(exist, req, setDoc, time.Now()) + if err := preparePlayletSlugOnUpdate(ctx, req.Id, exist, req, setDoc); err != nil { + return err + } + applyPlayletDescriptionOnUpdate(exist, req, setDoc) + if len(setDoc) <= 1 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + if err := playletmod.UpdateOnePlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: req.Id}, + bson.M{"$set": setDoc}, + ); 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)) + } + if horizontalCoverChanged { + go func(playletId int64, cover string) { + srv_mediaresource.FillPlayletCompressedHorizontalCover(context.Background(), &playletmod.Playlet{ + Id: playletId, + HorizontalCover: cover, + }) + }(req.Id, strings.TrimSpace(*req.HorizontalCover)) + } + clearAppPlayletHomeCache() + notifyPlayletSeoAfterWrite(ctx, req.Id, prevStatus) + if req.Status != nil && *req.Status == playletmod.StatusUp { + submitPlayletAiComments(req.Id) + } + // 编辑后按当前上下架状态异步同步 ES(含 SEO、题材名等搜索字段)。 + syncId := req.Id + go func() { + exist, err := playletmod.FindOnePlaylet(context.Background(), bson.M{ + playletmod.FN.Playlet.Id: syncId, + }, options.FindOne().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.Status, Value: 1}, + })) + if err != nil || exist == nil { + if err != nil { + log.Error("短剧更新后查询状态失败,跳过ES同步", log.Any("短剧ID", syncId), log.E(err)) + } + return + } + if err := srv_search.SyncPlayletStatusToES(context.Background(), []int64{syncId}, exist.Status); err != nil { + log.Error("短剧更新同步ES失败", log.Any("短剧ID", syncId), log.Any("状态", exist.Status), log.E(err)) + } + }() + return nil +} + +// DelPlaylet 单/批量物理删除短剧及其章节 +func DelPlaylet(ctx context.Context, req *proto.PlayletDelReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + 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.Status, Value: 1}, + })) + if err != nil { + return err + } + if len(list) == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + deleteIds := make([]int64, 0, len(list)) + onlineIds := make([]int64, 0, len(list)) + for _, item := range list { + if item == nil || item.Id <= 0 { + continue + } + deleteIds = append(deleteIds, item.Id) + if item.Status == playletmod.StatusUp { + onlineIds = append(onlineIds, item.Id) + } + } + if len(deleteIds) == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + deletedPlayletCount, err := playletmod.DelManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": deleteIds}, + }) + if err != nil { + return err + } + if deletedPlayletCount == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + if _, err := playletchaptermod.DelManyPlayletChapter(ctx, bson.M{ + playletchaptermod.FN.PlayletChapter.PlayletId: bson.M{"$in": deleteIds}, + }); err != nil { + return err + } + clearAppPlayletHomeCache() + notifyPlayletSeoAfterDelete(ctx, onlineIds) + syncIds := append([]int64(nil), deleteIds...) + go func() { + if err := srv_search.SyncPlayletStatusToES(context.Background(), syncIds, playletmod.StatusDown); err != nil { + log.Error("短剧物理删除同步ES失败", log.Any("短剧ID", syncIds), log.E(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 { + // 传入上架时间则整批使用该时间。未传时不在这里覆盖,避免改掉已有上架时间。 + if onlineAt, ok := explicitPlayletOnlineAt(req.OnlineAt); ok { + setDoc[playletmod.FN.Playlet.OnlineAt] = onlineAt + } + setDoc[playletmod.FN.Playlet.IndexNow] = int64(0) + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + // 单条和批量都走 Mongo 批量更新,避免批量操作时逐条访问数据库。 + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + if req.Status == playletmod.StatusUp { + if _, ok := explicitPlayletOnlineAt(req.OnlineAt); !ok { + if err := fillUnsetPlayletOnlineAt(ctx, ids, now); err != nil { + return err + } + } + } + clearAppPlayletHomeCache() + notifyPlayletSeoAfterStatus(ctx, ids, req.Status, now) + // 上架会同步写入 ES,ES Bulk 带 refresh 且无超时,同步等待会把接口卡住。 + // Mongo 已更新成功后异步同步 ES,避免 status=1 时接口无响应。 + syncIds := append([]int64(nil), ids...) + syncStatus := req.Status + go func() { + if err := srv_search.SyncPlayletStatusToES(context.Background(), syncIds, syncStatus); err != nil { + log.Error("短剧状态同步ES失败", log.Any("短剧ID", syncIds), log.Any("状态", syncStatus), log.E(err)) + } + }() + if flag := reportser.PlayletStatusFlag(req.Status); flag != 0 { + reportPlayletStatusEvents(ctx, ids, flag) + } + if req.Status == playletmod.StatusUp { + submitPlayletAiComments(ids...) + } + return nil +} + +func submitPlayletAiComments(ids ...int64) { + playletIDs := dedupePositiveInt64Ids(ids) + if len(playletIDs) == 0 { + return + } + go func() { + if err := srv_aicomment.AutoProcessUnsubmittedPlaylets(context.Background(), playletIDs); err != nil { + log.Warn("提交短剧自动评论任务失败", log.E(err), log.Any("playletIDs", playletIDs)) + } + }() +} + +func reportPlayletStatusEvents(ctx context.Context, ids []int64, flag int64) { + if len(ids) == 0 { + return + } + 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.Title, Value: 1}, + {Key: playletmod.FN.Playlet.MediaCenterId, Value: 1}, + })) + if err != nil { + log.Error("短剧状态变更上报查询失败", log.Any("短剧ID", ids), log.E(err)) + return + } + for _, item := range list { + reportser.ReportPlayletStatusChange(item, flag) + } +} + +// 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不能为空", "") + } + // isRecommend 是明确的 bool 开关,false 表示取消推荐。 + setDoc := bson.M{ + playletmod.FN.Playlet.IsRecommend: req.IsRecommend, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + 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 +} + +// 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不能为空", "") + } + // isFeatured 是爆剧开关,直接批量写入请求值,false 表示取消爆剧。 + setDoc := bson.M{ + playletmod.FN.Playlet.IsFeatured: req.IsFeatured, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + 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 +} + +// SetPlayletOperateType 单/批量设置短剧运营类型 +func SetPlayletOperateType(ctx context.Context, req *proto.PlayletOperateTypeSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids := dedupePositiveInt64Ids(req.Ids) + if len(ids) == 0 { + return srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "") + } + operateType, srvErr := normalizePlayletOperateType(req.OperateType, false) + if srvErr != nil { + return srvErr + } + setDoc := bson.M{ + playletmod.FN.Playlet.OperateType: operateType, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + 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 +} + +// 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不能为空", "") + } + // showOnBanner 是首页banner展示开关,只负责批量设置字段值。 + setDoc := bson.M{ + playletmod.FN.Playlet.ShowOnBanner: req.ShowOnBanner, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + 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 +} + +// 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 + } + // themeIds 是短剧多题材字段,按请求覆盖原题材集合。 + setDoc := bson.M{ + playletmod.FN.Playlet.ThemeIds: themeIds, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + markSitemapDirty(ctx) + 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 := normalizePlayletTagIds(*req.TagIds) + if err != nil { + return err + } + if err := validatePlayletTagIds(ctx, tagIds); err != nil { + return err + } + // tagIds 是短剧多标签字段,按请求覆盖原标签集合;空数组表示清空标签。 + setDoc := bson.M{ + playletmod.FN.Playlet.TagIds: tagIds, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + markSitemapDirty(ctx) + 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.CategoryBinds == nil && req.CategoryIds == nil { + return srvdef.New(srvdef.ErrParamError, "分类不能为空", "") + } + categoryBinds, err := resolvePlayletCategoryBinds(req.CategoryBinds, req.CategoryIds, nil) + if err != nil { + return err + } + setDoc := bson.M{ + playletmod.FN.Playlet.CategoryBinds: categoryBinds, + playletmod.FN.Playlet.CategoryIds: playletmod.CategoryIdsFromBinds(categoryBinds), + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + if err := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ); err != nil { + return err + } + clearAppPlayletHomeCache() + markSitemapDirty(ctx) + return nil +} + +// SetPlayletVirtualLike 单/批量设置短剧虚拟点赞数 +func SetPlayletVirtualLike(ctx context.Context, req *proto.PlayletVirtualLikeSetReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + // 去重并过滤无效ID,避免同一短剧在一次批量请求中被重复赋值。 + 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, + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + 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() + syncIds := append([]int64(nil), ids...) + go func() { + // 虚拟点赞数会写入 ES 的展示点赞数字段,后台保存成功后异步刷新 ES。 + if err := srv_search.SyncPlayletStatusToES(context.Background(), syncIds, playletmod.StatusUp); err != nil { + log.Error("短剧虚拟点赞数同步ES失败", log.Any("短剧ID", syncIds), log.E(err)) + } + }() + return nil +} + +// randomUniqueVirtualLikeValues 从[min,max]范围内随机取count个不重复的虚拟点赞数 +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) + // 使用Floyd采样算法,避免在大范围随机时为了去重反复重试。 + if _, ok := selected[picked]; ok { + selected[current] = struct{}{} + result = append(result, current) + continue + } + selected[picked] = struct{}{} + result = append(result, picked) + } + rng.Shuffle(len(result), func(i, j int) { + result[i], result[j] = result[j], result[i] + }) + return result +} + +// 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.Status != nil { + filter[playletchaptermod.FN.PlayletChapter.Status] = *req.Status + } + 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无效", "") + } + 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: req.Md5, + Hash: 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: req.SingleCoins, + Status: playletchaptermod.DefaultStatusByMediaSource(playlet.MediaSource), + } + if req.Status != nil { + if !isValidPlayletChapterStatus(*req.Status) { + return nil, srvdef.New(srvdef.ErrParamError, "上下架状态无效", "") + } + doc.Status = *req.Status + } + 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 +} + +// 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 { + 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] = *req.Md5 + } + if req.Hash != nil { + setDoc[playletchaptermod.FN.PlayletChapter.Hash] = *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] = *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 { + setDoc[playletchaptermod.FN.PlayletChapter.PayType] = *req.PayType + } + if req.SingleCoins != nil { + setDoc[playletchaptermod.FN.PlayletChapter.SingleCoins] = *req.SingleCoins + } + if req.Status != nil { + if !isValidPlayletChapterStatus(*req.Status) { + return srvdef.New(srvdef.ErrParamError, "上下架状态无效", "") + } + setDoc[playletchaptermod.FN.PlayletChapter.Status] = *req.Status + } + 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}, + ) +} + +// 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, "", "") + } + if _, serr := playletchapterlikemod.DeleteManyPlayletChapterLike(ctx, bson.M{ + playletchapterlikemod.FN.PlayletChapterLike.ChapterId: bson.M{"$in": ids}, + }); serr != nil { + log.Warn("删除剧集后清理点赞关系失败", log.Any("chapterIds", ids), log.E(serr)) + } + if _, serr := playletchaptercollectmod.DeleteManyPlayletChapterCollect(ctx, bson.M{ + playletchaptercollectmod.FN.PlayletChapterCollect.ChapterId: bson.M{"$in": ids}, + }); serr != nil { + log.Warn("删除剧集后清理收藏关系失败", log.Any("chapterIds", ids), log.E(serr)) + } + if _, serr := playletchapterunlockmod.DeleteManyPlayletChapterUnlock(ctx, bson.M{ + playletchapterunlockmod.FN.PlayletChapterUnlock.ChapterId: bson.M{"$in": ids}, + }); serr != nil { + log.Warn("删除剧集后清理解锁记录失败", log.Any("chapterIds", ids), log.E(serr)) + } + return nil +} + +// FillPlayletChapterStatusUp 将未写入上架状态的历史剧集设为上架 +func FillPlayletChapterStatusUp(ctx context.Context) (*proto.PlayletChapterStatusFillResp, *srvdef.SrvErr) { + filter := bson.M{ + "$or": []bson.M{ + {playletchaptermod.FN.PlayletChapter.Status: bson.M{"$exists": false}}, + {playletchaptermod.FN.PlayletChapter.Status: nil}, + }, + } + count, err := playletchaptermod.CountPlayletChapter(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletChapterStatusFillResp{} + if count == 0 { + return resp, nil + } + if err := playletchaptermod.UpdateManyPlayletChapter(ctx, filter, bson.M{ + "$set": bson.M{playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp}, + }); err != nil { + return nil, err + } + resp.UpdatedCount = count + return resp, nil +} + +// SetPlayletChapterStatus 单/批量设置章节上架状态 +func SetPlayletChapterStatus(ctx context.Context, req *proto.PlayletChapterStatusSetReq) *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 !isValidPlayletChapterStatus(req.Status) { + return srvdef.New(srvdef.ErrParamError, "上下架状态无效", "") + } + return playletchaptermod.UpdateManyPlayletChapter(ctx, + bson.M{playletchaptermod.FN.PlayletChapter.Id: bson.M{"$in": ids}}, + bson.M{"$set": bson.M{ + playletchaptermod.FN.PlayletChapter.Status: req.Status, + playletchaptermod.FN.PlayletChapter.UpdatedAt: time.Now(), + }}, + ) +} + +// 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}, + ) +} + +// 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, srvErr := normalizePlayletPaySetting(req.PayType, req.SingleCoins, true) + if srvErr != nil { + return srvErr + } + setDoc := bson.M{ + playletmod.FN.Playlet.PayType: payType, + playletmod.FN.Playlet.SingleCoins: singleCoins, + playletmod.FN.Playlet.UpdatedAt: time.Now(), + } + applyPlayletOperatorToSetDoc(ctx, setDoc) + return playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": setDoc}, + ) +} + +// 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, + }, + "$unset": bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkRetryAt: "", + }, + }, + ) + 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, + }, + "$unset": bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkRetryAt: "", + }, + }, + ) + if err != nil && err.Code == srvdef.ErrNoData { + return nil + } + return err +} + +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.ThemeId != nil { + filter[playletmod.FN.Playlet.ThemeIds] = bson.M{"$in": []int64{*req.ThemeId}} + } + if req.CategoryId != nil { + filter[playletmod.FN.Playlet.CategoryIds] = bson.M{"$in": []int64{*req.CategoryId}} + } + if req.TagId != nil { + filter[playletmod.FN.Playlet.TagIds] = bson.M{"$in": []int64{*req.TagId}} + } + if req.ContentType != nil { + filter[playletmod.FN.Playlet.ContentType] = *req.ContentType + } + if req.OperateType != nil { + if *req.OperateType == playletmod.OperateTypeComic { + filter[playletmod.FN.Playlet.OperateType] = playletmod.OperateTypeComic + } else { + // 历史数据可能没有该字段,按短剧处理 + filter[playletmod.FN.Playlet.OperateType] = bson.M{"$ne": playletmod.OperateTypeComic} + } + } + 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 slug := strings.TrimSpace(req.Slug); slug != "" { + filter[playletmod.FN.Playlet.Slug] = slug + } + if operatorName := strings.TrimSpace(req.OperatorName); operatorName != "" { + filter[playletmod.FN.Playlet.OperatorName] = bson.M{"$regex": regexp.QuoteMeta(operatorName)} + } + if req.MediaSource != nil { + mediaSource := strings.TrimSpace(*req.MediaSource) + if mediaSource == playletmod.MediaSourceHG { + filter[playletmod.FN.Playlet.MediaSource] = playletmod.MediaSourceHG + } else if mediaSource == playletmod.MediaSourceDefault { + filter["$or"] = []bson.M{ + {playletmod.FN.Playlet.MediaSource: playletmod.MediaSourceDefault}, + {playletmod.FN.Playlet.MediaSource: bson.M{"$exists": false}}, + } + } else { + filter[playletmod.FN.Playlet.MediaSource] = mediaSource + } + } + return filter +} + +// dedupePositiveInt64Ids 过滤无效ID并去重 +func dedupePositiveInt64Ids(ids []int64) []int64 { + result := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + // 只有正数ID才是有效的 Mongo 业务自增ID。 + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} + +func resolveAddPlayletCategoryBinds(binds []proto.PlayletCategoryBind, ids []int64) ([]playletmod.PlayletCategoryBind, *srvdef.SrvErr) { + if len(binds) > 0 { + return resolvePlayletCategoryBinds(&binds, nil, nil) + } + return resolvePlayletCategoryBinds(nil, &ids, nil) +} + +func resolvePlayletCategoryBinds(binds *[]proto.PlayletCategoryBind, ids *[]int64, exist []playletmod.PlayletCategoryBind) ([]playletmod.PlayletCategoryBind, *srvdef.SrvErr) { + if binds != nil { + return playletmod.NormalizeCategoryBinds(toModCategoryBinds(*binds)), nil + } + if ids != nil { + categoryIds := dedupePositiveInt64Ids(*ids) + if len(exist) > 0 { + return playletmod.MergeCategoryBindsByIds(exist, categoryIds), nil + } + return playletmod.CategoryBindsFromIds(categoryIds), nil + } + return make([]playletmod.PlayletCategoryBind, 0), nil +} + +func toModCategoryBinds(binds []proto.PlayletCategoryBind) []playletmod.PlayletCategoryBind { + result := make([]playletmod.PlayletCategoryBind, 0, len(binds)) + for _, bind := range binds { + result = append(result, playletmod.PlayletCategoryBind{Id: bind.Id, Rank: bind.Rank}) + } + return result +} + +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 +} + +// isValidPlayletStatus 校验短剧上下架状态 +func isValidPlayletStatus(status playletmod.Status) bool { + return status == playletmod.StatusDown || status == playletmod.StatusUp +} + +// resolvePlayletOnlineAt 上架时间未传或为零时使用当前时间 +func resolvePlayletOnlineAt(passed *time.Time, now time.Time) time.Time { + if onlineAt, ok := explicitPlayletOnlineAt(passed); ok { + return onlineAt + } + return now +} + +// explicitPlayletOnlineAt 返回请求里显式传入的上架时间。未传或零值视为未传。 +func explicitPlayletOnlineAt(passed *time.Time) (time.Time, bool) { + if passed == nil || passed.IsZero() { + return time.Time{}, false + } + return *passed, true +} + +// fillUnsetPlayletOnlineAt 给从未设置过上架时间的短剧写入当前时间。已有上架时间的记录不改。 +func fillUnsetPlayletOnlineAt(ctx context.Context, ids []int64, now time.Time) *srvdef.SrvErr { + if len(ids) == 0 { + return nil + } + err := playletmod.UpdateManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": ids}, + "$or": unsetPlayletOnlineAtClauses(), + }, bson.M{"$set": bson.M{ + playletmod.FN.Playlet.OnlineAt: now, + }}) + if err != nil && err.Code == srvdef.ErrNoData { + return nil + } + return err +} + +func unsetPlayletOnlineAtClauses() []bson.M { + return []bson.M{ + {playletmod.FN.Playlet.OnlineAt: bson.M{"$exists": false}}, + {playletmod.FN.Playlet.OnlineAt: nil}, + {playletmod.FN.Playlet.OnlineAt: time.Time{}}, + } +} + +// applyPlayletOnlineAtOnUpdate 编辑短剧时写入上架时间。 +// 新上架未传时间则用当前时间;已上架仅在显式传入时更新。 +func applyPlayletOnlineAtOnUpdate(exist *playletmod.Playlet, req *proto.PlayletUpdateReq, setDoc bson.M, now time.Time) { + if req == nil || setDoc == nil { + return + } + goingUp := req.Status != nil && *req.Status == playletmod.StatusUp && (exist == nil || exist.Status != playletmod.StatusUp) + if goingUp { + setDoc[playletmod.FN.Playlet.OnlineAt] = resolvePlayletOnlineAt(req.OnlineAt, now) + setDoc[playletmod.FN.Playlet.IndexNow] = int64(0) + return + } + if req.OnlineAt != nil && !req.OnlineAt.IsZero() { + setDoc[playletmod.FN.Playlet.OnlineAt] = *req.OnlineAt + } +} + +// FillEmptyPlayletOnlineAt 将已上架但上架时间为空的短剧,回填为该条修改时间 +func FillEmptyPlayletOnlineAt(ctx context.Context) (*proto.PlayletOnlineAtFillResp, *srvdef.SrvErr) { + filter := emptyOnlineAtUpPlayletFilter() + list, err := playletmod.FindManyPlaylet(ctx, filter, options.Find().SetProjection(bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.CreatedAt, Value: 1}, + })) + if err != nil { + return nil, err + } + resp := &proto.PlayletOnlineAtFillResp{} + if len(list) == 0 { + return resp, nil + } + writes := make([]mongo.WriteModel, 0, len(list)) + ids := make([]int64, 0, len(list)) + for _, item := range list { + if item == nil || item.Id <= 0 { + continue + } + onlineAt := item.UpdatedAt + if onlineAt.IsZero() { + onlineAt = item.CreatedAt + } + if onlineAt.IsZero() { + continue + } + writes = append(writes, mongo.NewUpdateOneModel(). + SetFilter(bson.M{playletmod.FN.Playlet.Id: item.Id}). + SetUpdate(bson.M{"$set": bson.M{ + playletmod.FN.Playlet.OnlineAt: onlineAt, + }})) + ids = append(ids, item.Id) + } + if len(writes) == 0 { + return resp, nil + } + if err := playletmod.BulkWritePlaylet(ctx, writes); err != nil { + return nil, err + } + resp.UpdatedCount = int64(len(ids)) + clearAppPlayletHomeCache() + syncIds := append([]int64(nil), ids...) + go func() { + if err := srv_search.SyncPlayletStatusToES(context.Background(), syncIds, playletmod.StatusUp); err != nil { + log.Error("回填上架时间同步ES失败", log.Any("短剧ID", syncIds), log.E(err)) + } + }() + return resp, nil +} + +func emptyOnlineAtUpPlayletFilter() bson.M { + return bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + "$or": unsetPlayletOnlineAtClauses(), + } +} + +// isValidPlayletChapterStatus 校验章节上下架状态 +func isValidPlayletChapterStatus(status playletchaptermod.Status) bool { + return status == playletchaptermod.StatusDown || status == playletchaptermod.StatusUp +} + +// isValidPlayletChapterPayType 校验章节支付方式 +func isValidPlayletChapterPayType(payType playletchaptermod.PayType) bool { + return payType == playletchaptermod.PayTypeFree || + payType == playletchaptermod.PayTypeCoin || + payType == playletchaptermod.PayTypeVip +} + +func normalizePlayletOperateType(operateType playletmod.OperateType, allowDefault bool) (playletmod.OperateType, *srvdef.SrvErr) { + if operateType == 0 && allowDefault { + return playletmod.OperateTypePlaylet, nil + } + if !playletmod.IsValidOperateType(operateType) { + return 0, srvdef.New(srvdef.ErrParamError, "运营类型无效", "") + } + return operateType, nil +} + +// isValidPlayletPayType 校验短剧默认支付方式 +func isValidPlayletPayType(payType playletmod.PayType) bool { + return payType == playletmod.PayTypeFree || + payType == playletmod.PayTypeCoin || + payType == playletmod.PayTypeVip +} + +// normalizePlayletPaySetting 规范化短剧默认支付方式;非金币时单集金币清零。 +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 +} + +// buildPlayletListSort 后台列表排序,均为降序;未传或非法值按创建时间。 +func buildPlayletListSort(sortType proto.PlayletListSortType) bson.D { + sortKey := playletmod.FN.Playlet.CreatedAt + switch sortType { + case proto.PlayletListSortRecommendRank: + sortKey = playletmod.FN.Playlet.RecommendRank + case proto.PlayletListSortUpdatedAt: + sortKey = playletmod.FN.Playlet.UpdatedAt + case proto.PlayletListSortCountLike: + sortKey = playletmod.FN.Playlet.CountLike + case proto.PlayletListSortCountBrowse: + sortKey = playletmod.FN.Playlet.CountBrowse + case proto.PlayletListSortCountCollect: + sortKey = playletmod.FN.Playlet.CountCollect + case proto.PlayletListSortOnlineAt: + sortKey = playletmod.FN.Playlet.OnlineAt + } + return bson.D{ + {Key: sortKey, Value: -1}, + {Key: playletmod.FN.Playlet.Id, Value: -1}, + } +} + +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 applyPlayletDescription(doc *playletmod.Playlet) { + if doc == nil { + return + } + description := playletmod.BuildPlayNameDescription(doc.Desc, doc.PlayName, doc.UpdateStatus, doc.TotalEpisode) + if description == "" { + return + } + doc.Description = description + doc.SeoInfo.Description = description +} + +func applyPlayletDescriptionOnUpdate(exist *playletmod.Playlet, req *proto.PlayletUpdateReq, setDoc bson.M) { + if exist == nil || req == nil || setDoc == nil { + return + } + playName := strings.TrimSpace(exist.PlayName) + if req.PlayName != nil { + playName = strings.TrimSpace(*req.PlayName) + } + if playName == "" { + return + } + desc := exist.Desc + if req.Desc != nil { + desc = strings.TrimSpace(*req.Desc) + } + totalEpisode := exist.TotalEpisode + if req.TotalEpisode != nil { + totalEpisode = *req.TotalEpisode + } + description := playletmod.BuildPlayNameDescription(desc, playName, exist.UpdateStatus, totalEpisode) + if description == "" { + return + } + setDoc[playletmod.FN.Playlet.Description] = description + seo := exist.SeoInfo + if req.SeoInfo != nil { + if current, ok := setDoc["seoInfo"].(pubdef.SeoValue); ok { + seo = current + } else { + seo = normalizeSeoValue(*req.SeoInfo) + } + } + seo = normalizeSeoValue(seo) + seo.Description = description + setDoc["seoInfo"] = seo +} + +func playletToWebResp(item *playletmod.Playlet, themeNameMap map[int64]string, tagNameMap map[int64]string, categoryId *int64) *proto.PlayletListResp { + binds := playletmod.EffectiveCategoryBinds(item) + var filterCategoryId int64 + if categoryId != nil { + filterCategoryId = *categoryId + } + return &proto.PlayletListResp{ + Id: item.Id, + Status: item.Status, + MediaSource: item.MediaSource, + IsFeatured: item.IsFeatured, + IsOriginal: item.IsOriginal, + ShowOriginal: item.ShowOriginal, + MediaBusiness: item.MediaBusiness, + IsRecommend: item.IsRecommend, + ShowOnBanner: item.ShowOnBanner, + Banner: item.Banner, + MobileBanner: item.MobileBanner, + ReviewAt: item.ReviewAt, + OnlineAt: item.OnlineAt, + ThemeIds: item.ThemeIds, + ThemeNames: themeNamesFromMap(item.ThemeIds, themeNameMap), + CategoryIds: playletmod.CategoryIdsFromBinds(binds), + CategoryBinds: toProtoCategoryBinds(binds), + CategoryRank: playletmod.CategoryRankOf(binds, filterCategoryId), + Title: item.Title, + PlayName: item.PlayName, + Description: item.Description, + Slug: item.Slug, + Desc: item.Desc, + Anchor: item.Anchor, + Cover: item.Cover, + HorizontalCover: item.HorizontalCover, + CompressedHorizontalCover: item.CompressedHorizontalCover, + Preview: item.Preview, + TagIds: item.TagIds, + TagNames: tagNamesFromMap(item.TagIds, tagNameMap), + ContentType: item.ContentType, + OperateType: playletmod.NormalizeOperateType(item.OperateType), + PayType: item.PayType, + SingleCoins: item.SingleCoins, + TotalEpisode: item.TotalEpisode, + UpdateStatus: item.UpdateStatus, + TopRank: item.TopRank, + RecommendRank: item.RecommendRank, + CountComment: item.CountComment, + CountBuy: item.CountBuy, + CountBrowse: item.CountBrowse, + HeatValue: item.HeatValue, + 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, + CompressedCover: item.CompressedCover, + } +} + +func playletDisplayCountLike(item *playletmod.Playlet) int64 { + if item == nil { + return 0 + } + return item.CountLike + item.VirtualCountLike +} + +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) + if len(themeIds) == 0 { + return result, nil + } + themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{ + playletthememod.FN.PlayletTheme.Id: bson.M{"$in": themeIds}, + }) + if err != nil { + return nil, err + } + for _, theme := range themes { + if theme == nil { + continue + } + result[theme.Id] = theme.Name + } + return result, nil +} + +// themeNamesFromMap 按题材ID顺序返回题材名称 +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) + if len(tagIds) == 0 { + return result, nil + } + tags, err := playlettagmod.FindManyPlayletTag(ctx, bson.M{ + playlettagmod.FN.PlayletTag.Id: bson.M{"$in": tagIds}, + }) + if err != nil { + return nil, err + } + for _, tag := range tags { + if tag == nil { + continue + } + result[tag.Id] = tag.Name + } + return result, nil +} + +// tagNamesFromMap 按标签ID顺序返回标签名称 +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 +} + +// normalizePlayletTagIds 校验并去重标签ID +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 chapterToWebResp(item *playletchaptermod.PlayletChapter) *proto.PlayletChapterListResp { + 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, + Status: item.Status, + PayType: item.PayType, + SingleCoins: item.SingleCoins, + WatermarkStatus: item.WatermarkStatus, + MediaSourceId: item.MediaSourceId, + MediaTaskId: item.MediaTaskId, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func validatePlayletTagIds(ctx context.Context, tagIds []int64) *srvdef.SrvErr { + if len(tagIds) == 0 { + return nil + } + uniqueIds := make([]int64, 0, len(tagIds)) + seen := make(map[int64]struct{}, len(tagIds)) + for _, tagId := range tagIds { + if tagId <= 0 { + return srvdef.New(srvdef.ErrParamError, "标签ID无效", "") + } + if _, ok := seen[tagId]; ok { + continue + } + seen[tagId] = struct{}{} + uniqueIds = append(uniqueIds, tagId) + } + count, err := playlettagmod.CountPlayletTag(ctx, bson.M{ + playlettagmod.FN.PlayletTag.Id: bson.M{"$in": uniqueIds}, + }) + if err != nil { + return err + } + if count != int64(len(uniqueIds)) { + return srvdef.New(srvdef.ErrParamError, "标签不存在", "") + } + return nil +} + +// validatePlayletThemeIds 校验题材ID是否存在 +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 +} diff --git a/web/service/srv_playlet/sales.go b/web/service/srv_playlet/sales.go new file mode 100644 index 0000000..ab267c7 --- /dev/null +++ b/web/service/srv_playlet/sales.go @@ -0,0 +1,371 @@ +package srv_playlet + +import ( + "context" + "regexp" + "sort" + "strings" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/playletchapterunlockmod" + "videoweb/pub/models/playletcollectmod" + "videoweb/pub/models/playletlikemod" + "videoweb/pub/models/playletmod" + "videoweb/pub/pubdef" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const playletSalesLimit = 50 + +type playletSalesAggRow struct { + PlayletId int64 `bson:"_id"` + Count int64 `bson:"count"` + Coins int64 `bson:"coins"` +} + +type playletSalesRow struct { + Id int64 + UnlockCount int64 + UnlockCoins int64 + LikeCount int64 + CollectCount int64 +} + +// ListPlayletSales 按时间段汇总短剧金币解锁、点赞、收藏,最多返回 50 条。 +func ListPlayletSales(ctx context.Context, req *proto.PlayletSalesReq) (*proto.PlayletSalesResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + start, end, serr := resolvePlayletSalesTimeRange(req.StartTime, req.EndTime) + if serr != nil { + return nil, serr + } + resp := &proto.PlayletSalesResp{ + StartTime: start.In(utils.LocBJ).Format(pubdef.TimeISO), + EndTime: end.In(utils.LocBJ).Format(pubdef.TimeISO), + List: make([]*proto.PlayletSalesItem, 0), + } + filterIDs, playlets, serr := loadPlayletSalesFilter(ctx, req) + if serr != nil { + return nil, serr + } + if filterIDs != nil && len(filterIDs) == 0 { + return resp, nil + } + + unlocks, serr := aggregatePlayletUnlocks(ctx, start, end, filterIDs) + if serr != nil { + return nil, serr + } + likes, serr := aggregatePlayletMetric(ctx, start, end, filterIDs, playletlikemod.FN.PlayletLike.CreatedAt, playletlikemod.FN.PlayletLike.PlayletId, playletlikemod.AggregatePlayletLike) + if serr != nil { + return nil, serr + } + collects, serr := aggregatePlayletMetric(ctx, start, end, filterIDs, playletcollectmod.FN.PlayletCollect.CreatedAt, playletcollectmod.FN.PlayletCollect.PlayletId, playletcollectmod.AggregatePlayletCollect) + if serr != nil { + return nil, serr + } + + rows := mergePlayletSalesRows(filterIDs, unlocks, likes, collects) + sortPlayletSalesRows(rows, req.SortType) + rows = takePlayletSalesRows(rows, playletSalesLimit) + if len(rows) == 0 { + return resp, nil + } + if playlets == nil { + playlets, serr = loadPlayletsByIDs(ctx, playletSalesIDs(rows)) + if serr != nil { + return nil, serr + } + } + for _, row := range rows { + item := &proto.PlayletSalesItem{ + Id: row.Id, + UnlockCount: row.UnlockCount, + UnlockCoins: row.UnlockCoins, + LikeCount: row.LikeCount, + CollectCount: row.CollectCount, + } + if playlet := playlets[row.Id]; playlet != nil { + item.Title = playlet.Title + item.PlayName = playlet.PlayName + item.Cover = playlet.Cover + item.Slug = playlet.Slug + item.Status = playlet.Status + } + resp.List = append(resp.List, item) + } + return resp, nil +} + +func resolvePlayletSalesTimeRange(startRaw, endRaw string) (time.Time, time.Time, *srvdef.SrvErr) { + startRaw = strings.TrimSpace(startRaw) + endRaw = strings.TrimSpace(endRaw) + if startRaw == "" && endRaw == "" { + day := time.Now().In(utils.LocBJ).AddDate(0, 0, -1) + return utils.BeginOfTime(day), utils.EndOfTime(day), nil + } + if startRaw == "" || endRaw == "" { + return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "开始时间和结束时间需同时传入", "开始时间和结束时间需同时传入") + } + return parsePlayletSalesTimeRange(startRaw, endRaw) +} + +func parsePlayletSalesTimeRange(startRaw, endRaw string) (time.Time, time.Time, *srvdef.SrvErr) { + start, ok := parsePlayletSalesTime(startRaw, true) + if !ok { + return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "开始时间无效", "开始时间无效") + } + end, ok := parsePlayletSalesTime(endRaw, false) + if !ok { + return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "结束时间无效", "结束时间无效") + } + if end.Before(start) { + return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "结束时间不能早于开始时间", "结束时间不能早于开始时间") + } + return start, end, nil +} + +func parsePlayletSalesTime(raw string, isStart bool) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + layouts := []string{ + pubdef.TimeISO, + time.RFC3339, + time.RFC3339Nano, + "2006-01-02 15:04:05 -0700 MST", + "2006-01-02 15:04:05.999999999 -0700 MST", + } + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, raw, utils.LocBJ); err == nil { + return t, true + } + } + if t, err := time.ParseInLocation(pubdef.DateISO, raw, utils.LocBJ); err == nil { + if isStart { + return utils.BeginOfTime(t), true + } + return utils.EndOfTime(t), true + } + return time.Time{}, false +} + +func loadPlayletSalesFilter(ctx context.Context, req *proto.PlayletSalesReq) ([]int64, map[int64]*playletmod.Playlet, *srvdef.SrvErr) { + id := int64(0) + if req.Id != nil { + id = *req.Id + } + title := strings.TrimSpace(req.Title) + if id <= 0 && title == "" { + return nil, nil, nil + } + filter := bson.M{} + if id > 0 { + filter[playletmod.FN.Playlet.Id] = id + } + if title != "" { + pattern := regexp.QuoteMeta(title) + filter["$or"] = []bson.M{ + {playletmod.FN.Playlet.Title: bson.M{"$regex": pattern}}, + {playletmod.FN.Playlet.PlayName: bson.M{"$regex": pattern}}, + } + } + list, serr := playletmod.FindManyPlaylet(ctx, filter, options.Find().SetProjection(playletSalesProjection())) + if serr != nil { + return nil, nil, serr + } + ids := make([]int64, 0, len(list)) + playlets := make(map[int64]*playletmod.Playlet, len(list)) + for _, item := range list { + if item == nil || item.Id <= 0 { + continue + } + ids = append(ids, item.Id) + playlets[item.Id] = item + } + return ids, playlets, nil +} + +func playletSalesProjection() bson.D { + return bson.D{ + {Key: playletmod.FN.Playlet.Id, Value: 1}, + {Key: playletmod.FN.Playlet.Title, Value: 1}, + {Key: playletmod.FN.Playlet.PlayName, Value: 1}, + {Key: playletmod.FN.Playlet.Cover, Value: 1}, + {Key: playletmod.FN.Playlet.Slug, Value: 1}, + {Key: playletmod.FN.Playlet.Status, Value: 1}, + } +} + +func loadPlayletsByIDs(ctx context.Context, ids []int64) (map[int64]*playletmod.Playlet, *srvdef.SrvErr) { + out := make(map[int64]*playletmod.Playlet, len(ids)) + if len(ids) == 0 { + return out, nil + } + list, serr := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": ids}, + }, options.Find().SetProjection(playletSalesProjection())) + if serr != nil { + return nil, serr + } + for _, item := range list { + if item == nil || item.Id <= 0 { + continue + } + out[item.Id] = item + } + return out, nil +} + +func aggregatePlayletUnlocks(ctx context.Context, start, end time.Time, playletIDs []int64) (map[int64]playletSalesAggRow, *srvdef.SrvErr) { + match := bson.M{ + playletchapterunlockmod.FN.PlayletChapterUnlock.CreatedAt: bson.M{ + "$gte": start, + "$lte": end, + }, + } + if len(playletIDs) > 0 { + match[playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId] = bson.M{"$in": playletIDs} + } + pipeline := []bson.M{ + {"$match": match}, + {"$group": bson.M{ + "_id": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.PlayletId, + "count": bson.M{"$sum": 1}, + "coins": bson.M{"$sum": "$" + playletchapterunlockmod.FN.PlayletChapterUnlock.Coins}, + }}, + } + rows := make([]playletSalesAggRow, 0) + if serr := playletchapterunlockmod.AggregatePlayletChapterUnlock(ctx, &rows, pipeline); serr != nil { + return nil, serr + } + return playletSalesAggMap(rows), nil +} + +func aggregatePlayletMetric(ctx context.Context, start, end time.Time, playletIDs []int64, createdAtField, playletIDField string, agg func(context.Context, interface{}, []bson.M) *srvdef.SrvErr) (map[int64]playletSalesAggRow, *srvdef.SrvErr) { + match := bson.M{ + createdAtField: bson.M{ + "$gte": start, + "$lte": end, + }, + } + if len(playletIDs) > 0 { + match[playletIDField] = bson.M{"$in": playletIDs} + } + pipeline := []bson.M{ + {"$match": match}, + {"$group": bson.M{ + "_id": "$" + playletIDField, + "count": bson.M{"$sum": 1}, + }}, + } + rows := make([]playletSalesAggRow, 0) + if serr := agg(ctx, &rows, pipeline); serr != nil { + return nil, serr + } + return playletSalesAggMap(rows), nil +} + +func playletSalesAggMap(rows []playletSalesAggRow) map[int64]playletSalesAggRow { + out := make(map[int64]playletSalesAggRow, len(rows)) + for _, row := range rows { + if row.PlayletId <= 0 { + continue + } + out[row.PlayletId] = row + } + return out +} + +func mergePlayletSalesRows(filterIDs []int64, unlocks, likes, collects map[int64]playletSalesAggRow) []playletSalesRow { + seen := make(map[int64]struct{}) + if filterIDs != nil { + for _, id := range filterIDs { + if id > 0 { + seen[id] = struct{}{} + } + } + } else { + for id := range unlocks { + seen[id] = struct{}{} + } + for id := range likes { + seen[id] = struct{}{} + } + for id := range collects { + seen[id] = struct{}{} + } + } + rows := make([]playletSalesRow, 0, len(seen)) + for id := range seen { + row := playletSalesRow{Id: id} + if item, ok := unlocks[id]; ok { + row.UnlockCount = item.Count + row.UnlockCoins = item.Coins + } + if item, ok := likes[id]; ok { + row.LikeCount = item.Count + } + if item, ok := collects[id]; ok { + row.CollectCount = item.Count + } + rows = append(rows, row) + } + return rows +} + +func sortPlayletSalesRows(rows []playletSalesRow, sortType proto.PlayletSalesSortType) { + sort.SliceStable(rows, func(i, j int) bool { + left, right := rows[i], rows[j] + switch sortType { + case proto.PlayletSalesSortLike: + if left.LikeCount != right.LikeCount { + return left.LikeCount > right.LikeCount + } + case proto.PlayletSalesSortCollect: + if left.CollectCount != right.CollectCount { + return left.CollectCount > right.CollectCount + } + default: + if left.UnlockCount != right.UnlockCount { + return left.UnlockCount > right.UnlockCount + } + if left.UnlockCoins != right.UnlockCoins { + return left.UnlockCoins > right.UnlockCoins + } + } + if left.UnlockCount != right.UnlockCount { + return left.UnlockCount > right.UnlockCount + } + if left.LikeCount != right.LikeCount { + return left.LikeCount > right.LikeCount + } + if left.CollectCount != right.CollectCount { + return left.CollectCount > right.CollectCount + } + return left.Id > right.Id + }) +} + +func takePlayletSalesRows(rows []playletSalesRow, limit int) []playletSalesRow { + if limit <= 0 || len(rows) <= limit { + return rows + } + return rows[:limit] +} + +func playletSalesIDs(rows []playletSalesRow) []int64 { + ids := make([]int64, 0, len(rows)) + for _, row := range rows { + if row.Id > 0 { + ids = append(ids, row.Id) + } + } + return ids +} diff --git a/web/service/srv_playlet/seo_notify.go b/web/service/srv_playlet/seo_notify.go new file mode 100644 index 0000000..766d5f3 --- /dev/null +++ b/web/service/srv_playlet/seo_notify.go @@ -0,0 +1,68 @@ +package srv_playlet + +import ( + "context" + "time" + "videoweb/pub/common/log" + "videoweb/pub/models/playletmod" + "videoweb/pub/seo/sitemapdirty" + "videoweb/web/service/srv_seopush" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func markSitemapDirty(ctx context.Context) { + sitemapdirty.Mark(ctx) +} + +// notifyPlayletSeoAfterWrite 短剧写入后:刷新 sitemap 标记。 +// 搜索引擎只在新上架(含新增即上架)或从已上架变为下架时推送,普通改内容不推。 +// prevStatus 为变更前状态;nil 表示新增。 +func notifyPlayletSeoAfterWrite(ctx context.Context, playletId int64, prevStatus *playletmod.Status) { + markSitemapDirty(ctx) + item, 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.Status, Value: 1}, + {Key: playletmod.FN.Playlet.OnlineAt, Value: 1}, + })) + if err != nil || item == nil { + if err != nil { + log.Warn("seopush: load playlet after write failed", log.Any("playletId", playletId), log.E(err)) + } + return + } + wasUp := prevStatus != nil && *prevStatus == playletmod.StatusUp + if item.Status == playletmod.StatusUp { + if !wasUp { + srv_seopush.EnqueuePlayletUpdated(ctx, item.Id, item.Status, item.OnlineAt) + } + return + } + if wasUp { + srv_seopush.EnqueuePlayletDeleted(ctx, item.Id) + } +} + +// notifyPlayletSeoAfterStatus 批量上下架后的 SEO 通知 +func notifyPlayletSeoAfterStatus(ctx context.Context, ids []int64, status playletmod.Status, onlineAt time.Time) { + markSitemapDirty(ctx) + if status == playletmod.StatusUp { + for _, id := range ids { + srv_seopush.EnqueuePlayletUpdated(ctx, id, status, onlineAt) + } + return + } + for _, id := range ids { + srv_seopush.EnqueuePlayletDeleted(ctx, id) + } +} + +// notifyPlayletSeoAfterDelete 物理删除后刷新 sitemap 标记,并对原上架短剧推送删除通知。 +func notifyPlayletSeoAfterDelete(ctx context.Context, onlineIds []int64) { + markSitemapDirty(ctx) + for _, id := range onlineIds { + srv_seopush.EnqueuePlayletDeleted(ctx, id) + } +} diff --git a/web/service/srv_playlet/slug.go b/web/service/srv_playlet/slug.go new file mode 100644 index 0000000..155aa19 --- /dev/null +++ b/web/service/srv_playlet/slug.go @@ -0,0 +1,416 @@ +package srv_playlet + +import ( + "context" + "strings" + "time" + "videoweb/pub/common/playletcache" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/pub/models/playletthememod" + "videoweb/web/api/proto" + "videoweb/web/service/srv_homeconfig" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + syncSlugDefaultSize = int64(500) + syncSlugMaxSize = int64(2000) +) + +func emptySlugFilter() bson.M { + return bson.M{ + "$or": []bson.M{ + {"slug": ""}, + {"slug": bson.M{"$exists": false}}, + }, + } +} + +func ensureThemeSlug(ctx context.Context, ac *playletthememod.PlayletTheme, excludeId int64) *srvdef.SrvErr { + ac.Slug = utils.SanitizeSlug(ac.Slug) + if ac.Slug != "" { + if utils.IsSlugAllDigits(ac.Slug) { + return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字") + } + filter := bson.M{playletthememod.FN.PlayletTheme.Slug: ac.Slug} + if excludeId > 0 { + filter[playletthememod.FN.PlayletTheme.Id] = bson.M{"$ne": excludeId} + } + exist, _ := playletthememod.FindOnePlayletTheme(ctx, filter) + if exist != nil { + return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在") + } + return nil + } + source := utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Name) + ac.Slug = utils.MakeUniqueSlug(source, func(slug string) bool { + filter := bson.M{playletthememod.FN.PlayletTheme.Slug: slug} + if excludeId > 0 { + filter[playletthememod.FN.PlayletTheme.Id] = bson.M{"$ne": excludeId} + } + exist, _ := playletthememod.FindOnePlayletTheme(ctx, filter) + return exist != nil + }) + return nil +} + +func prepareThemeSlugOnUpdate(ctx context.Context, id int64, info *playletthememod.PlayletTheme, req *proto.PlayletThemeUpdateReq, setDoc bson.M) *srvdef.SrvErr { + slug := info.Slug + if req.Slug != nil { + slug = utils.SanitizeSlug(*req.Slug) + } + if slug != "" { + if req.Slug != nil { + if utils.IsSlugAllDigits(slug) { + return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字") + } + exist, _ := playletthememod.FindOnePlayletTheme(ctx, bson.M{ + playletthememod.FN.PlayletTheme.Slug: slug, + playletthememod.FN.PlayletTheme.Id: bson.M{"$ne": id}, + }) + if exist != nil { + return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在") + } + setDoc[playletthememod.FN.PlayletTheme.Slug] = slug + } + return nil + } + name := info.Name + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + } + keywords := info.SeoInfo.Keyworld + if req.SeoInfo != nil { + keywords = normalizeSeoValue(*req.SeoInfo).Keyworld + } + source := utils.SlugSourceFromKeywordsOrTitle(keywords, name) + newSlug := utils.MakeUniqueSlug(source, func(s string) bool { + exist, _ := playletthememod.FindOnePlayletTheme(ctx, bson.M{ + playletthememod.FN.PlayletTheme.Slug: s, + playletthememod.FN.PlayletTheme.Id: bson.M{"$ne": id}, + }) + return exist != nil + }) + setDoc[playletthememod.FN.PlayletTheme.Slug] = newSlug + return nil +} + +func ensureTagSlug(ctx context.Context, ac *playlettagmod.PlayletTag, excludeId int64) *srvdef.SrvErr { + ac.Slug = utils.SanitizeSlug(ac.Slug) + if ac.Slug != "" { + if utils.IsSlugAllDigits(ac.Slug) { + return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字") + } + filter := bson.M{playlettagmod.FN.PlayletTag.Slug: ac.Slug} + if excludeId > 0 { + filter[playlettagmod.FN.PlayletTag.Id] = bson.M{"$ne": excludeId} + } + exist, _ := playlettagmod.FindOnePlayletTag(ctx, filter) + if exist != nil { + return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在") + } + return nil + } + source := utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Name) + ac.Slug = utils.MakeUniqueSlug(source, func(slug string) bool { + filter := bson.M{playlettagmod.FN.PlayletTag.Slug: slug} + if excludeId > 0 { + filter[playlettagmod.FN.PlayletTag.Id] = bson.M{"$ne": excludeId} + } + exist, _ := playlettagmod.FindOnePlayletTag(ctx, filter) + return exist != nil + }) + return nil +} + +func prepareTagSlugOnUpdate(ctx context.Context, id int64, info *playlettagmod.PlayletTag, req *proto.PlayletTagUpdateReq, setDoc bson.M) *srvdef.SrvErr { + slug := info.Slug + if req.Slug != nil { + slug = utils.SanitizeSlug(*req.Slug) + } + if slug != "" { + if req.Slug != nil { + if utils.IsSlugAllDigits(slug) { + return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字") + } + exist, _ := playlettagmod.FindOnePlayletTag(ctx, bson.M{ + playlettagmod.FN.PlayletTag.Slug: slug, + playlettagmod.FN.PlayletTag.Id: bson.M{"$ne": id}, + }) + if exist != nil { + return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在") + } + setDoc[playlettagmod.FN.PlayletTag.Slug] = slug + } + return nil + } + name := info.Name + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + } + keywords := info.SeoInfo.Keyworld + if req.SeoInfo != nil { + keywords = normalizeSeoValue(*req.SeoInfo).Keyworld + } + source := utils.SlugSourceFromKeywordsOrTitle(keywords, name) + newSlug := utils.MakeUniqueSlug(source, func(s string) bool { + exist, _ := playlettagmod.FindOnePlayletTag(ctx, bson.M{ + playlettagmod.FN.PlayletTag.Slug: s, + playlettagmod.FN.PlayletTag.Id: bson.M{"$ne": id}, + }) + return exist != nil + }) + setDoc[playlettagmod.FN.PlayletTag.Slug] = newSlug + return nil +} + +func ensurePlayletSlug(ctx context.Context, ac *playletmod.Playlet, excludeId int64) *srvdef.SrvErr { + ac.Slug = utils.SanitizeSlug(ac.Slug) + if ac.Slug != "" { + if utils.IsSlugAllDigits(ac.Slug) { + return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字") + } + filter := bson.M{playletmod.FN.Playlet.Slug: ac.Slug} + if excludeId > 0 { + filter[playletmod.FN.Playlet.Id] = bson.M{"$ne": excludeId} + } + exist, _ := playletmod.FindOnePlaylet(ctx, filter) + if exist != nil { + return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在") + } + return nil + } + source := utils.SlugSourceFromKeywordsOrTitle(ac.SeoInfo.Keyworld, ac.Title) + ac.Slug = utils.MakeUniqueSlug(source, func(slug string) bool { + filter := bson.M{playletmod.FN.Playlet.Slug: slug} + if excludeId > 0 { + filter[playletmod.FN.Playlet.Id] = bson.M{"$ne": excludeId} + } + exist, _ := playletmod.FindOnePlaylet(ctx, filter) + return exist != nil + }) + return nil +} + +func preparePlayletSlugOnUpdate(ctx context.Context, id int64, info *playletmod.Playlet, req *proto.PlayletUpdateReq, setDoc bson.M) *srvdef.SrvErr { + slug := info.Slug + if req.Slug != nil { + slug = utils.SanitizeSlug(*req.Slug) + } + if slug != "" { + if req.Slug != nil { + if utils.IsSlugAllDigits(slug) { + return srvdef.New(srvdef.ErrParamError, "Slug不能全是数字", "Slug不能全是数字") + } + exist, _ := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Slug: slug, + playletmod.FN.Playlet.Id: bson.M{"$ne": id}, + }) + if exist != nil { + return srvdef.New(srvdef.ErrDbInputExist, "Slug已存在", "Slug已存在") + } + setDoc[playletmod.FN.Playlet.Slug] = slug + } + return nil + } + title := info.Title + if req.Title != nil { + title = strings.TrimSpace(*req.Title) + } + keywords := info.SeoInfo.Keyworld + if req.SeoInfo != nil { + keywords = normalizeSeoValue(*req.SeoInfo).Keyworld + } + source := utils.SlugSourceFromKeywordsOrTitle(keywords, title) + newSlug := utils.MakeUniqueSlug(source, func(s string) bool { + exist, _ := playletmod.FindOnePlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Slug: s, + playletmod.FN.Playlet.Id: bson.M{"$ne": id}, + }) + return exist != nil + }) + setDoc[playletmod.FN.Playlet.Slug] = newSlug + return nil +} + +// SyncEmptySlugs 为题材、标签、短剧中空 slug 自动生成。可重复调用。 +func SyncEmptySlugs(ctx context.Context, req *proto.PlayletSlugSyncReq) (*proto.PlayletSlugSyncResp, *srvdef.SrvErr) { + maxSize := syncSlugDefaultSize + if req != nil && req.MaxSize > 0 { + maxSize = req.MaxSize + } + if maxSize > syncSlugMaxSize { + maxSize = syncSlugMaxSize + } + resp := &proto.PlayletSlugSyncResp{} + remain := maxSize + + themeUpdated, themeMore, err := syncThemeEmptySlugs(ctx, remain) + if err != nil { + return nil, err + } + resp.ThemeUpdated = themeUpdated + remain -= themeUpdated + if remain <= 0 { + resp.HasMore = themeMore || hasEmptySlug(ctx, "tag") || hasEmptySlug(ctx, "playlet") || srv_homeconfig.HasEmptySlug(ctx) + playletcache.ClearHomeCache() + return resp, nil + } + + tagUpdated, tagMore, err := syncTagEmptySlugs(ctx, remain) + if err != nil { + return nil, err + } + resp.TagUpdated = tagUpdated + remain -= tagUpdated + if remain <= 0 { + resp.HasMore = tagMore || hasEmptySlug(ctx, "playlet") || srv_homeconfig.HasEmptySlug(ctx) + playletcache.ClearHomeCache() + return resp, nil + } + + playletUpdated, playletMore, err := syncPlayletEmptySlugs(ctx, remain) + if err != nil { + return nil, err + } + resp.PlayletUpdated = playletUpdated + remain -= playletUpdated + if remain <= 0 { + resp.HasMore = playletMore || srv_homeconfig.HasEmptySlug(ctx) + playletcache.ClearHomeCache() + return resp, nil + } + + homeUpdated, homeMore, err := srv_homeconfig.SyncEmptySlugs(ctx, remain) + if err != nil { + return nil, err + } + resp.HomeConfigUpdated = homeUpdated + resp.HasMore = playletMore || homeMore + playletcache.ClearHomeCache() + return resp, nil +} + +func hasEmptySlug(ctx context.Context, kind string) bool { + filter := emptySlugFilter() + switch kind { + case "theme": + count, _ := playletthememod.CountPlayletTheme(ctx, filter) + return count > 0 + case "tag": + count, _ := playlettagmod.CountPlayletTag(ctx, filter) + return count > 0 + case "playlet": + count, _ := playletmod.CountPlaylet(ctx, filter) + return count > 0 + default: + return false + } +} + +func syncThemeEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := playletthememod.FindManyPlayletTheme(ctx, emptySlugFilter(), options.Find(). + SetSort(bson.D{{Key: playletthememod.FN.PlayletTheme.Id, Value: 1}}). + SetLimit(maxSize+1)) + if err != nil { + return 0, false, err + } + hasMore := int64(len(list)) > maxSize + if hasMore { + list = list[:maxSize] + } + var updated int64 + now := time.Now() + for _, item := range list { + if item == nil { + continue + } + if serr := ensureThemeSlug(ctx, item, item.Id); serr != nil { + return updated, hasMore, serr + } + if err := playletthememod.UpdateOnePlayletTheme(ctx, + bson.M{playletthememod.FN.PlayletTheme.Id: item.Id}, + bson.M{"$set": bson.M{ + playletthememod.FN.PlayletTheme.Slug: item.Slug, + playletthememod.FN.PlayletTheme.UpdatedAt: now, + }}, + ); err != nil { + return updated, hasMore, err + } + updated++ + } + return updated, hasMore, nil +} + +func syncTagEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := playlettagmod.FindManyPlayletTag(ctx, emptySlugFilter(), options.Find(). + SetSort(bson.D{{Key: playlettagmod.FN.PlayletTag.Id, Value: 1}}). + SetLimit(maxSize+1)) + if err != nil { + return 0, false, err + } + hasMore := int64(len(list)) > maxSize + if hasMore { + list = list[:maxSize] + } + var updated int64 + now := time.Now() + for _, item := range list { + if item == nil { + continue + } + if serr := ensureTagSlug(ctx, item, item.Id); serr != nil { + return updated, hasMore, serr + } + if err := playlettagmod.UpdateOnePlayletTag(ctx, + bson.M{playlettagmod.FN.PlayletTag.Id: item.Id}, + bson.M{"$set": bson.M{ + playlettagmod.FN.PlayletTag.Slug: item.Slug, + playlettagmod.FN.PlayletTag.UpdatedAt: now, + }}, + ); err != nil { + return updated, hasMore, err + } + updated++ + } + return updated, hasMore, nil +} + +func syncPlayletEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := playletmod.FindManyPlaylet(ctx, emptySlugFilter(), options.Find(). + SetSort(bson.D{{Key: playletmod.FN.Playlet.Id, Value: 1}}). + SetLimit(maxSize+1)) + if err != nil { + return 0, false, err + } + hasMore := int64(len(list)) > maxSize + if hasMore { + list = list[:maxSize] + } + var updated int64 + now := time.Now() + for _, item := range list { + if item == nil { + continue + } + if serr := ensurePlayletSlug(ctx, item, item.Id); serr != nil { + return updated, hasMore, serr + } + if err := playletmod.UpdateOnePlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: item.Id}, + bson.M{"$set": bson.M{ + playletmod.FN.Playlet.Slug: item.Slug, + playletmod.FN.Playlet.UpdatedAt: now, + }}, + ); err != nil { + return updated, hasMore, err + } + updated++ + } + return updated, hasMore, nil +} diff --git a/web/service/srv_playlet/tag.go b/web/service/srv_playlet/tag.go new file mode 100644 index 0000000..9202e06 --- /dev/null +++ b/web/service/srv_playlet/tag.go @@ -0,0 +1,170 @@ +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)} + } + if slug := strings.TrimSpace(req.Slug); slug != "" { + filter[playlettagmod.FN.PlayletTag.Slug] = slug + } + 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, + Slug: item.Slug, + 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), + Slug: strings.TrimSpace(req.Slug), + Status: req.Status, + Rank: req.Rank, + SeoInfo: normalizeSeoValue(req.SeoInfo), + } + if err := ensureTagSlug(ctx, doc, 0); err != nil { + return nil, err + } + if err := playlettagmod.InsertOnePlayletTag(ctx, doc); err != nil { + return nil, err + } + clearAppPlayletHomeCache() + markSitemapDirty(ctx) + 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无效", "") + } + info, err := playlettagmod.FindOnePlayletTag(ctx, bson.M{playlettagmod.FN.PlayletTag.Id: req.Id}) + if err != nil { + return err + } + 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 err := prepareTagSlugOnUpdate(ctx, req.Id, info, req, setDoc); err != nil { + return err + } + 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() + markSitemapDirty(ctx) + 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() + markSitemapDirty(ctx) + return nil +} diff --git a/web/service/srv_playlet/theme.go b/web/service/srv_playlet/theme.go new file mode 100644 index 0000000..1fd7765 --- /dev/null +++ b/web/service/srv_playlet/theme.go @@ -0,0 +1,207 @@ +package srv_playlet + +import ( + "context" + "regexp" + "strings" + "time" + "videoweb/pub/common/response" + "videoweb/pub/common/srvdef" + "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" +) + +// 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 req.IsRecommend != nil { + filter[playletthememod.FN.PlayletTheme.IsRecommend] = *req.IsRecommend + } + if name := strings.TrimSpace(req.Name); name != "" { + filter[playletthememod.FN.PlayletTheme.Name] = bson.M{"$regex": regexp.QuoteMeta(name)} + } + if slug := strings.TrimSpace(req.Slug); slug != "" { + filter[playletthememod.FN.PlayletTheme.Slug] = slug + } + 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(). + 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, + IsRecommend: item.IsRecommend, + 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: strings.TrimSpace(req.Slug), + Icon: req.Icon, + Status: req.Status, + IsRecommend: req.IsRecommend, + Rank: req.Rank, + SeoInfo: normalizeSeoValue(req.SeoInfo), + } + if err := ensureThemeSlug(ctx, doc, 0); err != nil { + return nil, err + } + if err := playletthememod.InsertOnePlayletTheme(ctx, doc); err != nil { + return nil, err + } + clearAppPlayletHomeCache() + markSitemapDirty(ctx) + 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无效", "") + } + info, 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] = *req.Icon + } + if req.Status != nil { + setDoc[playletthememod.FN.PlayletTheme.Status] = *req.Status + } + if req.IsRecommend != nil { + setDoc[playletthememod.FN.PlayletTheme.IsRecommend] = *req.IsRecommend + } + if req.Rank != nil { + setDoc[playletthememod.FN.PlayletTheme.Rank] = *req.Rank + } + if req.SeoInfo != nil { + setDoc["seoInfo"] = normalizeSeoValue(*req.SeoInfo) + } + if err := prepareThemeSlugOnUpdate(ctx, req.Id, info, req, setDoc); err != nil { + return err + } + 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() + markSitemapDirty(ctx) + return nil +} + +// SetPlayletThemeRecommend 单/批量设置题材是否推荐 +func SetPlayletThemeRecommend(ctx context.Context, req *proto.PlayletThemeRecommendSetReq) *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 err := playletthememod.UpdateManyPlayletTheme(ctx, + bson.M{playletthememod.FN.PlayletTheme.Id: bson.M{"$in": ids}}, + bson.M{"$set": bson.M{ + playletthememod.FN.PlayletTheme.IsRecommend: req.IsRecommend, + playletthememod.FN.PlayletTheme.UpdatedAt: time.Now(), + }}, + ); 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() + markSitemapDirty(ctx) + return nil +} diff --git a/web/service/srv_playlet/topic_group.go b/web/service/srv_playlet/topic_group.go new file mode 100644 index 0000000..e694e2c --- /dev/null +++ b/web/service/srv_playlet/topic_group.go @@ -0,0 +1,125 @@ +package srv_playlet + +import ( + "context" + "strings" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletthememod" + "videoweb/pub/models/playlettopicgroupmod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// ListPlayletTopicGroups 查询后台专题区块 +func ListPlayletTopicGroups(ctx context.Context, req *proto.PlayletTopicGroupListReq) (*proto.PlayletTopicGroupListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "", "") + } + pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) + filter := bson.M{} + if req.Status != nil { + filter["status"] = *req.Status + } + totalCount, err := playlettopicgroupmod.Count(ctx, filter) + if err != nil { + return nil, err + } + resp := &proto.PlayletTopicGroupListResp{ + TotalCount: totalCount, PageNum: int(pageNum), PageSize: int(pageSize), + List: make(playlettopicgroupmod.PlayletTopicGroupList, 0), + } + if totalCount == 0 || (pageNum-1)*pageSize >= totalCount { + return resp, nil + } + list, err := playlettopicgroupmod.FindMany(ctx, filter, options.Find().SetSkip((pageNum-1)*pageSize).SetLimit(pageSize)) + if err != nil { + return nil, err + } + resp.List = list + return resp, nil +} + +// AddPlayletTopicGroup 新增专题区块 +func AddPlayletTopicGroup(ctx context.Context, req *proto.PlayletTopicGroupAddReq) (*proto.PlayletTopicGroupAddResp, *srvdef.SrvErr) { + if req == nil || strings.TrimSpace(req.Name) == "" || req.Status == nil { + return nil, srvdef.New(srvdef.ErrParamError, "专题区块参数无效", "") + } + ids, err := validateTopicGroupIDs(ctx, req.TopicIds) + if err != nil { + return nil, err + } + doc := &playlettopicgroupmod.PlayletTopicGroup{ + Name: strings.TrimSpace(req.Name), Desc: strings.TrimSpace(req.Desc), + TopicIds: ids, Rank: req.Rank, Status: *req.Status, + } + if err := playlettopicgroupmod.Insert(ctx, doc); err != nil { + return nil, err + } + return &proto.PlayletTopicGroupAddResp{Id: doc.Id}, nil +} + +// UpdatePlayletTopicGroup 更新专题区块 +func UpdatePlayletTopicGroup(ctx context.Context, req *proto.PlayletTopicGroupUpdateReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "专题区块ID无效", "") + } + set := bson.M{} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return srvdef.New(srvdef.ErrParamError, "专题区块名称不能为空", "") + } + set["name"] = name + } + if req.Desc != nil { + set["desc"] = strings.TrimSpace(*req.Desc) + } + if req.TopicIds != nil { + ids, err := validateTopicGroupIDs(ctx, *req.TopicIds) + if err != nil { + return err + } + set["topicIds"] = ids + } + if req.Rank != nil { + set["rank"] = *req.Rank + } + if req.Status != nil { + set["status"] = *req.Status + } + if len(set) == 0 { + return srvdef.New(srvdef.ErrParamError, "没有可更新字段", "") + } + return playlettopicgroupmod.Update(ctx, req.Id, set) +} + +// DeletePlayletTopicGroup 删除专题区块 +func DeletePlayletTopicGroup(ctx context.Context, id int64) *srvdef.SrvErr { + if id <= 0 { + return srvdef.New(srvdef.ErrParamError, "专题区块ID无效", "") + } + return playlettopicgroupmod.Delete(ctx, id) +} + +func validateTopicGroupIDs(ctx context.Context, ids []int64) ([]int64, *srvdef.SrvErr) { + if len(ids) == 0 { + return []int64{}, nil + } + seen := make(map[int64]bool, len(ids)) + for _, id := range ids { + if id <= 0 || seen[id] { + return nil, srvdef.New(srvdef.ErrParamError, "专题ID无效或重复", "") + } + seen[id] = true + } + count, err := playletthememod.CountPlayletTheme(ctx, bson.M{playletthememod.FN.PlayletTheme.Id: bson.M{"$in": ids}}) + if err != nil { + return nil, err + } + if count != int64(len(ids)) { + return nil, srvdef.New(srvdef.ErrParamError, "专题不存在", "") + } + return ids, nil +} diff --git a/web/service/srv_recharge/callback.go b/web/service/srv_recharge/callback.go new file mode 100644 index 0000000..b729603 --- /dev/null +++ b/web/service/srv_recharge/callback.go @@ -0,0 +1,307 @@ +package srv_recharge + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/dataReport" + "videoweb/pub/models/cointransmod" + "videoweb/pub/models/eventmod" + "videoweb/pub/models/rechargepackagemod" + "videoweb/pub/models/usermod" + "videoweb/pub/models/vipordermod" + "videoweb/pub/payment" + "videoweb/pub/pubdef" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/bson" +) + +// BillCallbackParam 支付成功后的入账参数。签名校验由新支付中心回调完成。 +type BillCallbackParam struct { + TradeNo string `json:"transNo" form:"transNo"` + Oid string `json:"oid" form:"oid"` + RealAmount int64 `json:"payMoney" form:"payMoney"` + PaymentAt time.Time `json:"paymentAt" form:"paymentAt"` + SuccessAt time.Time `json:"successAt" form:"successAt"` +} + +// OnPayCenterCallback 统一支付回调入口 +// 通过 vip_order 表查询订单,再按 OrderType 分支处理。 +func OnPayCenterCallback(ctx *gin.Context, param *BillCallbackParam) *srvdef.SrvErr { + log.Info("srv_recharge:OnPayCenterCallback", + log.Any("tradeNo", param.TradeNo), + log.Any("oid", param.Oid), + log.Any("realAmount", param.RealAmount), + ) + if param.TradeNo == "" { + return srvdef.New(srvdef.ErrParamError, "tradeNo 不能为空", "") + } + + order, serr := vipordermod.FindOneVipOrder(ctx, bson.M{ + vipordermod.FN.VipOrder.TradeNo: param.TradeNo, + }) + if serr != nil { + log.Error("OnPayCenterCallback FindOneVipOrder failed", + log.Any("tradeNo", param.TradeNo), log.E(serr)) + return serr + } + + if order.Status == pubdef.VipOrderSuccess { + log.Info("OnPayCenterCallback already success", log.Any("tradeNo", param.TradeNo)) + return nil + } + + if order.Oid != "" && order.Oid != param.Oid { + return srvdef.New(srvdef.ErrParamError, + fmt.Sprintf("oid 不匹配: %s vs %s", order.Oid, param.Oid), "") + } + + paymentAt := resolvePaymentAt(param) + + if serr = vipordermod.UpdateOrderSuccess(ctx, param.TradeNo, param.Oid, param.RealAmount, paymentAt); serr != nil { + log.Error("OnPayCenterCallback UpdateOrderSuccess failed", + log.Any("tradeNo", param.TradeNo), log.E(serr)) + return serr + } + + orderSnap := *order + realAmount := param.RealAmount + oid := param.Oid + + switch order.OrderType { + case vipordermod.OrderTypeCoin: + go handleCoinOrderSuccess(orderSnap, oid, realAmount) + default: + go handleVipOrderSuccess(orderSnap, oid, realAmount) + } + + return nil +} + +func handleVipOrderSuccess(order vipordermod.VipOrder, oid string, realAmount int64) { + ctx := context.Background() + + pkg, err := findVipPackage(ctx, order.CardId) + if err != nil { + log.Error("handleVipOrderSuccess find package failed", + log.Any("userId", order.UserId), log.Any("packageId", order.CardId), log.E(err)) + return + } + + expire, err := payment.ActivateVip(ctx, order.UserId, pkg) + if err != nil { + log.Error("handleVipOrderSuccess ActivateVip failed, retrying", + log.Any("userId", order.UserId), log.Any("packageId", order.CardId), log.E(err)) + time.Sleep(3 * time.Second) + expire, err = payment.ActivateVip(ctx, order.UserId, pkg) + if err != nil { + log.Error("handleVipOrderSuccess ActivateVip retry failed", + log.Any("userId", order.UserId), log.Any("packageId", order.CardId), log.E(err)) + } + } + log.Info("handleVipOrderSuccess done", log.Any("userId", order.UserId), log.Any("expire", expire)) + + if pkg.GiftCoin > 0 { + _ = payment.WriteCoinTransByUser(ctx, payment.WriteCoinTransOpt{ + UserId: order.UserId, + FlowDir: cointransmod.FlowIn, + TranType: cointransmod.TranTypeVipGiftCoin, + Amount: pkg.GiftCoin, + OrderNo: order.TradeNo, + Remark: payment.CoinTransRemark(cointransmod.TranTypeVipGiftCoin, pkg.Name), + }) + } + + reportVipOrderPaid(ctx, &order, oid, realAmount, expire) +} + +func handleCoinOrderSuccess(order vipordermod.VipOrder, oid string, realAmount int64) { + ctx := context.Background() + + if err := addUserCoins(ctx, order.UserId, order.Coin); err != nil { + log.Error("handleCoinOrderSuccess addUserCoins failed, retrying", + log.Any("userId", order.UserId), log.Any("coin", order.Coin), log.E(err)) + time.Sleep(3 * time.Second) + if err = addUserCoins(ctx, order.UserId, order.Coin); err != nil { + log.Error("handleCoinOrderSuccess addUserCoins retry failed", + log.Any("userId", order.UserId), log.Any("coin", order.Coin), log.E(err)) + } + } + log.Info("handleCoinOrderSuccess done", log.Any("userId", order.UserId), log.Any("coin", order.Coin)) + + writeCoinOrderTrans(ctx, order) + + reportCoinOrderPaid(ctx, &order, oid, realAmount) +} + +func writeCoinOrderTrans(ctx context.Context, order vipordermod.VipOrder) { + baseCoin, giftCoin := resolveCoinOrderSplit(ctx, order) + balanceAfter, ok := coinOrderBalanceAfter(ctx, order.UserId) + if !ok || giftCoin <= 0 { + _ = payment.WriteCoinTransByUser(ctx, payment.WriteCoinTransOpt{ + UserId: order.UserId, + FlowDir: cointransmod.FlowIn, + TranType: cointransmod.TranTypeCoinRecharge, + Amount: order.Coin, + OrderNo: order.TradeNo, + Remark: payment.CoinTransRemark(cointransmod.TranTypeCoinRecharge, ""), + }) + return + } + + if baseCoin > 0 { + _ = payment.WriteCoinTrans(ctx, payment.WriteCoinTransOpt{ + UserId: order.UserId, + FlowDir: cointransmod.FlowIn, + TranType: cointransmod.TranTypeCoinRecharge, + Amount: baseCoin, + OrderNo: order.TradeNo, + Remark: payment.CoinTransRemark(cointransmod.TranTypeCoinRecharge, ""), + }, balanceAfter-giftCoin) + } + _ = payment.WriteCoinTrans(ctx, payment.WriteCoinTransOpt{ + UserId: order.UserId, + FlowDir: cointransmod.FlowIn, + TranType: cointransmod.TranTypeCoinGift, + Amount: giftCoin, + OrderNo: order.TradeNo, + Remark: payment.CoinTransRemark(cointransmod.TranTypeCoinGift, ""), + }, balanceAfter) +} + +func resolveCoinOrderSplit(ctx context.Context, order vipordermod.VipOrder) (int64, int64) { + if order.BaseCoin+order.GiftCoin == order.Coin && (order.BaseCoin > 0 || order.GiftCoin > 0) { + return order.BaseCoin, order.GiftCoin + } + pkg, _ := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{ + rechargepackagemod.FN.RechargePackage.Id: order.CoinCfgId, + rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeCoin, + }) + if pkg != nil && pkg.BaseCoin+pkg.GiftCoin == order.Coin { + return pkg.BaseCoin, pkg.GiftCoin + } + return order.Coin, 0 +} + +func coinOrderBalanceAfter(ctx context.Context, userId int64) (int64, bool) { + user, serr := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId}) + if serr != nil || user == nil { + return 0, false + } + return user.Recharge, true +} + +func resolvePaymentAt(param *BillCallbackParam) time.Time { + if !param.PaymentAt.IsZero() { + return param.PaymentAt + } + if !param.SuccessAt.IsZero() { + return param.SuccessAt + } + return time.Now() +} + +func findVipPackage(ctx context.Context, packageId int64) (*rechargepackagemod.RechargePackage, *srvdef.SrvErr) { + pkg, serr := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{ + rechargepackagemod.FN.RechargePackage.Id: packageId, + rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeVip, + }) + if serr != nil || pkg == nil { + return nil, srvdef.New(srvdef.ErrNoData, "会员套餐不存在", "") + } + return pkg, nil +} + +func addUserCoins(ctx context.Context, userId int64, coin int64) *srvdef.SrvErr { + return usermod.UpdateOneUser(ctx, + bson.M{usermod.FN.User.Id: userId}, + bson.M{ + "$inc": bson.M{usermod.FN.User.Recharge: coin}, + "$set": bson.M{usermod.FN.User.UpdatedAt: time.Now()}, + }, + ) +} + +func reportVipOrderPaid(ctx context.Context, order *vipordermod.VipOrder, oid string, realAmount int64, vipExpire time.Time) { + user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: order.UserId}) + districtCode, ip := "", order.IPAddr + if user != nil { + districtCode = user.DistrictCode + } + + ua := order.Client.UA(order.SysType) + eventId := newEventId(pubdef.TrackerAppId(), order.UserId, order.TradeNo, order.CreatedAt.Unix()) + cf := dataReport.NewCommonField(eventId, dataReport.EventTypeOrderPaid, districtCode, ua, strconv.FormatInt(user.Id, 10), ip) + + var expireTS int64 + if !vipExpire.IsZero() { + expireTS = vipExpire.Unix() + } + cf.Payload = dataReport.JsonRawMessage(&dataReport.OrderPaidEvent{ + OrderID: order.TradeNo, + OrderType: "vip_subscription", + ProductID: strconv.FormatInt(order.CardId, 10), + Amount: dataReport.JsonNumber(realAmount), + Currency: "CNY", + VIPExpirationTime: dataReport.JsonNumber(expireTS), + PayType: order.PayMode, + PayChannel: order.PayChannel, + TransactionID: oid, + CreateTime: dataReport.JsonNumber(order.CreatedAt.Unix()), + }) + insertEvent(ctx, cf, order.TradeNo) +} + +func reportCoinOrderPaid(ctx context.Context, order *vipordermod.VipOrder, oid string, realAmount int64) { + user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: order.UserId}) + districtCode, ip := "", order.IPAddr + if user != nil { + districtCode = user.DistrictCode + } + + ua := order.Client.UA(order.SysType) + eventId := newEventId(pubdef.TrackerAppId(), order.UserId, order.TradeNo, order.CreatedAt.Unix()) + cf := dataReport.NewCommonField(eventId, dataReport.EventTypeOrderPaid, districtCode, ua, strconv.FormatInt(user.Id, 10), ip) + + cf.Payload = dataReport.JsonRawMessage(&dataReport.OrderPaidEvent{ + OrderID: order.TradeNo, + OrderType: "coin_purchase", + ProductID: strconv.FormatInt(order.CoinCfgId, 10), + Amount: dataReport.JsonNumber(realAmount), + Currency: "CNY", + CoinQuantity: dataReport.JsonNumber(order.Coin), + PayType: order.PayMode, + PayChannel: order.PayChannel, + TransactionID: oid, + CreateTime: dataReport.JsonNumber(order.CreatedAt.Unix()), + }) + insertEvent(ctx, cf, order.TradeNo) +} + +func insertEvent(ctx context.Context, cf dataReport.CommonField, tradeNo string) { + data, _ := json.Marshal(cf) + if err := eventmod.InsertOneEvent(ctx, &eventmod.Event{ + EventId: cf.EventID, + EventType: cf.Event, + Data: string(data), + Status: eventmod.Default, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }); err != nil { + log.Error("insertEvent failed", log.Any("tradeNo", tradeNo), log.E(err)) + } +} + +func newEventId(appId string, uid int64, orderId interface{}, createTime int64) string { + input := fmt.Sprintf("%v%v%v%v", appId, uid, orderId, createTime) + h := md5.New() + h.Write([]byte(input)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/web/service/srv_rechargepackage/define.go b/web/service/srv_rechargepackage/define.go new file mode 100644 index 0000000..39753c4 --- /dev/null +++ b/web/service/srv_rechargepackage/define.go @@ -0,0 +1,88 @@ +package srv_rechargepackage + +import ( + "time" + "videoweb/pub/models/rechargepackagemod" + "videoweb/pub/pubdef" +) + +// RechargePackageListReq Web后台购买套餐分页列表请求 +type RechargePackageListReq struct { + pubdef.Page + Id *int64 `json:"id"` // 套餐ID + PackageType *rechargepackagemod.PackageType `json:"packageType"` // 套餐类型:1-会员畅享 2-金币充值 + Status *rechargepackagemod.Status `json:"status"` // 状态:1-启用 2-停用 + Name string `json:"name"` // 展示标题,模糊搜索 +} + +// RechargePackageListResp Web后台购买套餐分页列表响应 +type RechargePackageListResp struct { + Total int64 `json:"total"` // 总数量 + List []*RechargePackageItem `json:"list"` // 当前页列表 +} + +// VipPackageOption 会员畅享下拉列表项。 +type VipPackageOption struct { + Id int64 `json:"id"` // 套餐ID + Name string `json:"name"` // 套餐名称 +} + +// RechargePackageItem Web后台购买套餐列表项 +type RechargePackageItem struct { + Id int64 `json:"id"` // 套餐ID + PackageType rechargepackagemod.PackageType `json:"packageType"` // 套餐类型:1-会员畅享 2-金币充值 + Name string `json:"name"` // 展示标题 + SubTitle string `json:"subTitle"` // 展示副标题 + Price int64 `json:"price"` // 售价,单位分 + OriginalPrice int64 `json:"originalPrice"` // 原价,单位分 + DurationDays int64 `json:"durationDays"` // 会员有效天数 + DurationHours int64 `json:"durationHours"` // 会员有效小时数,由durationDays计算 + BaseCoin int64 `json:"baseCoin"` // 基础金币数 + GiftCoin int64 `json:"giftCoin"` // 赠送金币数 + TotalCoin int64 `json:"totalCoin"` // 到账总金币数,由baseCoin+giftCoin计算 + Sort int32 `json:"sort"` // 排序,越小越靠前 + Status rechargepackagemod.Status `json:"status"` // 状态:1-启用 2-停用 + Remark string `json:"remark"` // 后台备注 + CreatedAt time.Time `json:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt"` // 更新时间 +} + +// RechargePackageAddReq Web后台新增购买套餐请求 +type RechargePackageAddReq struct { + PackageType rechargepackagemod.PackageType `json:"packageType" binding:"required"` // 套餐类型:1-会员畅享 2-金币充值 + Name string `json:"name" binding:"required"` // 展示标题 + Price int64 `json:"price" binding:"required"` // 售价,单位分 + OriginalPrice int64 `json:"originalPrice"` // 原价,单位分 + DurationDays int64 `json:"durationDays"` // 会员有效天数,金币套餐传0 + BaseCoin int64 `json:"baseCoin"` // 基础金币数,会员套餐传0 + GiftCoin int64 `json:"giftCoin"` // 赠送金币数 + Sort int32 `json:"sort"` // 排序,越小越靠前 + Status rechargepackagemod.Status `json:"status"` // 状态:1-启用 2-停用,默认1 + Remark string `json:"remark"` // 后台备注 +} + +// RechargePackageUpdateReq Web后台更新购买套餐请求 +type RechargePackageUpdateReq struct { + Id int64 `json:"id" binding:"required"` // 套餐ID + PackageType *rechargepackagemod.PackageType `json:"packageType"` // 套餐类型:1-会员畅享 2-金币充值 + Name *string `json:"name"` // 展示标题 + Price *int64 `json:"price"` // 售价,单位分 + OriginalPrice *int64 `json:"originalPrice"` // 原价,单位分 + DurationDays *int64 `json:"durationDays"` // 会员有效天数 + BaseCoin *int64 `json:"baseCoin"` // 基础金币数 + GiftCoin *int64 `json:"giftCoin"` // 赠送金币数 + Sort *int32 `json:"sort"` // 排序 + Status *rechargepackagemod.Status `json:"status"` // 状态:1-启用 2-停用 + Remark *string `json:"remark"` // 后台备注 +} + +// RechargePackageDelReq Web后台删除购买套餐请求。 +type RechargePackageDelReq struct { + Id int64 `json:"id" binding:"required"` // 套餐ID +} + +// RechargePackageStatusSetReq Web后台单/批量设置购买套餐状态请求。 +type RechargePackageStatusSetReq struct { + Ids []int64 `json:"ids"` // 套餐ID集合 + Status rechargepackagemod.Status `json:"status"` // 状态:1-启用 2-停用 +} diff --git a/web/service/srv_rechargepackage/package.go b/web/service/srv_rechargepackage/package.go new file mode 100644 index 0000000..c106f61 --- /dev/null +++ b/web/service/srv_rechargepackage/package.go @@ -0,0 +1,343 @@ +package srv_rechargepackage + +import ( + "context" + "fmt" + "strings" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/rechargepackagemod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + defaultPageNum int64 = 1 + defaultPageSize int64 = 10 + maxPageSize int64 = 100 +) + +// ListRechargePackage 分页查询购买套餐配置。 +func ListRechargePackage(ctx context.Context, req *RechargePackageListReq) (*RechargePackageListResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + filter := buildListFilter(req) + pageNum, pageSize := normalizePage(req.PageNum, req.PageSize) + total, err := rechargepackagemod.CountRechargePackage(ctx, filter) + if err != nil { + return nil, err + } + resp := &RechargePackageListResp{ + Total: total, + List: make([]*RechargePackageItem, 0), + } + if total == 0 { + return resp, nil + } + list, err := rechargepackagemod.FindManyRechargePackage(ctx, filter, options.Find(). + SetSort(bson.D{ + {Key: rechargepackagemod.FN.RechargePackage.PackageType, Value: 1}, + {Key: rechargepackagemod.FN.RechargePackage.Sort, Value: 1}, + {Key: rechargepackagemod.FN.RechargePackage.Id, Value: -1}, + }). + SetSkip((pageNum-1)*pageSize). + SetLimit(pageSize)) + if err != nil { + return nil, err + } + for _, item := range list { + resp.List = append(resp.List, rechargePackageToWebItem(item)) + } + return resp, nil +} + +// ListVipPackageOptions 查询启用的会员畅享套餐选项,不分页。 +func ListVipPackageOptions(ctx context.Context) ([]*VipPackageOption, *srvdef.SrvErr) { + list, err := rechargepackagemod.FindManyRechargePackage(ctx, bson.M{ + rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeVip, + rechargepackagemod.FN.RechargePackage.Status: rechargepackagemod.StatusEnabled, + }, options.Find(). + SetProjection(bson.D{ + {Key: rechargepackagemod.FN.RechargePackage.Id, Value: 1}, + {Key: rechargepackagemod.FN.RechargePackage.Name, Value: 1}, + }). + SetSort(bson.D{ + {Key: rechargepackagemod.FN.RechargePackage.Sort, Value: 1}, + {Key: rechargepackagemod.FN.RechargePackage.Id, Value: -1}, + })) + if err != nil { + return nil, err + } + result := make([]*VipPackageOption, 0, len(list)) + for _, item := range list { + if item == nil { + continue + } + result = append(result, &VipPackageOption{ + Id: item.Id, + Name: item.Name, + }) + } + return result, nil +} + +// AddRechargePackage 新增购买套餐配置。 +func AddRechargePackage(ctx context.Context, req *RechargePackageAddReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + doc := &rechargepackagemod.RechargePackage{ + PackageType: req.PackageType, + Name: strings.TrimSpace(req.Name), + Price: req.Price, + OriginalPrice: req.OriginalPrice, + DurationDays: req.DurationDays, + BaseCoin: req.BaseCoin, + GiftCoin: req.GiftCoin, + Sort: req.Sort, + Status: req.Status, + Remark: strings.TrimSpace(req.Remark), + } + if doc.Status == 0 { + doc.Status = rechargepackagemod.StatusEnabled + } + normalizePackageFields(doc) + if err := validatePackage(doc); err != nil { + return err + } + doc.SubTitle = buildSubTitle(doc) + return rechargepackagemod.InsertOneRechargePackage(ctx, doc) +} + +// UpdateRechargePackage 更新购买套餐配置。 +func UpdateRechargePackage(ctx context.Context, req *RechargePackageUpdateReq) *srvdef.SrvErr { + if req == nil || req.Id <= 0 { + return srvdef.New(srvdef.ErrParamError, "套餐ID不能为空", "") + } + exist, err := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{rechargepackagemod.FN.RechargePackage.Id: req.Id}) + if err != nil { + return err + } + doc := *exist + applyUpdate(&doc, req) + normalizePackageFields(&doc) + if err := validatePackage(&doc); err != nil { + return err + } + doc.SubTitle = buildSubTitle(&doc) + now := time.Now() + setDoc := bson.M{ + rechargepackagemod.FN.RechargePackage.PackageType: doc.PackageType, + rechargepackagemod.FN.RechargePackage.Name: doc.Name, + rechargepackagemod.FN.RechargePackage.SubTitle: doc.SubTitle, + rechargepackagemod.FN.RechargePackage.Price: doc.Price, + rechargepackagemod.FN.RechargePackage.OriginalPrice: doc.OriginalPrice, + rechargepackagemod.FN.RechargePackage.DurationDays: doc.DurationDays, + rechargepackagemod.FN.RechargePackage.BaseCoin: doc.BaseCoin, + rechargepackagemod.FN.RechargePackage.GiftCoin: doc.GiftCoin, + rechargepackagemod.FN.RechargePackage.Sort: doc.Sort, + rechargepackagemod.FN.RechargePackage.Status: doc.Status, + rechargepackagemod.FN.RechargePackage.Remark: doc.Remark, + rechargepackagemod.FN.RechargePackage.UpdatedAt: now, + } + return rechargepackagemod.UpdateOneRechargePackage(ctx, + bson.M{rechargepackagemod.FN.RechargePackage.Id: req.Id}, + bson.M{"$set": setDoc}, + ) +} + +// DeleteRechargePackage 删除购买套餐配置。 +func DeleteRechargePackage(ctx context.Context, id int64) *srvdef.SrvErr { + if id <= 0 { + return srvdef.New(srvdef.ErrParamError, "套餐ID不能为空", "") + } + return rechargepackagemod.DeleteOneRechargePackage(ctx, bson.M{rechargepackagemod.FN.RechargePackage.Id: id}) +} + +// SetRechargePackageStatus 单/批量设置购买套餐启停状态。 +func SetRechargePackageStatus(ctx context.Context, req *RechargePackageStatusSetReq) *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 !isValidStatus(req.Status) { + return srvdef.New(srvdef.ErrParamError, "状态无效", "") + } + return rechargepackagemod.UpdateManyRechargePackage(ctx, + bson.M{rechargepackagemod.FN.RechargePackage.Id: bson.M{"$in": ids}}, + bson.M{"$set": bson.M{ + rechargepackagemod.FN.RechargePackage.Status: req.Status, + rechargepackagemod.FN.RechargePackage.UpdatedAt: time.Now(), + }}, + ) +} + +func buildListFilter(req *RechargePackageListReq) bson.M { + filter := bson.M{} + if req.Id != nil { + filter[rechargepackagemod.FN.RechargePackage.Id] = *req.Id + } + if req.PackageType != nil { + filter[rechargepackagemod.FN.RechargePackage.PackageType] = *req.PackageType + } + if req.Status != nil { + filter[rechargepackagemod.FN.RechargePackage.Status] = *req.Status + } + if strings.TrimSpace(req.Name) != "" { + filter[rechargepackagemod.FN.RechargePackage.Name] = bson.M{ + "$regex": primitive.Regex{Pattern: strings.TrimSpace(req.Name), Options: "i"}, + } + } + return filter +} + +// normalizePage 统一分页兜底,限制 pageSize 防止后台误传过大导致慢查询。 +func normalizePage(pageNum, pageSize int64) (int64, int64) { + if pageNum <= 0 { + pageNum = defaultPageNum + } + if pageSize <= 0 { + pageSize = defaultPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return pageNum, pageSize +} + +// normalizePackageFields 清理不同套餐类型不生效的字段。 +// 会员套餐不使用金币字段;金币套餐不使用会员有效期字段。 +func normalizePackageFields(doc *rechargepackagemod.RechargePackage) { + switch doc.PackageType { + case rechargepackagemod.PackageTypeVip: + doc.BaseCoin = 0 + doc.GiftCoin = 0 + case rechargepackagemod.PackageTypeCoin: + doc.DurationDays = 0 + } +} + +// validatePackage 校验购买套餐配置和套餐类型字段约束。 +func validatePackage(doc *rechargepackagemod.RechargePackage) *srvdef.SrvErr { + if !isValidPackageType(doc.PackageType) { + return srvdef.New(srvdef.ErrParamError, "套餐类型无效", "") + } + if strings.TrimSpace(doc.Name) == "" { + return srvdef.New(srvdef.ErrParamError, "套餐标题不能为空", "") + } + if doc.Price <= 0 { + return srvdef.New(srvdef.ErrParamError, "套餐价格必须大于0", "") + } + if !isValidStatus(doc.Status) { + return srvdef.New(srvdef.ErrParamError, "状态无效", "") + } + if doc.PackageType == rechargepackagemod.PackageTypeVip && doc.DurationDays <= 0 { + return srvdef.New(srvdef.ErrParamError, "会员套餐有效天数必须大于0", "") + } + if doc.PackageType == rechargepackagemod.PackageTypeCoin && doc.BaseCoin+doc.GiftCoin <= 0 { + return srvdef.New(srvdef.ErrParamError, "金币套餐到账金币数必须大于0", "") + } + return nil +} + +func isValidPackageType(packageType rechargepackagemod.PackageType) bool { + return packageType == rechargepackagemod.PackageTypeVip || packageType == rechargepackagemod.PackageTypeCoin +} + +func isValidStatus(status rechargepackagemod.Status) bool { + return status == rechargepackagemod.StatusEnabled || status == rechargepackagemod.StatusDisabled +} + +// applyUpdate 将更新请求合并到现有文档副本,再统一做类型归一化和校验。 +func applyUpdate(doc *rechargepackagemod.RechargePackage, req *RechargePackageUpdateReq) { + if req.PackageType != nil { + doc.PackageType = *req.PackageType + } + if req.Name != nil { + doc.Name = strings.TrimSpace(*req.Name) + } + if req.Price != nil { + doc.Price = *req.Price + } + if req.OriginalPrice != nil { + doc.OriginalPrice = *req.OriginalPrice + } + if req.DurationDays != nil { + doc.DurationDays = *req.DurationDays + } + if req.BaseCoin != nil { + doc.BaseCoin = *req.BaseCoin + } + if req.GiftCoin != nil { + doc.GiftCoin = *req.GiftCoin + } + if req.Sort != nil { + doc.Sort = *req.Sort + } + if req.Status != nil { + doc.Status = *req.Status + } + if req.Remark != nil { + doc.Remark = strings.TrimSpace(*req.Remark) + } +} + +// buildSubTitle 根据套餐类型和核心配置生成展示副标题,不由后台请求手动传入。 +func buildSubTitle(doc *rechargepackagemod.RechargePackage) string { + switch doc.PackageType { + case rechargepackagemod.PackageTypeVip: + return fmt.Sprintf("%d × 24 小时会员", doc.DurationDays) + case rechargepackagemod.PackageTypeCoin: + if doc.GiftCoin > 0 { + return fmt.Sprintf("基础%d + 赠送%d", doc.BaseCoin, doc.GiftCoin) + } + return fmt.Sprintf("基础%d", doc.BaseCoin) + default: + return "" + } +} + +// rechargePackageToWebItem 转换后台列表展示项,并补充派生字段。 +func rechargePackageToWebItem(item *rechargepackagemod.RechargePackage) *RechargePackageItem { + return &RechargePackageItem{ + Id: item.Id, + PackageType: item.PackageType, + Name: item.Name, + SubTitle: item.SubTitle, + Price: item.Price, + OriginalPrice: item.OriginalPrice, + DurationDays: item.DurationDays, + DurationHours: item.DurationDays * 24, + BaseCoin: item.BaseCoin, + GiftCoin: item.GiftCoin, + TotalCoin: item.BaseCoin + item.GiftCoin, + Sort: item.Sort, + Status: item.Status, + Remark: item.Remark, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +// dedupePositiveInt64Ids 去重并过滤非法ID,批量操作只处理正整数ID。 +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 +} diff --git a/web/service/srv_report/report.go b/web/service/srv_report/report.go new file mode 100644 index 0000000..6760381 --- /dev/null +++ b/web/service/srv_report/report.go @@ -0,0 +1,73 @@ +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" + + "go.mongodb.org/mongo-driver/bson" +) + +// ReportVideoStatusChangeEvent 视频状态变更事件上报 +func ReportVideoStatusChangeEvent(media *videoinformationmod.VideoInformation, flag, userId int64, ua useragent.UA) { + if media == nil || media.Id == 0 || flag == 0 { + return + } + + var lsjId string + + var videoTypeID = "default" + var videoTypeName = "默认分类" + if len(media.CategoryIds) > 0 { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + category, _ := videocategorymod.FindOneVideoCategory(ctx, bson.M{ + videocategorymod.FN.VideoCategory.Id: media.CategoryIds[0], + }) + if category != nil { + videoTypeID = fmt.Sprintf("%v", category.Id) + videoTypeName = category.Name + } + } + event := dataReport.NewCommonField("", dataReport.EventTypeVideoStatusChange, "", ua, strconv.FormatInt(userId, 10), "") + + 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 videoinformationmod.InformationSatus) int64 { + switch status { + case videoinformationmod.InformationSatusVerifyOK: + return 1 // 上架 + case videoinformationmod.InformationSatusVerifyCancel: + 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/define.go b/web/service/srv_search/define.go new file mode 100644 index 0000000..22da4fd --- /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 +} diff --git a/web/service/srv_search/es.go b/web/service/srv_search/es.go new file mode 100644 index 0000000..d6330a9 --- /dev/null +++ b/web/service/srv_search/es.go @@ -0,0 +1,177 @@ +package srv_search + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/esmanager" + "videoweb/pub/models/esmod" + "videoweb/pub/models/videoinformationmod" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +var syncIsRuining = false + +func StartES() { + log.Info("srv_search:Start...") + defer log.Info("srv_search:Start!!!") + // 初始化 ES 索引 mapping(已存在时报错可忽略) + //InitArticleESIndex() + //InitToolESIndex() + startSyncEs() + _, _ = skdCron.AddFunc("0 0/17 * * * ?", func() { + startSyncEs() + }) + skdCron.Start() +} + +func startSyncEs() { + ctx := context.Background() + updateEsData(ctx, esmod.GetVideoIndex(), getVideoEsData) +} + +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()) { + esData, serr = fn(ctx, nil, nil, esIndex) + if serr != nil { + log.Warn("updateEsData fn return err", log.Any("index", esIndex), log.E(serr)) + return + } + _ = delAllFromES(esIndex) + 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, now); err != nil { + return + } + } + } else { + timestamp := esRecord.RecordTime + var recentMinute = utils.RecentMinute(now, esmod.FiveMinuteScale) + 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 { + 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, now); err != nil { + return + } + } + } + timestamp = end + } + } +} + +func commit(data esmanager.M, esIndex string, syncTime time.Time) (bool, error) { + bulkData := handleBulkCountLimit(data) + isHaveSuccess := false + if bulkData != nil && len(bulkData) > 0 { + for _, doc := range bulkData { + if err := esmanager.Instance.Bulk(esIndex, doc); err != nil { + log.Warn(fmt.Sprintf("Elastic sync commit video data SyncDataToES failed!, err:%+v\n", err)) + // 出错继续 + } else { + isHaveSuccess = true + } + } + } + if isHaveSuccess { + 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 +} + +// DelVideoFromES 从 ES 实时删除单条视频(视频删除/下架时调用) +func DelVideoFromES(id int64) { + if esmanager.Instance == nil { + return + } + strId := strconv.FormatInt(id, 10) + if err := batchDelFromES(esmod.GetVideoIndex(), []string{strId}); err != nil { + log.Warn("srv_search:DelVideoFromES failed", log.Any("id", id), log.E(err)) + } +} + +// UpsertVideoToES 将单条视频实时写入/更新 ES(视频上架时调用) +func UpsertVideoToES(info *videoinformationmod.VideoInformation) { + if esmanager.Instance == nil { + return + } + esData := videoToEsData([]*videoinformationmod.VideoInformation{info}) + if err := AddMultiDataToES(esmod.GetVideoIndex(), esData); err != nil { + log.Warn("srv_search:UpsertVideoToES failed", log.Any("id", info.Id), log.E(err)) + } +} diff --git a/web/service/srv_search/es_playlet.go b/web/service/srv_search/es_playlet.go new file mode 100644 index 0000000..bac7b08 --- /dev/null +++ b/web/service/srv_search/es_playlet.go @@ -0,0 +1,249 @@ +package srv_search + +import ( + "context" + "strconv" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/esmanager" + "videoweb/pub/models/esmod" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/playlettagmod" + "videoweb/pub/models/playletthememod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +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.OperateType, 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.ThemeIds, Value: 1}, + {Key: playletmod.FN.Playlet.CategoryIds, Value: 1}, + {Key: playletmod.FN.Playlet.Title, Value: 1}, + {Key: playletmod.FN.Playlet.PlayName, Value: 1}, + {Key: playletmod.FN.Playlet.Description, 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.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.HeatValue, 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.CreatedAt, Value: 1}, + {Key: playletmod.FN.Playlet.UpdatedAt, Value: 1}, + {Key: "seoInfo", Value: 1}, +} + +// SyncPlayletStatusToES 根据短剧上下架状态同步ES搜索数据 +func SyncPlayletStatusToES(ctx context.Context, ids []int64, status playletmod.Status) *srvdef.SrvErr { + // ES只保留已上架短剧;上架写入/更新索引,下架从索引删除。 + 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 + } +} + +// upsertPlayletsToES 批量写入已上架短剧ES文档 +func upsertPlayletsToES(ctx context.Context, ids []int64) *srvdef.SrvErr { + // 只查询已上架数据,避免并发状态变化后把已下架短剧重新写进ES。 + list, err := playletmod.FindManyPlaylet(ctx, bson.M{ + playletmod.FN.Playlet.Id: bson.M{"$in": ids}, + playletmod.FN.Playlet.Status: playletmod.StatusUp, + }, options.Find().SetProjection(playletESProjection)) + if err != nil { + return err + } + if len(list) == 0 { + return nil + } + themeNameMap, err := buildPlayletESThemeNameMap(ctx, list) + if err != nil { + return err + } + tagNameMap, err := buildPlayletESTagNameMap(ctx, list) + if err != nil { + return err + } + esData := make(esmanager.M, len(list)) + for _, item := range list { + if item == nil { + continue + } + esData[strconv.FormatInt(item.Id, 10)] = playletToESDoc(item, themeNameMap, tagNameMap) + } + if len(esData) == 0 { + return nil + } + if err = AddMultiDataToES(esmod.GetPlayletIndex(), esData); err != nil { + log.Error("批量写入短剧ES失败", log.Any("短剧ID", ids), log.E(err)) + return err + } + log.Info("批量写入短剧ES成功", log.Any("数量", len(esData)), log.Any("短剧ID", ids)) + return nil +} + +// deletePlayletsFromES 批量删除短剧ES文档 +func deletePlayletsFromES(ids []int64) *srvdef.SrvErr { + // ES文档ID和Mongo短剧业务ID保持一致,可以直接按ID批量删除。 + 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 err := batchDelFromES(esmod.GetPlayletIndex(), strIds); err != nil { + log.Error("批量删除短剧ES失败", log.Any("短剧ID", ids), log.E(err)) + return err + } + log.Info("批量删除短剧ES成功", log.Any("数量", len(strIds)), log.Any("短剧ID", ids)) + return nil +} + +// playletToESDoc 转换短剧Mongo数据为ES文档 +func playletToESDoc(item *playletmod.Playlet, themeNameMap map[int64]string, tagNameMap map[int64]string) *esmod.ESPlaylet { + // ES文档保留题材名和标签名,app搜索可直接命中剧名、题材名、简介和标签名。 + return &esmod.ESPlaylet{ + Id: item.Id, + Status: item.Status, + IsFeatured: item.IsFeatured, + IsRecommend: item.IsRecommend, + OperateType: playletmod.NormalizeOperateType(item.OperateType), + ShowOnBanner: item.ShowOnBanner, + Banner: item.Banner, + OnlineAt: item.OnlineAt, + ThemeIds: item.ThemeIds, + ThemeNames: playletESNamesByIds(item.ThemeIds, themeNameMap), + CategoryIds: item.CategoryIds, + Title: item.Title, + PlayName: item.PlayName, + Description: item.Description, + Slug: item.Slug, + 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.CountBrowse, + HeatValue: item.HeatValue, + CountCollect: item.CountCollect, + CountLike: item.CountLike + item.VirtualCountLike, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + SeoInfo: item.SeoInfo, + } +} + +// 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, 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 _, 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, err := playlettagmod.FindManyPlayletTag(ctx, bson.M{ + playlettagmod.FN.PlayletTag.Id: bson.M{"$in": ids}, + }, options.Find().SetProjection(bson.D{ + {Key: playlettagmod.FN.PlayletTag.Id, Value: 1}, + {Key: playlettagmod.FN.PlayletTag.Name, Value: 1}, + })) + if err != nil { + return nil, err + } + 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 +} + +// 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/esmedia.go b/web/service/srv_search/esmedia.go new file mode 100644 index 0000000..b8d0ead --- /dev/null +++ b/web/service/srv_search/esmedia.go @@ -0,0 +1,98 @@ +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{ + videoinformationmod.FN.VideoInformation.MediaType: bson.M{ + "$in": []interface{}{ + videoinformationmod.MediaType_Video, + videoinformationmod.MediaType_VideoV2, + }, + }, + } + 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, + Slug: v.Slug, + 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, + CategoryIds: v.CategoryIds, + Tags: v.Tags, + Real: v.Real, + VideoCount: v.VideoCount, + } + 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/keyword.go b/web/service/srv_search/keyword.go new file mode 100644 index 0000000..904e172 --- /dev/null +++ b/web/service/srv_search/keyword.go @@ -0,0 +1,71 @@ +package srv_search + +import ( + "regexp" + "strings" +) + +// 分隔符:空格、英文逗号、中文逗号、顿号、斜杠、竖线 +var kwSplitRe = regexp.MustCompile(`[\s,,、/|]+`) + +// ParseKeywords 解析搜索字符串为关键词列表。 +// 规则:去除首尾空格,转小写,按空格/逗号/顿号/斜杠/竖线分割,过滤空串。 +func ParseKeywords(q string) []string { + q = strings.TrimSpace(q) + if q == "" { + return nil + } + parts := kwSplitRe.Split(strings.ToLower(q), -1) + result := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + result = append(result, p) + } + } + return result +} + +// buildMultiKeywordQuery 构建多关键词 ES bool should 查询(关键词之间 OR 关系)。 +// 每个关键词同时生成两个子句: +// - 精确 multi_match(权重更高,boost=2) +// - 模糊 multi_match(fuzziness=AUTO,prefix_length=1),覆盖拼写错误/多余字符等场景 +// +// 示例:ChatGPTT→ChatGPT(1次编辑)、midjorny→midjourney(2次编辑)、数智人→数字人(1次编辑) +func buildMultiKeywordQuery(keywords []string, fields []string, from, size int) map[string]interface{} { + shouldClauses := make([]map[string]interface{}, 0, len(keywords)*2) + for _, kw := range keywords { + // 精确匹配,分数更高 + shouldClauses = append(shouldClauses, map[string]interface{}{ + "multi_match": map[string]interface{}{ + "query": kw, + "fields": fields, + "type": "best_fields", + "boost": 2, + }, + }) + // 模糊匹配:AUTO fuzziness(长度≥6时允许2次编辑),prefix_length=1 避免过度误召回 + shouldClauses = append(shouldClauses, map[string]interface{}{ + "multi_match": map[string]interface{}{ + "query": kw, + "fields": fields, + "type": "best_fields", + "fuzziness": "AUTO", + "prefix_length": 1, + "fuzzy_rewrite": "constant_score_blended", + }, + }) + } + return map[string]interface{}{ + "from": from, + "size": size, + "query": map[string]interface{}{ + "bool": map[string]interface{}{ + "should": shouldClauses, + "minimum_should_match": 1, + }, + }, + "sort": []map[string]interface{}{ + {"_score": map[string]interface{}{"order": "desc"}}, + }, + } +} diff --git a/web/service/srv_search/search.go b/web/service/srv_search/search.go new file mode 100644 index 0000000..de0a9e8 --- /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() +} diff --git a/web/service/srv_search/seo.go b/web/service/srv_search/seo.go new file mode 100644 index 0000000..23ffd9c --- /dev/null +++ b/web/service/srv_search/seo.go @@ -0,0 +1,15 @@ +package srv_search + +import ( + "strings" + "videoweb/pub/pubdef" +) + +func flattenSeoInfo(seo pubdef.SeoValue) (title, keywords, desc string) { + title = seo.Title + desc = seo.Description + if len(seo.Keyworld) > 0 { + keywords = strings.Join(seo.Keyworld, " ") + } + return +} diff --git a/web/service/srv_search/sync_common.go b/web/service/srv_search/sync_common.go new file mode 100644 index 0000000..88aee07 --- /dev/null +++ b/web/service/srv_search/sync_common.go @@ -0,0 +1,47 @@ +package srv_search + +import ( + "context" + "time" + "videoweb/pub/esmanager" + "videoweb/pub/models/esmod" +) + +const esSyncOverlap = time.Minute + +// needFullESSync 判断是否需要全量同步 ES +func needFullESSync(ctx context.Context, esIndex string) (bool, *esmod.ESRecord) { + esRecord, serr := esmod.GetRecordTimeByESIndex(ctx, esIndex) + if serr != nil || esRecord == nil { + return true, esRecord + } + + count, err := esmanager.Instance.Count(esIndex, esmanager.M{ + "query": esmanager.M{"match_all": esmanager.M{}}, + }) + if err != nil || count == 0 { + return true, esRecord + } + + now := time.Now() + if (now.Hour() >= 5 && esRecord.UpdatedAt.Local().Hour() < 5) || + esRecord.UpdatedAt.Local().Day() < now.Day() { + return true, esRecord + } + return false, esRecord +} + +// esSyncSinceTime 取增量同步起点(RecordTime 优先,回退 1 分钟避免边界遗漏) +func esSyncSinceTime(esRecord *esmod.ESRecord) time.Time { + if esRecord == nil { + return time.Time{} + } + since := esRecord.RecordTime + if since.IsZero() { + since = esRecord.UpdatedAt + } + if since.IsZero() { + return time.Time{} + } + return since.Add(-esSyncOverlap) +} diff --git a/web/service/srv_search/updater.go b/web/service/srv_search/updater.go new file mode 100644 index 0000000..6c02e5a --- /dev/null +++ b/web/service/srv_search/updater.go @@ -0,0 +1,223 @@ +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)) + } + 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/push.go b/web/service/srv_seopush/push.go new file mode 100644 index 0000000..a5c19b2 --- /dev/null +++ b/web/service/srv_seopush/push.go @@ -0,0 +1,428 @@ +// 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/models/playletmod" + "videoweb/pub/models/seopushmod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" + "videoweb/pub/seo/googleindex" + "videoweb/pub/seo/h5path" + 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" +) + +// 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, +} + +// PlayletDetailURL 拼装短剧详情页完整地址,与 sitemap 一致:/drama/{slug},无 slug 回退 ID。 +func PlayletDetailURL(webAddr, slug string, playletId int64) string { + path := h5path.Drama(slug, playletId) + if path == "" { + return "" + } + return h5path.SitemapAbs(webAddr, path) +} + +// VideoPlayURL 拼装视频播放页完整地址,路径与 H5 / sitemap 一致:/series-play/{slug}。 +func VideoPlayURL(webAddr, slug string, videoId int64) string { + path := h5path.VideoPlay(slug, videoId) + if path == "" { + return "" + } + return h5path.Abs(webAddr, path) +} + +// PageVisible 判断详情页当前是否对外可访问。 +func PageVisible(status playletmod.Status, onlineAt time.Time) bool { + if status != playletmod.StatusUp { + return false + } + if onlineAt.IsZero() { + return true + } + return !onlineAt.After(time.Now()) +} + +// 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 +} + +func canPushExternally() bool { + return pubdef.Enabled() && webg.Conf.RunMod == "prod" +} + +// EnqueuePlayletUpdated 短剧新上架后登记并立即推送,仅在页面已可访问时生效 +func EnqueuePlayletUpdated(ctx context.Context, playletId int64, + status playletmod.Status, onlineAt time.Time) { + if !pubdef.Enabled() || !PageVisible(status, onlineAt) { + return + } + enqueue(ctx, playletId, seopushmod.ActionUpdated, true) +} + +// RegisterPlayletUpdated 只登记不立即推送,供批量补推使用 +func RegisterPlayletUpdated(ctx context.Context, playletId int64, + status playletmod.Status, onlineAt time.Time) { + if !pubdef.Enabled() || !PageVisible(status, onlineAt) { + return + } + enqueue(ctx, playletId, seopushmod.ActionUpdated, false) +} + +// EnqueuePlayletDeleted 短剧下架/删除后登记并立即推送 +func EnqueuePlayletDeleted(ctx context.Context, playletId int64) { + if !pubdef.Enabled() { + return + } + enqueue(ctx, playletId, seopushmod.ActionDeleted, true) +} + +// VideoPageVisible 判断视频播放页当前是否对外可访问。 +func VideoPageVisible(status videoinformationmod.InformationSatus) bool { + return status == videoinformationmod.InformationSatusVerifyOK +} + +// EnqueueVideoUpdated 视频上架后登记并立即推送。 +func EnqueueVideoUpdated(ctx context.Context, videoId int64, status videoinformationmod.InformationSatus) { + if !pubdef.Enabled() || !VideoPageVisible(status) { + return + } + enqueueVideo(ctx, videoId, seopushmod.ActionUpdated, true) +} + +// EnqueueVideoDeleted 视频下架/删除后登记并立即推送。 +func EnqueueVideoDeleted(ctx context.Context, videoId int64) { + if !pubdef.Enabled() { + return + } + enqueueVideo(ctx, videoId, seopushmod.ActionDeleted, true) +} + +func enqueue(ctx context.Context, playletId int64, action string, pushNow bool) { + webAddr := SEOBaseURL(ctx) + if webAddr == "" { + return + } + pageURL := PlayletDetailURL(webAddr, loadPlayletSlug(ctx, playletId), playletId) + if pageURL == "" { + return + } + now := time.Now() + task := &seopushmod.SeoPushTask{ + Url: pageURL, + VideoId: playletId, + 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 { + markPlayletQueued(ctx, playletId) + } + if !pushNow { + return + } + + godef.Go(func() { + PushTask(context.Background(), webAddr, task) + }) +} + +func enqueueVideo(ctx context.Context, videoId int64, action string, pushNow bool) { + webAddr := SEOBaseURL(ctx) + if webAddr == "" { + return + } + pageURL := VideoPlayURL(webAddr, loadVideoSlug(ctx, videoId), videoId) + if pageURL == "" { + return + } + now := time.Now() + task := &seopushmod.SeoPushTask{ + Url: pageURL, + VideoId: videoId, + 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 video task failed", log.Any("url", pageURL), log.E(serr)) + return + } + if action == seopushmod.ActionUpdated { + markVideoQueued(ctx, videoId) + } + if !pushNow { + return + } + godef.Go(func() { + PushTask(context.Background(), webAddr, task) + }) +} + +func loadPlayletSlug(ctx context.Context, playletId int64) string { + if playletId <= 0 { + return "" + } + item, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playletId}, + options.FindOne().SetProjection(bson.D{{Key: playletmod.FN.Playlet.Slug, Value: 1}})) + if err != nil || item == nil { + return "" + } + return strings.TrimSpace(item.Slug) +} + +func loadVideoSlug(ctx context.Context, videoId int64) string { + if videoId <= 0 { + return "" + } + item, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, + bson.M{videoinformationmod.FN.VideoInformation.Id: videoId}, + options.FindOne().SetProjection(bson.D{{Key: videoinformationmod.FN.VideoInformation.Slug, Value: 1}})) + if err != nil || item == nil { + return "" + } + return strings.TrimSpace(item.Slug) +} + +func isObsoletePushURL(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return true + } + if strings.Contains(raw, "?") { + return true + } + if strings.Contains(raw, "/lieyan/av/detail") { + return true + } + path := requestPath(raw) + if path == "/detail" || strings.HasPrefix(path, "/detail/") { + return true + } + // 旧推送路径 /play/{slug}。/series-play 是视频页,不能当成短剧旧地址。 + return path == "/play" || strings.HasPrefix(path, "/play/") +} + +func requestPath(raw string) string { + raw = strings.TrimSpace(raw) + if i := strings.IndexAny(raw, "?#"); i >= 0 { + raw = raw[:i] + } + if i := strings.Index(raw, "://"); i >= 0 { + raw = raw[i+3:] + if j := strings.Index(raw, "/"); j >= 0 { + return raw[j:] + } + return "/" + } + if raw == "" { + return "/" + } + if !strings.HasPrefix(raw, "/") { + return "/" + raw + } + return raw +} + +func markPlayletQueued(ctx context.Context, playletId int64) { + if serr := playletmod.UpdateOnePlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: playletId}, + bson.M{"$set": bson.M{playletmod.FN.Playlet.IndexNow: int64(1)}}); serr != nil { + log.Warn("seopush: mark playlet queued failed", log.Any("playletId", playletId), log.E(serr)) + } +} + +func markVideoQueued(ctx context.Context, videoId int64) { + if serr := videoinformationmod.UpdateOneVideoInformation(ctx, + bson.M{videoinformationmod.FN.VideoInformation.Id: videoId}, + bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.IndexNow: int64(1)}}); serr != nil { + log.Warn("seopush: mark video queued failed", log.Any("videoId", videoId), log.E(serr)) + } +} + +// PushTask 推送任务中尚未成功的通道 +func PushTask(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) { + if task == nil || !pubdef.Enabled() { + return + } + // 旧路径不再推送:查询参数、/play/{slug}、/detail/{slug}、/lieyan/av/detail。 + if isObsoletePushURL(task.Url) { + log.Warn("seopush: drop obsolete-url task", log.Any("url", task.Url), log.Any("action", task.Action)) + _, _ = seopushmod.DelManySeoPushTask(ctx, taskFilter(task)) + 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 daily quota exceeded, keep pending", + log.Any("url", task.Url), log.Any("resp", err.Error())) + return + } + if errors.Is(err, googleindex.ErrNotEnabled) { + log.Warn("seopush: google client not ready, keep pending", 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_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..9ee27f3 --- /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, serr := translationmod.CountTranslationInfos(ctx, filter) + if serr != nil { + return resp, serr + } + resp.Total = total + if total == 0 { + return resp, nil + } + ads, serr := translationmod.FindManyTranslationInfos(ctx, filter, opt) + if serr != nil { + return resp, serr + } + 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..7dc8cd8 --- /dev/null +++ b/web/service/srv_translationcategory/define.go @@ -0,0 +1,36 @@ +package srv_translationcategory + +import ( + "time" + "videoweb/pub/models/translationcategorymod" + "videoweb/pub/pubdef" +) + +type TranslationCategoryReq struct { + Id *int64 `json:"id"` // 分类ID,精确匹配 + Name string `json:"name"` // 分类名称,模糊搜索 + pubdef.Page +} +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 + SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息 + 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..83d91b4 --- /dev/null +++ b/web/service/srv_translationcategory/translation.go @@ -0,0 +1,90 @@ +package srv_translationcategory + +import ( + "context" + "regexp" + "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{} + if param.Id != nil && *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": regexp.QuoteMeta(name), "$options": "i"} + } + opt := &options.FindOptions{} + resp := &TranslationCategoryResp{} + + opt.SetSort(bson.M{translationcategorymod.FN.TranslationCategory.Rank: -1}) + opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize) + + total, serr := translationcategorymod.CountTranslationCategorys(ctx, filter) + if serr != nil { + return resp, serr + } + resp.Total = total + if total == 0 { + return resp, nil + } + ads, serr := translationcategorymod.FindManyTranslationCategorys(ctx, filter, opt) + if serr != nil { + return resp, serr + } + 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..9a22ac1 --- /dev/null +++ b/web/service/srv_user/define.go @@ -0,0 +1,43 @@ +package srv_user + +import ( + "videoweb/pub/models/usermod" + "videoweb/pub/pubdef" +) + +type UserListReq struct { + pubdef.Page + Id *int64 `json:"id"` // 用户ID + AccountNo string `json:"accountNo"` // 账号(精确) + Email string `json:"email"` // 邮箱(精确) + NickName string `json:"nickName"` // 昵称(模糊) + DistrictCode string `json:"districtCode"` // 渠道码 + Status *pubdef.UserStatus `json:"status"` // 状态 + Type *pubdef.UserType `json:"type"` // 用户类型 +} + +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"` +} + +type GiftCoinReq struct { + UserId int64 `json:"userId" binding:"required"` // 目标用户ID + Coin int64 `json:"coin" binding:"required"` // 赠送金币数(正数=增加,负数=扣除) + Remark string `json:"remark"` // 备注(记录在流水表) +} + +type UpdateVipReq struct { + UserId int64 `json:"userId" binding:"required"` // 用户ID + PackageId int64 `json:"packageId" binding:"required"` // 会员畅享套餐ID +} + +type ResetPasswordReq struct { + UserId int64 `json:"userId" binding:"required"` // 用户ID + Password string `json:"password" binding:"required"` // 明文密码 +} diff --git a/web/service/srv_user/user.go b/web/service/srv_user/user.go new file mode 100644 index 0000000..9aac6f7 --- /dev/null +++ b/web/service/srv_user/user.go @@ -0,0 +1,189 @@ +package srv_user + +import ( + "context" + "strings" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/cointransmod" + "videoweb/pub/models/rechargepackagemod" + "videoweb/pub/models/usermod" + "videoweb/pub/payment" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" + "golang.org/x/crypto/bcrypt" +) + +// 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 accountNo := strings.TrimSpace(req.AccountNo); accountNo != "" { + filter[usermod.FN.User.AccountNo] = accountNo + } + if email := strings.TrimSpace(req.Email); email != "" { + filter[usermod.FN.User.Email] = email + } + if req.NickName != "" { + filter[usermod.FN.User.NickName] = bson.M{"$regex": req.NickName} + } + if districtCode := strings.TrimSpace(req.DistrictCode); districtCode != "" { + filter[usermod.FN.User.DistrictCode] = districtCode + } + if req.Status != nil { + filter[usermod.FN.User.Status] = *req.Status + } + if req.Type != nil { + filter[usermod.FN.User.Type] = *req.Type + } + + 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}, + ) +} + +// GiftCoin 给用户赠送/扣除金币,并写入流水记录 +// coin 为正数时增加余额,为负数时扣减(不校验余额是否充足,由调用方确保) +func GiftCoin(ctx context.Context, req *GiftCoinReq) *srvdef.SrvErr { + if req.Coin == 0 { + return srvdef.New(srvdef.ErrParamError, "金币数量不能为 0", "") + } + + // 原子更新余额 + if serr := usermod.UpdateOneUser(ctx, + bson.M{usermod.FN.User.Id: req.UserId}, + bson.M{ + "$inc": bson.M{usermod.FN.User.Recharge: req.Coin}, + "$set": bson.M{usermod.FN.User.UpdatedAt: time.Now()}, + }, + ); serr != nil { + return serr + } + + // 写流水(更新后再查余额,作为 balanceAfter) + flowDir := cointransmod.FlowIn + amount := req.Coin + if req.Coin < 0 { + flowDir = cointransmod.FlowOut + amount = -req.Coin + } + remark := req.Remark + if remark == "" { + remark = payment.CoinTransRemark(cointransmod.TranTypeAdminAdjust, "") + } + _ = payment.WriteCoinTransByUser(ctx, payment.WriteCoinTransOpt{ + UserId: req.UserId, + FlowDir: flowDir, + TranType: cointransmod.TranTypeAdminAdjust, + Amount: amount, + Remark: remark, + }) + + return nil +} + +// UpdateVip 根据会员畅享套餐修改用户会员信息。 +func UpdateVip(ctx context.Context, req *UpdateVipReq) *srvdef.SrvErr { + if req == nil || req.UserId <= 0 || req.PackageId <= 0 { + return srvdef.New(srvdef.ErrParamError, "用户ID和套餐ID不能为空", "") + } + user, serr := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: req.UserId}) + if serr != nil || user == nil { + return srvdef.New(srvdef.ErrUserNotExsit, "", "") + } + pkg, serr := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{ + rechargepackagemod.FN.RechargePackage.Id: req.PackageId, + rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeVip, + rechargepackagemod.FN.RechargePackage.Status: rechargepackagemod.StatusEnabled, + }) + if serr != nil || pkg == nil { + return srvdef.New(srvdef.ErrNoData, "会员套餐不存在或未启用", "") + } + if pkg.DurationDays <= 0 { + return srvdef.New(srvdef.ErrParamError, "会员套餐有效天数无效", "") + } + + now := time.Now() + baseTime := now + if user.CurrentVipExpire.After(now) { + baseTime = user.CurrentVipExpire + } + newExpire := baseTime.Add(time.Duration(pkg.DurationDays) * 24 * time.Hour) + updateDoc := bson.M{ + "$set": bson.M{ + usermod.FN.User.VipType: vipTypeFromDurationDays(pkg.DurationDays), + usermod.FN.User.ShowCardId: pkg.Id, + usermod.FN.User.ShowCardName: pkg.Name, + usermod.FN.User.CurrentVipExpire: newExpire, + usermod.FN.User.UpdatedAt: now, + }, + } + + if serr = usermod.UpdateOneUser(ctx, bson.M{usermod.FN.User.Id: req.UserId}, updateDoc); serr != nil { + return serr + } + return nil +} + +// ResetPassword 按用户ID重置登陆密码,入库为 bcrypt 哈希,与 app 登录校验一致。 +func ResetPassword(ctx context.Context, req *ResetPasswordReq) *srvdef.SrvErr { + if req == nil || req.UserId <= 0 { + return srvdef.New(srvdef.ErrParamError, "用户ID不能为空", "") + } + password := strings.TrimSpace(req.Password) + if password == "" { + return srvdef.New(srvdef.ErrParamError, "密码不能为空", "") + } + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return srvdef.New(srvdef.Failure, "密码处理失败", "") + } + return usermod.UpdateOneUser(ctx, + bson.M{usermod.FN.User.Id: req.UserId}, + bson.M{"$set": bson.M{ + usermod.FN.User.Password: string(hashed), + usermod.FN.User.UpdatedAt: time.Now(), + }}, + ) +} + +func vipTypeFromDurationDays(days int64) pubdef.VipType { + if days >= 365 { + return pubdef.VipTypeYear + } + if days > 0 { + return pubdef.VipTypeMonth + } + return pubdef.VipTypeNone +} diff --git a/web/service/srv_videocategory/category.go b/web/service/srv_videocategory/category.go new file mode 100644 index 0000000..24e2bf4 --- /dev/null +++ b/web/service/srv_videocategory/category.go @@ -0,0 +1,126 @@ +package srv_videocategory + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/mongos" + "videoweb/web/service/srv_videoslug" + + "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{} + opt := &options.FindOptions{} + resp := &VideoCategoryResp{} + if param.ID > 0 { + filter[videocategorymod.FN.VideoCategory.Id] = param.ID + } + if param.Name != "" { + filter[videocategorymod.FN.VideoCategory.Name] = bson.M{ + "$regex": param.Name, + } + } + + 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 { + + ac.Id = mongos.Lid.VideoCategory.Get() + now := time.Now() + ac.CreatedAt = now + ac.UpdatedAt = now + + if ac.Name == "" { + return srvdef.New(srvdef.ErrParamError, "", "") + } + if serr := srv_videoslug.EnsureCategory(ctx, ac, 0); serr != nil { + return serr + } + + serr := videocategorymod.InsertOneVideoCategory(ctx, ac) + if serr != nil { + return serr + } + 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, "", "") + } + return nil +} + +// UpdateVideoCategory 更新指定视频分类 +func UpdateVideoCategory(ctx context.Context, id int64, param VideoCategoryUpdate) *srvdef.SrvErr { + now := time.Now() + param.UpdatedAt = now + + info, serr := videocategorymod.FindOneVideoCategory(ctx, bson.M{videocategorymod.FN.VideoCategory.Id: id}) + if serr != nil { + return serr + } + name := info.Name + if param.Name != nil { + name = *param.Name + } + keywords := info.SeoInfo.Keyworld + if param.SeoInfo != nil { + keywords = param.SeoInfo.Keyworld + } + slug, changed, slugErr := srv_videoslug.PrepareCategoryOnUpdate(ctx, info, param.Slug, name, keywords) + if slugErr != nil { + return slugErr + } + if changed { + param.Slug = &slug + } else { + param.Slug = nil + } + + adFilter := bson.M{videocategorymod.FN.VideoCategory.Id: id} + 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 + } + return nil +} diff --git a/web/service/srv_videocategory/define.go b/web/service/srv_videocategory/define.go new file mode 100644 index 0000000..27c975a --- /dev/null +++ b/web/service/srv_videocategory/define.go @@ -0,0 +1,40 @@ +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"` //分类名称 +} +type VideoCategoryResp struct { + Total int64 `json:"total"` + List videocategorymod.VideoCategoryList `json:"list"` +} +type VideoCategoryUpdate 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 + Title *string `json:"title" bson:"title,omitempty"` // APP显示 + Desc *string `json:"desc" bson:"desc,omitempty"` // 描述 + SortType *int64 `json:"sortType" bson:"sortType,omitempty"` // 排序方式 0:上线时间 1:观看次数 2排序 + ContentType *int64 `json:"contentType" bson:"contentType,omitempty"` // 内容:0: 默认 1:首页 2:今日 + Rank *int64 `json:"rank" bson:"rank,omitempty"` // 排序 + SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息 + Status *bool `json:"status" bson:"status,omitempty"` // 上架状态 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` + IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow"` // 是否重新提交索引 + 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_videocreater/creater.go b/web/service/srv_videocreater/creater.go new file mode 100644 index 0000000..fe713e9 --- /dev/null +++ b/web/service/srv_videocreater/creater.go @@ -0,0 +1,128 @@ +package srv_videocreater + +import ( + "context" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/mongos" + "videoweb/web/service/srv_videoslug" + + "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{} + if param.ID > 0 { + filter[videocreatermod.FN.VideoCreaterInfo.Id] = param.ID + } + if param.Name != "" { + filter[videocreatermod.FN.VideoCreaterInfo.Name] = bson.M{ + "$regex": param.Name, + } + } + + 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() + now := time.Now() + ac.CreatedAt = now + ac.UpdatedAt = now + + if ac.Name == "" { + return srvdef.New(srvdef.ErrParamError, "", "") + } + if serr := srv_videoslug.EnsureCreater(ctx, ac, 0); serr != nil { + return serr + } + + 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 + + info, serr := videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{videocreatermod.FN.VideoCreaterInfo.Id: id}) + if serr != nil { + return serr + } + name := info.Name + if param.Name != nil { + name = *param.Name + } + keywords := info.SeoInfo.Keyworld + if param.SeoInfo != nil { + keywords = param.SeoInfo.Keyworld + } + slug, changed, slugErr := srv_videoslug.PrepareCreaterOnUpdate(ctx, info, param.Slug, name, keywords) + if slugErr != nil { + return slugErr + } + if changed { + param.Slug = &slug + } else { + param.Slug = nil + } + + adFilter := bson.M{videocreatermod.FN.VideoCreaterInfo.Id: id} + 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..af60590 --- /dev/null +++ b/web/service/srv_videocreater/define.go @@ -0,0 +1,36 @@ +package srv_videocreater + +import ( + "time" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/pubdef" +) + +type VideoCreaterReq struct { + pubdef.Page + ID int64 `json:"id"` //分类ID + Name string `json:"name"` //分类名称 +} +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"` // 是否重新提交索引 + IndexNow *int64 `bson:"indexNow,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..38dccd2 --- /dev/null +++ b/web/service/srv_videoinfo/define.go @@ -0,0 +1,113 @@ +package srv_videoinfo + +import ( + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/pubdef" +) + +type VideoInfoReq struct { + Id *int64 `json:"id"` // Id + Title *string `json:"title"` // 标题 + CreatorId *int64 `json:"creatorId"` // 作者Id + Status *videoinformationmod.InformationSatus `json:"status"` // 帖子上架状态 + CategoryId *int64 `json:"categoryId"` // 分类Id + TopicId *int64 `json:"topicId"` // 专题Id + Tag *int64 `json:"tag" ` // 标签 + IsHot *bool `json:"isHot"` // 是否热门 + MediaType videoinformationmod.MediaType `json:"mediaType"` // 资源类型 + CreatorName *string `json:"creatorName"` // 作者名称 + Keyworld *string `json:"keyworld"` // 关键字 + VideoTaskId *string `json:"videoTaskId"` // 视频任务id + VideoFileId *string `json:"videoFileId"` // 切片id(切片列表的ID) + IsTop *bool `json:"isTop" bson:"isTop"` // 是否置顶 + IsH265 *bool `json:"isH265"` // 是否支持 H265 + Sort int64 `json:"sort"` // 排序方式 0=创建时间 1=上架时间 2=更新时间 3=排序值 4=播放次数 + 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 VideoInfoUpdate struct { + Title *string `json:"title,omitempty" bson:"title,omitempty"` // 帖子标题 + Slug *string `json:"slug,omitempty" bson:"slug,omitempty"` // slug + 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"` // + 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 *videoinformationmod.InformationSatus `json:"status,omitempty" bson:"status,omitempty"` // 帖子上架状态 + Sort *int64 `json:"sort,omitempty" bson:"sort,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"` // 影片长度 + + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类ID集合,可为空;不传或空数组表示清空 + TopicIds *[]int64 `json:"topicIds,omitempty" bson:"topicIds,omitempty"` // 专题ID + Tags *[]int64 `json:"tags,omitempty" bson:"tags,omitempty"` // 标签 + IsHot *bool `json:"isHot,omitempty" bson:"isHot,omitempty"` // 是否热门 + 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"` + Content *string `json:"content,omitempty" bson:"content,omitempty"` + 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"` // 帖子标题 + Slug string `json:"slug,omitempty" bson:"slug,omitempty"` // slug + 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"` // 标签 + CategoryIds []int64 `json:"categoryIds" bson:"categoryIds"` // 分类ID集合,可为空;不传或空数组表示清空 +} + +type SetFakeWatchTimesReq struct { + Ids []int64 `json:"ids"` // 视频ID集合,支持单个或批量 + WatchTimesMin int64 `json:"watchTimesMin"` // 假观看次数随机最小值 + WatchTimesMax int64 `json:"watchTimesMax"` // 假观看次数随机最大值 +} + +type SetFakeCountResp struct { + MatchedCount int64 `json:"matchedCount"` // 匹配到的视频数量 + ModifiedCount int64 `json:"modifiedCount"` // 实际修改的视频数量 +} + +// SetVideoCategoryReq 单/批量设置视频分类请求 +type SetVideoCategoryReq struct { + Ids []int64 `json:"ids"` // 视频ID集合,传1个表示单条设置,传多个表示批量设置 + CategoryIds []int64 `json:"categoryIds"` // 分类ID集合,可为空;不传或空数组表示清空 +} 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..b1ddaa8 --- /dev/null +++ b/web/service/srv_videoinfo/sync.go @@ -0,0 +1,232 @@ +package srv_videoinfo + +import ( + "context" + "strings" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/playletchaptermod" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/mongos" + "videoweb/pub/storage/proxy" + "videoweb/web/service/srv_videoslug" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +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 + } + filter := bson.M{ + "$or": []bson.M{ + {videoinformationmod.FN.VideoInformation.Video.FileId: media.Id.Hex()}, + {videoinformationmod.FN.VideoInformation.SyncId: media.Id.Hex()}, + }, + } + 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 srvdef.New(srvdef.ErrNoData, "videoInfo is nil") + } + videoInfo.IndexNow = 0 + 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, + Status: true, + Sort: 0, + UpdatedAt: time.Now(), + CreatedAt: time.Now(), + } + if slugErr := srv_videoslug.EnsureCreater(ctx, creater, 0); slugErr != nil { + return slugErr + } + serr = videocreatermod.InsertOneVideoCreaterInfo(ctx, creater) + if serr != nil { + log.Error("VideoSync.InsertOneVideoCreaterInfo", log.Any("media", media), log.E(serr)) + return serr + } + } + videoInfo.CreatorId = creater.Id + videoInfo.CreatorName = creater.Name + } + if slugErr := srv_videoslug.EnsureVideo(ctx, videoInfo, 0); slugErr != nil { + return slugErr + } + 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 { + // 更新节点里面图片水印 以本地视频列表的节点为主 + for _, node := range videoInfo.Nodes { + isFind := false + if len(node.NodeId) > 0 { + for _, v := range media.Nodes { + if node.NodeId == v.NodeId { + isFind = true + node.Images = v.Images + nodes = append(nodes, node) + break + } + } + } + if !isFind { + 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.UnencryptedImage: media.UnencryptedImage, + } + if len(media.FieldNameFs) > 0 { + video := videoinformationmod.VideoInfo{ + TaskId: media.TaskID, + FileId: media.Id.Hex(), + Url: media.FieldNameFs, + Cover: media.CoverImg, + Height: media.Height, + Width: media.Width, + VideoTime: media.VideoTime, + UnencryptedPreMp4: media.UnencryptedPreMp4, + PreMp4FileNameFs: media.PreMp4FileNameFs, + } + 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 + setDoc[videoinformationmod.FN.VideoInformation.Video.UnencryptedPreMp4] = video.UnencryptedPreMp4 + } + if strings.TrimSpace(videoInfo.Slug) == "" { + if slugErr := srv_videoslug.EnsureVideo(ctx, videoInfo, videoInfo.Id); slugErr == nil && strings.TrimSpace(videoInfo.Slug) != "" { + setDoc[videoinformationmod.FN.VideoInformation.Slug] = videoInfo.Slug + } + } + serr = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: videoInfo.Id}, bson.M{"$set": setDoc}) + if serr != nil { + return serr + } + } + _ = syncNodeVideo(ctx, media) + return nil +} + +// SyncUnencryptedOne 仅补齐单条资源的 UnencryptedImage / UnencryptedPreMp4 字段。 +// 源数据对应字段为空时跳过该字段(避免覆盖本地已有值);本地无对应资源时直接返回。 +// 幂等:可重复执行,用于历史数据补齐。 +func SyncUnencryptedOne(ctx context.Context, media proxy.MediaResp) *srvdef.SrvErr { + if media.UnencryptedImage == "" && media.UnencryptedPreMp4 == "" { + return nil + } + filter := bson.M{videoinformationmod.FN.VideoInformation.Video.FileId: media.Id.Hex()} + videoInfo, serr := videoinformationmod.FindOneVideoInformation(ctx, filter) + if serr != nil { + return serr + } + setDoc := bson.M{} + if media.UnencryptedImage != "" { + setDoc[videoinformationmod.FN.VideoInformation.UnencryptedImage] = media.UnencryptedImage + } + // 仅当源端有主视频文件时,才补 Video.UnencryptedPreMp4(避免向空的 Video 子结构写字段) + if media.UnencryptedPreMp4 != "" && len(media.FieldNameFs) > 0 { + setDoc[videoinformationmod.FN.VideoInformation.Video.UnencryptedPreMp4] = media.UnencryptedPreMp4 + } + if len(setDoc) == 0 { + return nil + } + return videoinformationmod.UpdateOneVideoInformation(ctx, + bson.M{videoinformationmod.FN.VideoInformation.Id: videoInfo.Id}, + bson.M{"$set": setDoc}) +} + +// syncNodeVideo 同步节点里的视频 +func syncNodeVideo(ctx context.Context, media proxy.MediaResp) *srvdef.SrvErr { + filter := bson.M{"nodes.nodeId": media.Id.Hex()} + 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 { + // 更新视频路径 + 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 == media.Id.Hex() { + node.Videos[j].Url = media.FieldNameFs + isFind = true + break + } + } + } + if isFind { + video.Nodes[i] = node + break + } + } + if isFind { + serr = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: video.Id}, bson.M{"$set": bson.M{ + videoinformationmod.FN.VideoInformation.UpdatedAt: time.Now(), + videoinformationmod.FN.VideoInformation.Nodes: video.Nodes, + }}) + if serr != nil { + log.Warn("syncNodeVideo UpdateOneVideoInformation failed", log.Any("filter", filter), log.E(serr)) + continue + } + } + } + } + } + return nil +} diff --git a/web/service/srv_videoinfo/videoinfo.go b/web/service/srv_videoinfo/videoinfo.go new file mode 100644 index 0000000..8e70507 --- /dev/null +++ b/web/service/srv_videoinfo/videoinfo.go @@ -0,0 +1,450 @@ +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/srvdef" + "videoweb/pub/common/useragent" + "videoweb/pub/common/utils" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/mongos" + "videoweb/pub/seo/sitemapdirty" + "videoweb/pub/storage" + "videoweb/pub/storage/proxy" + "videoweb/web/service/srv_report" + "videoweb/web/service/srv_search" + "videoweb/web/service/srv_seopush" + "videoweb/web/service/srv_videoslug" + + "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" +) + +// GetVideoInfos 按照参数搜索APP +func GetVideoInfos(ctx context.Context, param *VideoInfoReq) (*VideoInfoResp, *srvdef.SrvErr) { + filter := bson.M{} + 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.CreatorId != nil && *param.CreatorId > 0 { + filter[videoinformationmod.FN.VideoInformation.CreatorId] = *param.CreatorId + } + if param.TopicId != nil && *param.TopicId > 0 { + filter[videoinformationmod.FN.VideoInformation.TopicIds] = bson.M{"$in": []int64{*param.TopicId}} + } + if param.Status != nil { + filter[videoinformationmod.FN.VideoInformation.Status] = *param.Status + } + if param.CategoryId != nil && *param.CategoryId > 0 { + filter[videoinformationmod.FN.VideoInformation.CategoryIds] = bson.M{"$in": []int64{*param.CategoryId}} + } + 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.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.IsTop != nil { + filter[videoinformationmod.FN.VideoInformation.IsTop] = *param.IsTop + } + if param.IsH265 != nil { + if *param.IsH265 { + filter[videoinformationmod.FN.VideoInformation.HevcStatus] = videoinformationmod.HevcStatusSuccess + } else { + filter[videoinformationmod.FN.VideoInformation.HevcStatus] = bson.M{"$in": []videoinformationmod.HevcStatus{ + videoinformationmod.HevcStatusNone, + videoinformationmod.HevcStatusPending, + videoinformationmod.HevcStatusFailed, + }} + } + } + 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 + } + ads, serr := videoinformationmod.FindManyVideoInformations(ctx, filter, opt) + if serr != nil { + return resp, serr + } + resp.List = ads + + return resp, nil +} + +// 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 { + serr := SyncOneVideo(ctx, media) + if serr != nil { + log.Warn("SyncVideoInfo SYncOneVideo failed", log.Any("filter", req.FileIds), log.E(serr)) + continue + } + } + return nil +} + +// DelVideoInfo 删除指定APP +func DelVideoInfo(ctx context.Context, id int64) *srvdef.SrvErr { + adFilter := bson.M{videoinformationmod.FN.VideoInformation.Id: id} + info, _ := videoinformationmod.FindOneVideoInformation(ctx, adFilter) + //删除 + count, serr := videoinformationmod.DelOneVideoInformation(ctx, adFilter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + // 同步删除 ES 中的数据,避免搜索到已删除的视频 + go srv_search.DelVideoFromES(id) + sitemapdirty.Mark(ctx) + if info != nil && info.Status == videoinformationmod.InformationSatusVerifyOK { + srv_seopush.EnqueueVideoDeleted(ctx, id) + } + return nil +} + +// 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) +} + +// setVideoFakeField 使用BulkWrite批量设置假流量字段,避免批量请求逐条访问数据库 +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, "", "") + } + return &SetFakeCountResp{MatchedCount: result.MatchedCount, ModifiedCount: result.ModifiedCount}, nil +} + +// normalizeFakeFlowIds 校验并去重视频ID,减少无效批量写入 +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 +} + +func randomFakeFlowValue(r *rand.Rand, min, max int64) int64 { + if min == max { + return min + } + return min + r.Int63n(max-min+1) +} + +// UpdateVideoInfo 更新指定APP +func UpdateVideoInfo(ctx context.Context, id, userId int64, param VideoInfoUpdate, ua useragent.UA) *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.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 + } + title := info.Title + if param.Title != nil { + title = *param.Title + } + keyworld := info.Keyworld + if param.Keyworld != nil { + keyworld = *param.Keyworld + } + slug, changed, slugErr := srv_videoslug.PrepareVideoOnUpdate(ctx, info, param.Slug, title, keyworld) + if slugErr != nil { + return slugErr + } + if changed { + param.Slug = &slug + } else { + param.Slug = nil + } + + param.CategoryIds = dedupePositiveInt64Ids(param.CategoryIds) + 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 { + // if *param.Status == videoinformationmod.InformationSatusVerifyOK { + // param.AddedTime = &now + // } + //} + adFilter := bson.M{videoinformationmod.FN.VideoInformation.Id: id} + param.IndexNow = nil + if (param.Status != nil && *param.Status == videoinformationmod.InformationSatusVerifyOK) || + (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() + // status 发生变更时实时同步 ES,避免搜索结果与 MongoDB 不一致 + if param.Status != nil { + newStatus := *param.Status + go func() { + if newStatus != videoinformationmod.InformationSatusVerifyOK { + // 下架/重置状态:立即从 ES 删除 + srv_search.DelVideoFromES(id) + } else { + // 上架:从 MongoDB 读取最新完整数据后写入 ES + latest, serr := videoinformationmod.FindOneVideoInformation(context.Background(), adFilter) + if serr != nil { + log.Warn("UpdateVideoInfo: read latest for ES upsert failed", log.Any("id", id), log.E(serr)) + return + } + srv_search.UpsertVideoToES(latest) + } + }() + } + 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, ua) + }) + } + } + sitemapdirty.Mark(ctx) + if param.Status != nil { + if *param.Status == videoinformationmod.InformationSatusVerifyOK && + info.Status != videoinformationmod.InformationSatusVerifyOK { + srv_seopush.EnqueueVideoUpdated(ctx, id, *param.Status) + } else if info.Status == videoinformationmod.InformationSatusVerifyOK && + *param.Status != videoinformationmod.InformationSatusVerifyOK { + srv_seopush.EnqueueVideoDeleted(ctx, id) + } + } + return nil +} + +func AddVideoCollection(ctx context.Context, param *AddVideoCollectionReq) *srvdef.SrvErr { + 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: videoinformationmod.InformationSatusWaiting, + Video: videoinformationmod.VideoInfo{}, + CategoryIds: dedupePositiveInt64Ids(param.CategoryIds), + Tags: param.Tags, + Real: videoinformationmod.Flow{}, + VideoCount: 0, + IndexNow: 0, + UpdatedAt: time.Now(), + CreatedAt: time.Now(), + Slug: param.Slug, + } + if serr := srv_videoslug.EnsureVideo(ctx, &info, 0); serr != nil { + return serr + } + + if serr := videoinformationmod.InsertOneVideoInformation(ctx, &info); serr != nil { + return serr + } + playletcache.ClearHomeCache() + sitemapdirty.Mark(ctx) + return nil +} + +// SetVideoCategory 单/批量设置视频分类 +func SetVideoCategory(ctx context.Context, req *SetVideoCategoryReq) *srvdef.SrvErr { + if req == nil { + return srvdef.New(srvdef.ErrParamError, "参数不能为空", "") + } + ids, err := normalizeFakeFlowIds(req.Ids) + if err != nil { + return err + } + if err := videoinformationmod.UpdateManyVideoInformation(ctx, + bson.M{videoinformationmod.FN.VideoInformation.Id: bson.M{"$in": ids}}, + bson.M{"$set": bson.M{ + videoinformationmod.FN.VideoInformation.CategoryIds: dedupePositiveInt64Ids(req.CategoryIds), + videoinformationmod.FN.VideoInformation.UpdatedAt: time.Now(), + }}, + ); err != nil { + return err + } + playletcache.ClearHomeCache() + return nil +} + +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 +} diff --git a/web/service/srv_videoslug/slug.go b/web/service/srv_videoslug/slug.go new file mode 100644 index 0000000..bdda882 --- /dev/null +++ b/web/service/srv_videoslug/slug.go @@ -0,0 +1,344 @@ +package srv_videoslug + +import ( + "context" + "strings" + "videoweb/pub/common/slugx" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/videocategorymod" + "videoweb/pub/models/videocreatermod" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/models/videotagmod" + "videoweb/pub/models/videotopicmod" + "videoweb/web/api/proto" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + syncSlugDefaultSize = int64(500) + syncSlugMaxSize = int64(2000) +) + +func videoExists(ctx context.Context, slug string, excludeId int64) bool { + item, _ := videoinformationmod.FindOneVideoInformation(ctx, slugx.Filter( + videoinformationmod.FN.VideoInformation.Slug, videoinformationmod.FN.VideoInformation.Id, slug, excludeId)) + return item != nil +} + +func tagExists(ctx context.Context, slug string, excludeId int64) bool { + item, _ := videotagmod.FindOneVideoTagInfo(ctx, slugx.Filter( + videotagmod.FN.VideoTagInfo.Slug, videotagmod.FN.VideoTagInfo.Id, slug, excludeId)) + return item != nil +} + +func topicExists(ctx context.Context, slug string, excludeId int64) bool { + item, _ := videotopicmod.FindOneVideoTopicModel(ctx, slugx.Filter( + videotopicmod.FN.VideoTopicModel.Slug, videotopicmod.FN.VideoTopicModel.Id, slug, excludeId)) + return item != nil +} + +func createrExists(ctx context.Context, slug string, excludeId int64) bool { + item, _ := videocreatermod.FindOneVideoCreaterInfo(ctx, slugx.Filter( + videocreatermod.FN.VideoCreaterInfo.Slug, videocreatermod.FN.VideoCreaterInfo.Id, slug, excludeId)) + return item != nil +} + +func categoryExists(ctx context.Context, slug string, excludeId int64) bool { + item, _ := videocategorymod.FindOneVideoCategory(ctx, slugx.Filter( + videocategorymod.FN.VideoCategory.Slug, videocategorymod.FN.VideoCategory.Id, slug, excludeId)) + return item != nil +} + +func EnsureVideo(ctx context.Context, info *videoinformationmod.VideoInformation, excludeId int64) *srvdef.SrvErr { + if info == nil { + return nil + } + return slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.Keyworld, info.Title), + func(s string) bool { return videoExists(ctx, s, excludeId) }) +} + +func PrepareVideoOnUpdate(ctx context.Context, info *videoinformationmod.VideoInformation, reqSlug *string, title string, keyworld []string) (string, bool, *srvdef.SrvErr) { + if info == nil { + return "", false, nil + } + return slugx.PrepareOnUpdate(info.Slug, reqSlug, utils.SlugSourceFromKeywordsOrTitle(keyworld, title), + func(s string) bool { return videoExists(ctx, s, info.Id) }) +} + +func EnsureTag(ctx context.Context, info *videotagmod.VideoTagInfo, excludeId int64) *srvdef.SrvErr { + if info == nil { + return nil + } + return slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.SeoInfo.Keyworld, info.Title), + func(s string) bool { return tagExists(ctx, s, excludeId) }) +} + +func PrepareTagOnUpdate(ctx context.Context, info *videotagmod.VideoTagInfo, reqSlug *string, title string, keywords []string) (string, bool, *srvdef.SrvErr) { + if info == nil { + return "", false, nil + } + return slugx.PrepareOnUpdate(info.Slug, reqSlug, utils.SlugSourceFromKeywordsOrTitle(keywords, title), + func(s string) bool { return tagExists(ctx, s, info.Id) }) +} + +func EnsureTopic(ctx context.Context, info *videotopicmod.VideoTopicModel, excludeId int64) *srvdef.SrvErr { + if info == nil { + return nil + } + return slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.SeoInfo.Keyworld, info.Name), + func(s string) bool { return topicExists(ctx, s, excludeId) }) +} + +func PrepareTopicOnUpdate(ctx context.Context, info *videotopicmod.VideoTopicModel, reqSlug *string, name string, keywords []string) (string, bool, *srvdef.SrvErr) { + if info == nil { + return "", false, nil + } + return slugx.PrepareOnUpdate(info.Slug, reqSlug, utils.SlugSourceFromKeywordsOrTitle(keywords, name), + func(s string) bool { return topicExists(ctx, s, info.Id) }) +} + +func EnsureCreater(ctx context.Context, info *videocreatermod.VideoCreaterInfo, excludeId int64) *srvdef.SrvErr { + if info == nil { + return nil + } + return slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.SeoInfo.Keyworld, info.Name), + func(s string) bool { return createrExists(ctx, s, excludeId) }) +} + +func PrepareCreaterOnUpdate(ctx context.Context, info *videocreatermod.VideoCreaterInfo, reqSlug *string, name string, keywords []string) (string, bool, *srvdef.SrvErr) { + if info == nil { + return "", false, nil + } + return slugx.PrepareOnUpdate(info.Slug, reqSlug, utils.SlugSourceFromKeywordsOrTitle(keywords, name), + func(s string) bool { return createrExists(ctx, s, info.Id) }) +} + +func EnsureCategory(ctx context.Context, info *videocategorymod.VideoCategory, excludeId int64) *srvdef.SrvErr { + if info == nil { + return nil + } + return slugx.Ensure(&info.Slug, utils.SlugSourceFromKeywordsOrTitle(info.SeoInfo.Keyworld, info.Name), + func(s string) bool { return categoryExists(ctx, s, excludeId) }) +} + +func PrepareCategoryOnUpdate(ctx context.Context, info *videocategorymod.VideoCategory, reqSlug *string, name string, keywords []string) (string, bool, *srvdef.SrvErr) { + if info == nil { + return "", false, nil + } + return slugx.PrepareOnUpdate(info.Slug, reqSlug, utils.SlugSourceFromKeywordsOrTitle(keywords, name), + func(s string) bool { return categoryExists(ctx, s, info.Id) }) +} + +// SyncEmptySlugs 为视频、标签、题材、作者、分类中空 slug 自动生成。 +func SyncEmptySlugs(ctx context.Context, req *proto.VideoSlugSyncReq) (*proto.VideoSlugSyncResp, *srvdef.SrvErr) { + maxSize := syncSlugDefaultSize + if req != nil && req.MaxSize > 0 { + maxSize = req.MaxSize + } + if maxSize > syncSlugMaxSize { + maxSize = syncSlugMaxSize + } + resp := &proto.VideoSlugSyncResp{} + remain := maxSize + + categoryUpdated, categoryMore, err := syncCategoryEmptySlugs(ctx, remain) + if err != nil { + return nil, err + } + resp.CategoryUpdated = categoryUpdated + remain -= categoryUpdated + if remain <= 0 { + resp.HasMore = categoryMore || hasEmpty(ctx, "tag") || hasEmpty(ctx, "topic") || hasEmpty(ctx, "creater") || hasEmpty(ctx, "video") + return resp, nil + } + + tagUpdated, tagMore, err := syncTagEmptySlugs(ctx, remain) + if err != nil { + return nil, err + } + resp.TagUpdated = tagUpdated + remain -= tagUpdated + if remain <= 0 { + resp.HasMore = tagMore || hasEmpty(ctx, "topic") || hasEmpty(ctx, "creater") || hasEmpty(ctx, "video") + return resp, nil + } + + topicUpdated, topicMore, err := syncTopicEmptySlugs(ctx, remain) + if err != nil { + return nil, err + } + resp.TopicUpdated = topicUpdated + remain -= topicUpdated + if remain <= 0 { + resp.HasMore = topicMore || hasEmpty(ctx, "creater") || hasEmpty(ctx, "video") + return resp, nil + } + + createrUpdated, createrMore, err := syncCreaterEmptySlugs(ctx, remain) + if err != nil { + return nil, err + } + resp.CreaterUpdated = createrUpdated + remain -= createrUpdated + if remain <= 0 { + resp.HasMore = createrMore || hasEmpty(ctx, "video") + return resp, nil + } + + videoUpdated, videoMore, err := syncVideoEmptySlugs(ctx, remain) + if err != nil { + return nil, err + } + resp.VideoUpdated = videoUpdated + resp.HasMore = videoMore + return resp, nil +} + +func hasEmpty(ctx context.Context, kind string) bool { + filter := slugx.EmptyFilter() + switch kind { + case "tag": + n, _ := videotagmod.CountVideoTagInfos(ctx, filter) + return n > 0 + case "topic": + n, _ := videotopicmod.CountVideoTopicModels(ctx, filter) + return n > 0 + case "creater": + n, _ := videocreatermod.CountVideoCreaterInfos(ctx, filter) + return n > 0 + case "video": + n, _ := videoinformationmod.CountVideoInformations(ctx, filter) + return n > 0 + default: + n, _ := videocategorymod.CountVideoCategorys(ctx, filter) + return n > 0 + } +} + +func syncCategoryEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := videocategorymod.FindManyVideoCategorys(ctx, slugx.EmptyFilter(), options.Find().SetLimit(maxSize+1)) + if err != nil { + return 0, false, err + } + more := int64(len(list)) > maxSize + if more { + list = list[:maxSize] + } + var updated int64 + for _, item := range list { + if item == nil { + continue + } + if serr := EnsureCategory(ctx, item, item.Id); serr != nil || strings.TrimSpace(item.Slug) == "" { + continue + } + _ = videocategorymod.UpdateOneVideoCategory(ctx, bson.M{videocategorymod.FN.VideoCategory.Id: item.Id}, bson.M{ + "$set": bson.M{videocategorymod.FN.VideoCategory.Slug: item.Slug}, + }) + updated++ + } + return updated, more, nil +} + +func syncTagEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := videotagmod.FindManyVideoTagInfos(ctx, slugx.EmptyFilter(), options.Find().SetLimit(maxSize+1)) + if err != nil { + return 0, false, err + } + more := int64(len(list)) > maxSize + if more { + list = list[:maxSize] + } + var updated int64 + for _, item := range list { + if item == nil { + continue + } + if serr := EnsureTag(ctx, item, item.Id); serr != nil || strings.TrimSpace(item.Slug) == "" { + continue + } + _ = videotagmod.UpdateOneVideoTagInfo(ctx, bson.M{videotagmod.FN.VideoTagInfo.Id: item.Id}, bson.M{ + "$set": bson.M{videotagmod.FN.VideoTagInfo.Slug: item.Slug}, + }) + updated++ + } + return updated, more, nil +} + +func syncTopicEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := videotopicmod.FindManyVideoTopicModels(ctx, slugx.EmptyFilter(), options.Find().SetLimit(maxSize+1)) + if err != nil { + return 0, false, err + } + more := int64(len(list)) > maxSize + if more { + list = list[:maxSize] + } + var updated int64 + for _, item := range list { + if item == nil { + continue + } + if serr := EnsureTopic(ctx, item, item.Id); serr != nil || strings.TrimSpace(item.Slug) == "" { + continue + } + _ = videotopicmod.UpdateOneVideoTopicModel(ctx, bson.M{videotopicmod.FN.VideoTopicModel.Id: item.Id}, bson.M{ + "$set": bson.M{videotopicmod.FN.VideoTopicModel.Slug: item.Slug}, + }) + updated++ + } + return updated, more, nil +} + +func syncCreaterEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := videocreatermod.FindManyVideoCreaterInfos(ctx, slugx.EmptyFilter(), options.Find().SetLimit(maxSize+1)) + if err != nil { + return 0, false, err + } + more := int64(len(list)) > maxSize + if more { + list = list[:maxSize] + } + var updated int64 + for _, item := range list { + if item == nil { + continue + } + if serr := EnsureCreater(ctx, item, item.Id); serr != nil || strings.TrimSpace(item.Slug) == "" { + continue + } + _ = videocreatermod.UpdateOneVideoCreaterInfo(ctx, bson.M{videocreatermod.FN.VideoCreaterInfo.Id: item.Id}, bson.M{ + "$set": bson.M{videocreatermod.FN.VideoCreaterInfo.Slug: item.Slug}, + }) + updated++ + } + return updated, more, nil +} + +func syncVideoEmptySlugs(ctx context.Context, maxSize int64) (int64, bool, *srvdef.SrvErr) { + list, err := videoinformationmod.FindManyVideoInformations(ctx, slugx.EmptyFilter(), options.Find().SetLimit(maxSize+1)) + if err != nil { + return 0, false, err + } + more := int64(len(list)) > maxSize + if more { + list = list[:maxSize] + } + var updated int64 + for _, item := range list { + if item == nil { + continue + } + if serr := EnsureVideo(ctx, item, item.Id); serr != nil || strings.TrimSpace(item.Slug) == "" { + continue + } + _ = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: item.Id}, bson.M{ + "$set": bson.M{videoinformationmod.FN.VideoInformation.Slug: item.Slug}, + }) + updated++ + } + return updated, more, nil +} diff --git a/web/service/srv_videotag/define.go b/web/service/srv_videotag/define.go new file mode 100644 index 0000000..1d76faa --- /dev/null +++ b/web/service/srv_videotag/define.go @@ -0,0 +1,36 @@ +package srv_videotag + +import ( + "time" + "videoweb/pub/models/videotagmod" + "videoweb/pub/pubdef" +) + +type VideoTagInfoReq struct { + pubdef.Page + Title *string `json:"title"` + Id *int64 `json:"id"` +} +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"` // 帖子标题 + 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"` // 是否重新提交索引 + 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..ef24648 --- /dev/null +++ b/web/service/srv_videotag/hottag.go @@ -0,0 +1,97 @@ +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} 索引) +// watchTimes 由 app/service/videoser.VideoDetails 在用户进入视频详情页时对关联标签各 $inc: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 IpStatResp struct { + TodayIP int64 `json:"todayIP"` + YesterdayIP int64 `json:"yesterdayIP"` +} + +func FetchIpStat(ctx context.Context) *IpStatResp { + resp := &IpStatResp{} + now := time.Now().In(utils.LocBJ) + today := now.Format("20060102") + yesterday := now.AddDate(0, 0, -1).Format("20060102") + + if cnt, err := ipdailystatmod.FindCount(ctx, pubdef.AppId, today); err == nil { + resp.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 { + resp.YesterdayIP = cnt + } else { + log.Warn("FetchIpStat: FindCount yesterday failed", + log.Any("appId", pubdef.AppId), log.E(err)) + } + return resp +} diff --git a/web/service/srv_videotag/taginfo.go b/web/service/srv_videotag/taginfo.go new file mode 100644 index 0000000..3bef993 --- /dev/null +++ b/web/service/srv_videotag/taginfo.go @@ -0,0 +1,123 @@ +package srv_videotag + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videotagmod" + "videoweb/pub/mongos" + "videoweb/web/service/srv_videoslug" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetVideoTagInfos 按照参数搜索视频标签 +func GetVideoTagInfos(ctx context.Context, param *VideoTagInfoReq) (*VideoTagInfoResp, *srvdef.SrvErr) { + filter := bson.M{} + opt := &options.FindOptions{} + resp := &VideoTagInfoResp{} + if param.Id != nil && *param.Id > 0 { + filter[videotagmod.FN.VideoTagInfo.Id] = *param.Id + } + if param.Title != nil && *param.Title != "" { + filter[videotagmod.FN.VideoTagInfo.Title] = bson.M{"$regex": *param.Title} + } + + opt.SetSort(bson.M{videotagmod.FN.VideoTagInfo.Id: 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 + if ac.Title == "" { + return srvdef.New(srvdef.ErrParamError, "", "") + } + if serr := srv_videoslug.EnsureTag(ctx, ac, 0); serr != nil { + return serr + } + + 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 + + info, serr := videotagmod.FindOneVideoTagInfo(ctx, bson.M{videotagmod.FN.VideoTagInfo.Id: id}) + if serr != nil { + return serr + } + title := info.Title + if param.Title != nil { + title = *param.Title + } + keywords := info.SeoInfo.Keyworld + if param.SeoInfo != nil { + keywords = param.SeoInfo.Keyworld + } + slug, changed, slugErr := srv_videoslug.PrepareTagOnUpdate(ctx, info, param.Slug, title, keywords) + if slugErr != nil { + return slugErr + } + if changed { + param.Slug = &slug + } else { + param.Slug = nil + } + + 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/service/srv_videotopic/define.go b/web/service/srv_videotopic/define.go new file mode 100644 index 0000000..4c7c167 --- /dev/null +++ b/web/service/srv_videotopic/define.go @@ -0,0 +1,40 @@ +package srv_videotopic + +import ( + "time" + "videoweb/pub/models/videoinformationmod" + "videoweb/pub/models/videotopicmod" + "videoweb/pub/pubdef" +) + +type VideoTopicReq struct { + VideoCategoryId *int64 `json:"videoCategoryId" bson:"videoCategoryId"` + pubdef.Page +} +type VideoTopicResp struct { + Total int64 `json:"total"` + List videotopicmod.VideoTopicModelList `json:"list"` +} +type VideoTopicUpdate 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"` // 描述 + Rank *int64 `json:"rank" bson:"rank,omitempty"` // + SeoInfo *pubdef.SeoValue `json:"seoInfo" bson:"seoInfo,omitempty"` // Seo相关信息 + Status *bool `json:"status" bson:"status,omitempty"` // 上架状态 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt,omitempty"` + IsReIndexNow *bool `json:"isReIndexNow" bson:"isReIndexNow,omitempty"` // 是否重新提交索引 + IndexNow *int64 `bson:"indexNow,omitempty"` + Image *string `json:"image" bson:"image,omitempty"` // 背景 + VideoCategoryId *int64 `json:"videoCategoryId" bson:"videoCategoryId,omitempty"` // 视频分类id + VideoTopicType *videoinformationmod.MediaType `json:"videoTopicType" bson:"videoTopicType"` // 视频类型 +} +type VideoTopicUpdateReq struct { + Id int64 `json:"id" binding:"required"` + Param VideoTopicUpdate `json:"param" bson:"param"` // appId,唯一id +} + +type VideoTopicDelReq struct { + Id int64 `json:"id" binding:"required"` +} diff --git a/web/service/srv_videotopic/topic.go b/web/service/srv_videotopic/topic.go new file mode 100644 index 0000000..319abac --- /dev/null +++ b/web/service/srv_videotopic/topic.go @@ -0,0 +1,116 @@ +package srv_videotopic + +import ( + "context" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/videotopicmod" + "videoweb/pub/mongos" + "videoweb/web/service/srv_videoslug" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetVideoTopics 按照参数搜索视频分类 +func GetVideoTopics(ctx context.Context, param *VideoTopicReq) (*VideoTopicResp, *srvdef.SrvErr) { + filter := bson.M{} + if param.VideoCategoryId != nil { + filter[videotopicmod.FN.VideoTopicModel.VideoCategoryId] = *param.VideoCategoryId + } + opt := &options.FindOptions{} + resp := &VideoTopicResp{} + + opt.SetSort(bson.M{videotopicmod.FN.VideoTopicModel.Rank: -1}) + opt.SetSkip((param.PageNum - 1) * param.PageSize).SetLimit(param.PageSize) + + total, serr := videotopicmod.CountVideoTopicModels(ctx, filter) + if serr != nil { + return resp, serr + } + resp.Total = total + if total == 0 { + return resp, nil + } + ads, serr := videotopicmod.FindManyVideoTopicModels(ctx, filter, opt) + if serr != nil { + return resp, serr + } + resp.List = ads + + return resp, nil +} + +// AddVideoTopic 添加一个视频分类 +func AddVideoTopic(ctx context.Context, + ac *videotopicmod.VideoTopicModel) *srvdef.SrvErr { + + ac.Id = mongos.Lid.VideoTopic.Get() + now := time.Now() + ac.CreatedAt = now + ac.UpdatedAt = now + if serr := srv_videoslug.EnsureTopic(ctx, ac, 0); serr != nil { + return serr + } + + serr := videotopicmod.InsertOneVideoTopicModel(ctx, ac) + if serr != nil { + return serr + } + return nil +} + +// DelVideoTopic 删除指定视频分类 +func DelVideoTopic(ctx context.Context, id int64) *srvdef.SrvErr { + adFilter := bson.M{videotopicmod.FN.VideoTopicModel.Id: id} + //删除 + count, serr := videotopicmod.DelOneVideoTopicModel(ctx, adFilter) + if serr != nil { + return serr + } + if count == 0 { + return srvdef.New(srvdef.ErrNoData, "", "") + } + return nil +} + +// UpdateVideoTopic 更新指定视频分类 +func UpdateVideoTopic(ctx context.Context, id int64, param VideoTopicUpdate) *srvdef.SrvErr { + now := time.Now() + param.UpdatedAt = now + + info, serr := videotopicmod.FindOneVideoTopicModel(ctx, bson.M{videotopicmod.FN.VideoTopicModel.Id: id}) + if serr != nil { + return serr + } + name := info.Name + if param.Name != nil { + name = *param.Name + } + keywords := info.SeoInfo.Keyworld + if param.SeoInfo != nil { + keywords = param.SeoInfo.Keyworld + } + slug, changed, slugErr := srv_videoslug.PrepareTopicOnUpdate(ctx, info, param.Slug, name, keywords) + if slugErr != nil { + return slugErr + } + if changed { + param.Slug = &slug + } else { + param.Slug = nil + } + + adFilter := bson.M{videotopicmod.FN.VideoTopicModel.Id: id} + param.IndexNow = nil + if param.IsReIndexNow != nil && *param.IsReIndexNow { + indexNow := int64(0) + param.IndexNow = &indexNow + } + updateDoc := bson.M{"$set": param} + serr = videotopicmod.UpdateOneVideoTopicModel(ctx, adFilter, updateDoc) + if serr != nil { + return serr + } + return nil +} diff --git a/web/service/srv_viporder/define.go b/web/service/srv_viporder/define.go new file mode 100644 index 0000000..1347782 --- /dev/null +++ b/web/service/srv_viporder/define.go @@ -0,0 +1,44 @@ +package srv_viporder + +import ( + "time" + "videoweb/pub/models/vipordermod" + "videoweb/pub/pubdef" +) + +type VipOrderReq struct { + pubdef.Page + UserId *int64 `json:"userId"` // 用户ID + OrderType *vipordermod.OrderType `json:"orderType"` // 订单类型:1会员 2金币 + Status *pubdef.VipOrderStatus `json:"status"` // 订单状态 + TradeNo *string `json:"tradeNo"` // 订单号 + DistrictCode string `json:"districtCode"` // 渠道码 + StartTime *string `json:"startTime"` // 创建时间-开始 + EndTime *string `json:"endTime"` // 创建时间-结束 +} + +type VipOrderResp struct { + Total int64 `json:"total"` + List vipordermod.VipOrderList `json:"list"` +} + +type SyncDistrictCodeReq struct { + MaxSize int64 `json:"maxSize"` // 本次最多扫描的空渠道码订单数,默认500,最大2000 +} + +type SyncDistrictCodeResp struct { + Scanned int64 `json:"scanned"` // 本次扫描的空渠道码订单数 + OrderUpdated int64 `json:"orderUpdated"` // 成功回填的订单数 + UserUpdated int64 `json:"userUpdated"` // 成功回填的用户数 + UserSkipped int64 `json:"userSkipped"` // 用户本身没有渠道码 + HasMore bool `json:"hasMore"` // 是否还有未处理的历史订单 +} + +type timeLayout = string + +const layout timeLayout = "2006-01-02 15:04:05" + +func parseTime(s string) (time.Time, bool) { + t, err := time.ParseInLocation(layout, s, time.Local) + return t, err == nil +} diff --git a/web/service/srv_viporder/order.go b/web/service/srv_viporder/order.go new file mode 100644 index 0000000..69ded25 --- /dev/null +++ b/web/service/srv_viporder/order.go @@ -0,0 +1,234 @@ +package srv_viporder + +import ( + "context" + "strings" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/models/usermod" + "videoweb/pub/models/vipordermod" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// GetVipOrders 分页查询会员购买记录(固定 OrderType=会员) +func GetVipOrders(ctx context.Context, req *VipOrderReq) (*VipOrderResp, *srvdef.SrvErr) { + orderType := vipordermod.OrderTypeVip + reqCopy := *req + reqCopy.OrderType = &orderType + return GetOrders(ctx, &reqCopy) +} + +// GetOrders 分页查询充值订单,支持按 OrderType 筛选(不传则查全部类型) +func GetOrders(ctx context.Context, req *VipOrderReq) (*VipOrderResp, *srvdef.SrvErr) { + filter := bson.M{} + if req.OrderType != nil { + filter[vipordermod.FN.VipOrder.OrderType] = *req.OrderType + } + if req.UserId != nil && *req.UserId > 0 { + filter[vipordermod.FN.VipOrder.UserId] = *req.UserId + } + if req.Status != nil { + filter[vipordermod.FN.VipOrder.Status] = *req.Status + } + if req.TradeNo != nil && *req.TradeNo != "" { + filter[vipordermod.FN.VipOrder.TradeNo] = *req.TradeNo + } + if districtCode := strings.TrimSpace(req.DistrictCode); districtCode != "" { + filter[vipordermod.FN.VipOrder.DistrictCode] = districtCode + } + + timeFilter := bson.M{} + if req.StartTime != nil && *req.StartTime != "" { + if t, ok := parseTime(*req.StartTime); ok { + timeFilter["$gte"] = t + } + } + if req.EndTime != nil && *req.EndTime != "" { + if t, ok := parseTime(*req.EndTime); ok { + timeFilter["$lte"] = t + } + } + if len(timeFilter) > 0 { + filter[vipordermod.FN.VipOrder.CreatedAt] = timeFilter + } + + total, serr := vipordermod.CountVipOrder(ctx, filter) + if serr != nil { + return nil, serr + } + resp := &VipOrderResp{Total: total} + if total == 0 { + resp.List = vipordermod.VipOrderList{} + return resp, nil + } + + opt := options.Find(). + SetSort(bson.D{{Key: vipordermod.FN.VipOrder.CreatedAt, Value: -1}}). + SetSkip((req.PageNum - 1) * req.PageSize). + SetLimit(req.PageSize) + + list, serr := vipordermod.FindManyVipOrder(ctx, filter, opt) + if serr != nil { + return nil, serr + } + resp.List = list + return resp, nil +} + +const ( + syncDistrictDefaultSize = int64(500) + syncDistrictMaxSize = int64(2000) + syncDistrictBatchSize = int64(200) +) + +// SyncDistrictCode 将历史订单中空的渠道码,按用户当前 districtCode 回填。 +func SyncDistrictCode(ctx context.Context, req *SyncDistrictCodeReq) (*SyncDistrictCodeResp, *srvdef.SrvErr) { + maxSize := syncDistrictDefaultSize + if req != nil && req.MaxSize > 0 { + maxSize = req.MaxSize + } + if maxSize > syncDistrictMaxSize { + maxSize = syncDistrictMaxSize + } + + resp := &SyncDistrictCodeResp{} + lastId := int64(0) + updatedUsers := make(map[int64]struct{}) + skippedUsers := make(map[int64]struct{}) + + for resp.Scanned < maxSize { + limit := syncDistrictBatchSize + if remain := maxSize - resp.Scanned; remain < limit { + limit = remain + } + filter := emptyDistrictSyncFilter(lastId, skippedUsers) + list, serr := vipordermod.FindManyVipOrder(ctx, filter, options.Find(). + SetProjection(bson.D{ + {Key: vipordermod.FN.VipOrder.Id, Value: 1}, + {Key: vipordermod.FN.VipOrder.UserId, Value: 1}, + }). + SetSort(bson.D{{Key: vipordermod.FN.VipOrder.Id, Value: 1}}). + SetLimit(limit)) + if serr != nil { + return nil, serr + } + if len(list) == 0 { + break + } + resp.Scanned += int64(len(list)) + lastId = list[len(list)-1].Id + if int64(len(list)) == limit && resp.Scanned >= maxSize { + resp.HasMore = true + } + + userIds := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, order := range list { + if order == nil || order.UserId <= 0 { + continue + } + if _, ok := seen[order.UserId]; ok { + continue + } + seen[order.UserId] = struct{}{} + userIds = append(userIds, order.UserId) + } + if len(userIds) == 0 { + continue + } + + users, serr := usermod.FindManyUser(ctx, bson.M{ + usermod.FN.User.Id: bson.M{"$in": userIds}, + }, options.Find().SetProjection(bson.D{ + {Key: usermod.FN.User.Id, Value: 1}, + {Key: usermod.FN.User.DistrictCode, Value: 1}, + })) + if serr != nil { + return nil, serr + } + userMap := make(map[int64]*usermod.User, len(users)) + for _, user := range users { + if user != nil { + userMap[user.Id] = user + } + } + + now := time.Now() + for _, userId := range userIds { + user := userMap[userId] + code := "" + if user != nil { + code = strings.TrimSpace(user.DistrictCode) + } + if code == "" { + skippedUsers[userId] = struct{}{} + continue + } + modified, serr := vipordermod.UpdateManyVipOrder(ctx, + mergeEmptyDistrictFilter(bson.M{vipordermod.FN.VipOrder.UserId: userId}), + bson.M{"$set": bson.M{ + vipordermod.FN.VipOrder.DistrictCode: code, + vipordermod.FN.VipOrder.UpdatedAt: now, + }}, + ) + if serr != nil { + return nil, serr + } + if modified > 0 { + updatedUsers[userId] = struct{}{} + resp.OrderUpdated += modified + } + } + } + + resp.UserUpdated = int64(len(updatedUsers)) + resp.UserSkipped = int64(len(skippedUsers)) + if !resp.HasMore { + remain, serr := vipordermod.FindManyVipOrder(ctx, emptyDistrictSyncFilter(lastId, skippedUsers), + options.Find().SetProjection(bson.D{{Key: vipordermod.FN.VipOrder.Id, Value: 1}}).SetLimit(1)) + if serr != nil { + return nil, serr + } + resp.HasMore = len(remain) > 0 + } + return resp, nil +} + +func emptyDistrictSyncFilter(lastId int64, skippedUsers map[int64]struct{}) bson.M { + conds := []bson.M{vipordermod.EmptyDistrictFilter()} + if lastId > 0 { + conds = append(conds, bson.M{vipordermod.FN.VipOrder.Id: bson.M{"$gt": lastId}}) + } + if skipIds := mapKeys(skippedUsers); len(skipIds) > 0 { + conds = append(conds, bson.M{vipordermod.FN.VipOrder.UserId: bson.M{"$nin": skipIds}}) + } + if len(conds) == 1 { + return conds[0] + } + return bson.M{"$and": conds} +} + +func mergeEmptyDistrictFilter(extra bson.M) bson.M { + filter := vipordermod.EmptyDistrictFilter() + if len(extra) == 0 { + return filter + } + conds := []bson.M{filter} + for key, value := range extra { + conds = append(conds, bson.M{key: value}) + } + return bson.M{"$and": conds} +} + +func mapKeys(m map[int64]struct{}) []int64 { + if len(m) == 0 { + return nil + } + keys := make([]int64, 0, len(m)) + for id := range m { + keys = append(keys, id) + } + return keys +} diff --git a/web/service/srv_viporder/summary.go b/web/service/srv_viporder/summary.go new file mode 100644 index 0000000..c46467e --- /dev/null +++ b/web/service/srv_viporder/summary.go @@ -0,0 +1,165 @@ +package srv_viporder + +import ( + "context" + "strings" + "time" + "videoweb/pub/common/srvdef" + "videoweb/pub/common/utils" + "videoweb/pub/models/vipordermod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" +) + +// RechargeSummaryReq 充值汇总请求,时间按北京时间解析。 +type RechargeSummaryReq struct { + StartTime string `form:"startTime" json:"startTime"` // 开始时间,北京时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339 + EndTime string `form:"endTime" json:"endTime"` // 结束时间,北京时间:2006-01-02 / 2006-01-02 15:04:05 / RFC3339 +} + +// RechargeSummaryResp 充值汇总结果,金额单位为分。 +type RechargeSummaryResp struct { + StartTime string `json:"startTime"` // 实际统计开始时间(北京时间) + EndTime string `json:"endTime"` // 实际统计结束时间(北京时间) + OrderCount int64 `json:"orderCount"` // 成功订单数 + TotalAmount int64 `json:"totalAmount"` // 成功订单金额合计(分) + VipOrderCount int64 `json:"vipOrderCount"` // 会员成功订单数 + VipAmount int64 `json:"vipAmount"` // 会员成功订单金额(分) + CoinOrderCount int64 `json:"coinOrderCount"` // 金币成功订单数 + CoinAmount int64 `json:"coinAmount"` // 金币成功订单金额(分) + UserCount int64 `json:"userCount"` // 成功充值用户数(去重) +} + +type rechargeSummaryAgg struct { + OrderCount int64 `bson:"orderCount"` + TotalAmount int64 `bson:"totalAmount"` + VipOrderCount int64 `bson:"vipOrderCount"` + VipAmount int64 `bson:"vipAmount"` + CoinOrderCount int64 `bson:"coinOrderCount"` + CoinAmount int64 `bson:"coinAmount"` + UserCount int64 `bson:"userCount"` +} + +// GetRechargeSummary 按支付完成时间汇总成功充值订单。 +func GetRechargeSummary(ctx context.Context, req *RechargeSummaryReq) (*RechargeSummaryResp, *srvdef.SrvErr) { + if req == nil { + return nil, srvdef.New(srvdef.ErrParamError, "参数不能为空") + } + start, end, err := parseBeijingTimeRange(req.StartTime, req.EndTime) + if err != nil { + return nil, err + } + resp := &RechargeSummaryResp{ + StartTime: start.In(utils.LocBJ).Format(pubdef.TimeISO), + EndTime: end.In(utils.LocBJ).Format(pubdef.TimeISO), + } + list := make([]rechargeSummaryAgg, 0) + if serr := vipordermod.AggregateVipOrder(ctx, &list, rechargeSummaryPipeline(start, end)); serr != nil { + return nil, serr + } + if len(list) == 0 { + return resp, nil + } + item := list[0] + resp.OrderCount = item.OrderCount + resp.TotalAmount = item.TotalAmount + resp.VipOrderCount = item.VipOrderCount + resp.VipAmount = item.VipAmount + resp.CoinOrderCount = item.CoinOrderCount + resp.CoinAmount = item.CoinAmount + resp.UserCount = item.UserCount + return resp, nil +} + +func rechargeSummaryPipeline(start, end time.Time) []bson.M { + vipType := bson.M{"$eq": bson.A{bson.M{"$ifNull": bson.A{"$" + vipordermod.FN.VipOrder.OrderType, vipordermod.OrderTypeVip}}, vipordermod.OrderTypeVip}} + return []bson.M{ + { + "$match": bson.M{ + vipordermod.FN.VipOrder.Status: pubdef.VipOrderSuccess, + vipordermod.FN.VipOrder.FinishedAt: bson.M{ + "$gte": start, + "$lte": end, + }, + }, + }, + { + "$group": bson.M{ + "_id": nil, + "orderCount": bson.M{"$sum": 1}, + "totalAmount": bson.M{"$sum": "$" + vipordermod.FN.VipOrder.Amount}, + "vipOrderCount": bson.M{"$sum": bson.M{"$cond": bson.A{vipType, 1, 0}}}, + "vipAmount": bson.M{"$sum": bson.M{"$cond": bson.A{ + vipType, + "$" + vipordermod.FN.VipOrder.Amount, + 0, + }}}, + "coinOrderCount": bson.M{"$sum": bson.M{"$cond": bson.A{ + bson.M{"$eq": bson.A{"$" + vipordermod.FN.VipOrder.OrderType, vipordermod.OrderTypeCoin}}, + 1, + 0, + }}}, + "coinAmount": bson.M{"$sum": bson.M{"$cond": bson.A{ + bson.M{"$eq": bson.A{"$" + vipordermod.FN.VipOrder.OrderType, vipordermod.OrderTypeCoin}}, + "$" + vipordermod.FN.VipOrder.Amount, + 0, + }}}, + "userIds": bson.M{"$addToSet": "$" + vipordermod.FN.VipOrder.UserId}, + }, + }, + { + "$project": bson.M{ + "_id": 0, + "orderCount": 1, + "totalAmount": 1, + "vipOrderCount": 1, + "vipAmount": 1, + "coinOrderCount": 1, + "coinAmount": 1, + "userCount": bson.M{"$size": "$userIds"}, + }, + }, + } +} + +func parseBeijingTimeRange(startRaw, endRaw string) (time.Time, time.Time, *srvdef.SrvErr) { + start, ok := parseBeijingTime(startRaw, true) + if !ok { + return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "开始时间无效") + } + end, ok := parseBeijingTime(endRaw, false) + if !ok { + return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "结束时间无效") + } + if end.Before(start) { + return time.Time{}, time.Time{}, srvdef.New(srvdef.ErrParamError, "结束时间不能早于开始时间") + } + return start, end, nil +} + +func parseBeijingTime(raw string, isStart bool) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + layouts := []string{ + pubdef.TimeISO, + time.RFC3339, + time.RFC3339Nano, + "2006-01-02 15:04:05 -0700 MST", + "2006-01-02 15:04:05.999999999 -0700 MST", + } + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, raw, utils.LocBJ); err == nil { + return t, true + } + } + if t, err := time.ParseInLocation(pubdef.DateISO, raw, utils.LocBJ); err == nil { + if isStart { + return utils.BeginOfTime(t), true + } + return utils.EndOfTime(t), true + } + return time.Time{}, false +} 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/llmstxt/llmstxt.go b/web/skd/job/llmstxt/llmstxt.go new file mode 100644 index 0000000..70ff3b0 --- /dev/null +++ b/web/skd/job/llmstxt/llmstxt.go @@ -0,0 +1,386 @@ +package llmstxt + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + "videoweb/pub/common/log" + "videoweb/pub/common/utils" + "videoweb/pub/pubdef" + "videoweb/web/service/srv_cfg" +) + +const ( + maxRecentToolsInBrief = 50 // llms.txt 中最新工具条数上限 + maxRecentArticlesBrief = 50 // llms.txt 中最新文章条数上限 + maxRecentInFull = 500 // llms-full.txt 中最新工具/文章条数上限 +) + +var generateMu sync.Mutex + +// GenerateLLMSTxt 生成 llms.txt(摘要)和 llms-full.txt(详细) +func GenerateLLMSTxt() { + generateMu.Lock() + defer generateMu.Unlock() + + ctx := context.Background() + config, serr := srv_cfg.GetSystemConfig(ctx) + if serr != nil || config == nil { + log.Error("GenerateLLMSTxt GetSystemConfig", log.Any("err", serr)) + return + } + + //baseURL := strings.TrimSuffix(config.WebAddr, "/") + + //categories, subCategories, cateNameMap, subCateNameMap := loadCategories(ctx) + //recentTools := loadRecentTools(ctx, maxRecentToolsInBrief) + //recentArticles := loadRecentArticles(ctx, maxRecentArticlesBrief) + // + //var brief strings.Builder + //writeBrief(&brief, config.WebTitle, config.WebDescription, baseURL, config.WebKeyworld, recentTools, recentArticles) + // + //if err := writeTextFile("llms.txt", brief.String()); err != nil { + // log.Error("GenerateLLMSTxt write llms.txt", log.Any("err", err)) + // return + //} + //if err := commitFile("llms.txt"); err != nil { + // log.Error("GenerateLLMSTxt commit llms.txt", log.Any("err", err)) + // return + //} + // + //allTools := loadRecentTools(ctx, maxRecentInFull) + //allArticles := loadRecentArticles(ctx, maxRecentInFull) + // + //var full strings.Builder + //writeFull(&full, config.WebTitle, config.WebDescription, baseURL, config.WebKeyworld, categories, subCategories, cateNameMap, subCateNameMap, allTools, allArticles) + // + //if err := writeTextFile("llms-full.txt", full.String()); err != nil { + // log.Error("GenerateLLMSTxt write llms-full.txt", log.Any("err", err)) + // return + //} + //if err := commitFile("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(categories)), + // log.Any("subCategories", len(subCategories)), + // log.Any("tools", len(allTools)), + // log.Any("articles", len(allArticles)), + //) +} + +// ── 数据加载 ────────────────────────────────────────────────── +// +//func loadCategories(ctx context.Context) ( +// categories toolcategorymod.ToolCategoryModelList, +// subCategories toolsubcategorymod.ToolSubCategoryModelList, +// cateNameMap map[int64]string, +// subCateNameMap map[int64]string, +//) { +// cateFilter := bson.M{ +// toolcategorymod.FN.ToolCategory.Status: 1, +// toolcategorymod.FN.ToolCategory.Deleted: bson.M{"$ne": 1}, +// } +// cateOpt := options.Find().SetSort(bson.D{ +// {Key: toolcategorymod.FN.ToolCategory.Sort, Value: 1}, +// {Key: toolcategorymod.FN.ToolCategory.Id, Value: 1}, +// }) +// categories, _ = toolcategorymod.FindMany(ctx, cateFilter, cateOpt) +// +// subFilter := bson.M{ +// toolsubcategorymod.FN.ToolSubCategory.Status: 1, +// toolsubcategorymod.FN.ToolSubCategory.Deleted: bson.M{"$ne": 1}, +// } +// subOpt := options.Find().SetSort(bson.D{ +// {Key: toolsubcategorymod.FN.ToolSubCategory.Sort, Value: 1}, +// {Key: toolsubcategorymod.FN.ToolSubCategory.Id, Value: 1}, +// }) +// subCategories, _ = toolsubcategorymod.FindMany(ctx, subFilter, subOpt) +// +// cateNameMap = make(map[int64]string, len(categories)) +// for _, c := range categories { +// cateNameMap[c.Id] = c.Name +// } +// subCateNameMap = make(map[int64]string, len(subCategories)) +// for _, s := range subCategories { +// subCateNameMap[s.Id] = s.Name +// } +// return categories, subCategories, cateNameMap, subCateNameMap +//} +// +//func loadRecentTools(ctx context.Context, limit int64) toolmod.ToolModelList { +// opt := options.Find(). +// SetSort(bson.D{ +// {Key: toolmod.FN.Tool.Sort, Value: 1}, +// {Key: toolmod.FN.Tool.Hot, Value: -1}, +// {Key: toolmod.FN.Tool.CreatedAt, Value: -1}, +// }). +// SetLimit(limit) +// filter := bson.M{ +// toolmod.FN.Tool.ReviewStatus: 1, +// toolmod.FN.Tool.Slug: bson.M{"$nin": []interface{}{"", nil}}, +// } +// list, _ := toolmod.FindMany(ctx, filter, opt) +// return list +//} +// +//func loadRecentArticles(ctx context.Context, limit int64) articlemod.ArticleModelList { +// opt := options.Find(). +// SetSort(bson.D{{Key: articlemod.FN.Article.CreatedAt, Value: -1}}). +// SetLimit(limit) +// filter := bson.M{ +// articlemod.FN.Article.ReviewStatus: 1, +// articlemod.FN.Article.Slug: bson.M{"$nin": []interface{}{"", nil}}, +// } +// list, _ := articlemod.FindMany(ctx, filter, opt) +// return list +//} +// +//// ── 内容构建 ────────────────────────────────────────────────── +// +//func writeBrief( +// b *strings.Builder, +// title, desc, baseURL string, +// keywords []string, +// recentTools toolmod.ToolModelList, +// recentArticles articlemod.ArticleModelList, +//) { +// writeHeader(b, title, desc, keywords) +// writeImportantPages(b, baseURL) +// +// if len(recentTools) > 0 { +// fmt.Fprintf(b, "## 最新 AI 工具\n\n") +// for _, t := range recentTools { +// if t == nil || t.Slug == "" { +// continue +// } +// summary := t.Intro +// if summary == "" { +// summary = t.Desc +// } +// if summary == "" { +// summary = t.Title +// } +// fmt.Fprintf(b, "- [%s](%s): %s\n", t.Title, toolDetailURL(baseURL, t.Slug), truncate(summary, 100)) +// } +// b.WriteString("\n") +// } +// +// if len(recentArticles) > 0 { +// fmt.Fprintf(b, "## 最新 AI 资讯\n\n") +// for _, a := range recentArticles { +// if a == nil || a.Slug == "" { +// continue +// } +// summary := a.Intro +// if summary == "" { +// summary = a.Title +// } +// fmt.Fprintf(b, "- [%s](%s): %s\n", a.Title, articleDetailURL(baseURL, a.Slug), truncate(summary, 100)) +// } +// b.WriteString("\n") +// } +//} +// +//func GetCategorySlug(id int64, categories toolcategorymod.ToolCategoryModelList) string { +// if id <= 0 { +// return "" +// } +// if len(categories) == 0 { +// return "" +// } +// for _, category := range categories { +// if category.Id == id { +// return category.Slug +// } +// } +// return "" +//} +//func GetSubCategorySlug(id int64, categories toolsubcategorymod.ToolSubCategoryModelList) string { +// if id <= 0 { +// return "" +// } +// if len(categories) == 0 { +// return "" +// } +// for _, category := range categories { +// if category.Id == id { +// return category.Slug +// } +// } +// return "" +//} +//func writeFull( +// b *strings.Builder, +// title, desc, baseURL string, +// keywords []string, +// categories toolcategorymod.ToolCategoryModelList, +// subCategories toolsubcategorymod.ToolSubCategoryModelList, +// cateNameMap, subCateNameMap map[int64]string, +// tools toolmod.ToolModelList, +// articles articlemod.ArticleModelList, +//) { +// writeHeader(b, title, desc, keywords) +// writeImportantPages(b, baseURL) +// writeCategorySections(b, baseURL, categories, subCategories, true) +// +// if len(tools) > 0 { +// fmt.Fprintf(b, "## AI 工具详情(最新 %d 条)\n\n", len(tools)) +// for _, t := range tools { +// if t == nil || t.Slug == "" { +// continue +// } +// fmt.Fprintf(b, "### [%s](%s)\n\n", t.Title, toolDetailURL(baseURL, t.Slug)) +// +// summary := t.Desc +// if summary == "" { +// summary = t.Intro +// } +// if summary != "" { +// fmt.Fprintf(b, "%s\n\n", truncate(summary, 500)) +// } +// if subName := subCateNameMap[t.SubCateId]; subName != "" { +// fmt.Fprintf(b, "- 子分类:[%s](%s)\n", subName, subCategoryListURL(baseURL, GetCategorySlug(t.CategoryId, categories), GetSubCategorySlug(t.SubCateId, subCategories))) +// } +// if t.OfficialUrl != "" { +// fmt.Fprintf(b, "- 官网:%s\n", t.OfficialUrl) +// } +// if len(t.SeoInfo.Keyworld) > 0 { +// fmt.Fprintf(b, "- 关键词:%s\n", strings.Join(t.SeoInfo.Keyworld, "、")) +// } +// if !t.CreatedAt.IsZero() { +// fmt.Fprintf(b, "- 发布时间:%s\n", t.CreatedAt.Format("2006-01-02")) +// } +// b.WriteString("\n") +// } +// } +// +// if len(articles) > 0 { +// fmt.Fprintf(b, "## AI 资讯详情(最新 %d 条)\n\n", len(articles)) +// for _, a := range articles { +// if a == nil || a.Slug == "" { +// continue +// } +// fmt.Fprintf(b, "### [%s](%s)\n\n", a.Title, articleDetailURL(baseURL, a.Slug)) +// +// if a.Intro != "" { +// fmt.Fprintf(b, "%s\n\n", truncate(a.Intro, 500)) +// } +// if a.Author != "" { +// fmt.Fprintf(b, "- 作者:%s\n", a.Author) +// } +// if len(a.SeoInfo.Keyworld) > 0 { +// fmt.Fprintf(b, "- 关键词:%s\n", strings.Join(a.SeoInfo.Keyworld, "、")) +// } +// if !a.CreatedAt.IsZero() { +// fmt.Fprintf(b, "- 发布时间:%s\n", a.CreatedAt.Format("2006-01-02")) +// } +// b.WriteString("\n") +// } +// } +//} +// +//func writeHeader(b *strings.Builder, title, desc string, keywords []string) { +// 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) +//} +// +//func writeImportantPages(b *strings.Builder, baseURL string) { +// fmt.Fprintf(b, "## 重要页面\n\n") +// fmt.Fprintf(b, "- [首页](%s/): AI 工具导航主页\n", baseURL) +// fmt.Fprintf(b, "- [工具列表](%s/tool/list): 全部 AI 工具\n", baseURL) +// fmt.Fprintf(b, "- [热门推荐](%s/tool/hot): 热门 AI 工具\n", baseURL) +// fmt.Fprintf(b, "- [AI 资讯](%s/article/list): AI 行业资讯文章\n", baseURL) +// fmt.Fprintf(b, "- [搜索](%s/search): 搜索工具与资讯\n", baseURL) +// fmt.Fprintf(b, "- [关于我们](%s/about/): 站点介绍\n", baseURL) +// fmt.Fprintf(b, "- [Sitemap](%s/sitemap.xml): 网站地图\n\n", baseURL) +//} +// +//func writeCategorySections( +// b *strings.Builder, +// baseURL string, +// categories toolcategorymod.ToolCategoryModelList, +// subCategories toolsubcategorymod.ToolSubCategoryModelList, +// withSeo bool, +//) { +// +// if len(subCategories) > 0 { +// fmt.Fprintf(b, "## 工具子分类\n\n") +// for _, s := range subCategories { +// if s == nil { +// continue +// } +// subDesc := subCategoryDesc(s, withSeo) +// fmt.Fprintf(b, "- [%s](%s): %s\n", s.Name, subCategoryListURL(baseURL, GetCategorySlug(s.CategoryId, categories), GetSubCategorySlug(s.Id, subCategories)), subDesc) +// } +// b.WriteString("\n") +// } +//} +// +//func subCategoryDesc(s *toolsubcategorymod.ToolSubCategoryModel, withSeo bool) string { +// if withSeo && s.SeoInfo.Description != "" { +// return s.SeoInfo.Description +// } +// return s.Name + "相关 AI 工具" +//} + +// ── URL 构建 ────────────────────────────────────────────────── + +func toolDetailURL(baseURL, slug string) string { + return utils.JoinUrlPath(baseURL, fmt.Sprintf("/tool/details/%s", slug)) +} + +func articleDetailURL(baseURL, slug string) string { + return utils.JoinUrlPath(baseURL, fmt.Sprintf("/article/details/%s", slug)) +} + +func subCategoryListURL(baseURL string, categorySlug, subCateSlug string) string { + return utils.JoinUrlPath(baseURL, fmt.Sprintf("/classification/%s/%s", categorySlug, subCateSlug)) +} + +// ── 工具函数 ────────────────────────────────────────────────── + +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen]) + "…" +} + +func writeTextFile(filename, content string) error { + if err := os.MkdirAll(pubdef.OutputDir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", pubdef.OutputDir, err) + } + tmpPath := filepath.Join(pubdef.OutputDir, 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(filename string) error { + src := filepath.Join(pubdef.OutputDir, filename+".tmp") + dst := filepath.Join(pubdef.OutputDir, 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/web/skd/job/reportdata/event.go b/web/skd/job/reportdata/event.go new file mode 100644 index 0000000..2ec6201 --- /dev/null +++ b/web/skd/job/reportdata/event.go @@ -0,0 +1,124 @@ +package job + +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/rss/rss.go b/web/skd/job/rss/rss.go new file mode 100644 index 0000000..6a43622 --- /dev/null +++ b/web/skd/job/rss/rss.go @@ -0,0 +1,310 @@ +package rss + +import ( + "context" + "encoding/xml" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + "videoweb/pub/common/log" + "videoweb/pub/common/netutil" + "videoweb/pub/common/utils" + "videoweb/pub/models/cfg" + "videoweb/pub/pubdef" + "videoweb/web/service/srv_cfg" +) + +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"` +} + +// rssEntry 内部排序用,合并文章与工具后统一输出 +type rssEntry struct { + Id int64 + Title string + Link string + Description string + PubAt time.Time + Cover string +} + +func GenerateRSS() { + generateMu.Lock() + defer generateMu.Unlock() + + ctx := context.Background() + config, serr := srv_cfg.GetSystemConfig(ctx) + if serr != nil { + log.Error("GenerateRSS GetSystemConfig", log.Any("err", serr)) + return + } + if config == nil { + log.Error("GenerateRSS GetSystemConfig nil config") + return + } + // + //entries := loadRSSEntries(ctx, config.WebAddr, maxRSSItems) + //items := buildRSSItems(config, entries) + //feedURL := utils.JoinUrlPath(config.WebAddr, "/rss.xml") + //feed := rssFeed{ + // Version: "2.0", + // Atom: "http://www.w3.org/2005/Atom", + // Channel: rssChannel{ + // Title: config.WebTitle, + // Link: config.WebAddr, + // Description: channelDescription(config), + // 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(filename, feed); err != nil { + // log.Error("GenerateRSS write", log.Any("err", err)) + // return + //} + // + //src := filepath.Join(pubdef.OutputDir, filename+".tmp") + //dst := filepath.Join(pubdef.OutputDir, 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))) +} + +func channelDescription(config *cfg.SysCfg) string { + if config.WebDescription != "" { + return config.WebDescription + } + return "AI 工具导航与资讯更新" +} + +// +//func loadRSSEntries(ctx context.Context, baseURL string, limit int) []rssEntry { +// baseURL = strings.TrimSuffix(baseURL, "/") +// articleFilter := bson.M{ +// articlemod.FN.Article.ReviewStatus: 1, +// articlemod.FN.Article.Slug: bson.M{"$nin": []interface{}{"", nil}}, +// } +// articleOpt := options.Find(). +// SetSort(bson.D{{Key: articlemod.FN.Article.CreatedAt, Value: -1}}). +// SetLimit(int64(limit)) +// articles, _ := articlemod.FindMany(ctx, articleFilter, articleOpt) +// +// toolFilter := bson.M{ +// toolmod.FN.Tool.ReviewStatus: 1, +// toolmod.FN.Tool.Slug: bson.M{"$nin": []interface{}{"", nil}}, +// } +// toolOpt := options.Find(). +// SetSort(bson.D{{Key: toolmod.FN.Tool.CreatedAt, Value: -1}}). +// SetLimit(int64(limit)) +// tools, _ := toolmod.FindMany(ctx, toolFilter, toolOpt) +// +// entries := make([]rssEntry, 0, len(articles)+len(tools)) +// for _, a := range articles { +// if a == nil || a.Slug == "" { +// continue +// } +// desc := a.Intro +// if desc == "" { +// desc = a.Title +// } +// entries = append(entries, rssEntry{ +// Id: a.Id, +// Title: a.Title, +// Link: articleDetailURL(baseURL, a.Slug), +// Description: desc, +// PubAt: a.CreatedAt, +// Cover: a.Cover, +// }) +// } +// for _, t := range tools { +// if t == nil || t.Slug == "" { +// continue +// } +// desc := t.Intro +// if desc == "" { +// desc = t.Desc +// } +// if desc == "" { +// desc = t.Title +// } +// cover := t.Logo +// if cover == "" { +// cover = t.Snapshot +// } +// entries = append(entries, rssEntry{ +// Id: t.Id, +// Title: t.Title, +// Link: toolDetailURL(baseURL, t.Slug), +// Description: desc, +// PubAt: t.CreatedAt, +// Cover: cover, +// }) +// } +// +// sort.Slice(entries, func(i, j int) bool { +// return entries[i].PubAt.After(entries[j].PubAt) +// }) +// if len(entries) > limit { +// entries = entries[:limit] +// } +// return entries +//} +// +//func buildRSSItems(config *cfg.SysCfg, entries []rssEntry) []rssItem { +// logoURL := utils.JoinUrlPath(strings.TrimSuffix(config.WebAddr, "/"), "/static/images/logo.png") +// imgCdn := imageCdn(config) +// +// items := make([]rssItem, 0, len(entries)) +// for _, e := range entries { +// if e.Link == "" { +// continue +// } +// +// pubDate := "" +// if !e.PubAt.IsZero() { +// pubDate = e.PubAt.Format(time.RFC1123Z) +// } +// +// item := rssItem{ +// Title: e.Title, +// Link: e.Link, +// Description: e.Description, +// PubDate: pubDate, +// GUID: e.Link, +// } +// +// encURL, encType := resolveEnclosureURL(config, imgCdn, logoURL, e.Cover, e.Id) +// item.Enclosure = &rssEnclosure{ +// URL: encURL, +// Type: encType, +// Length: "0", +// } +// items = append(items, item) +// } +// return items +//} + +func imageCdn(config *cfg.SysCfg) string { + if config.ImgWebCdn != "" { + return config.ImgWebCdn + } + return config.UnencryptedCdn +} + +func resolveEnclosureURL(config *cfg.SysCfg, imgCdn, logoURL, cover string, id int64) (url, mime string) { + mime = "image/jpeg" + if cover != "" && imgCdn != "" { + url = netutil.MergeURL(imgCdn, cover) + } else if len(config.SitemapDefaultImages) > 0 && imgCdn != "" { + img := config.SitemapDefaultImages[absModIndex(id, len(config.SitemapDefaultImages))] + url = netutil.MergeURL(imgCdn, img) + } else { + url = logoURL + mime = "image/png" + } + if strings.HasSuffix(strings.ToLower(url), ".png") { + mime = "image/png" + } else if strings.HasSuffix(strings.ToLower(url), ".webp") { + mime = "image/webp" + } + return url, mime +} + +func toolDetailURL(baseURL, slug string) string { + return utils.JoinUrlPath(baseURL, fmt.Sprintf("/tool/details/%s", slug)) +} + +func articleDetailURL(baseURL, slug string) string { + return utils.JoinUrlPath(baseURL, fmt.Sprintf("/article/details/%s", slug)) +} + +func absModIndex(id int64, length int) int { + if length <= 0 { + return 0 + } + idx := id % int64(length) + if idx < 0 { + idx = -idx + } + return int(idx) +} + +func writeRSSFile(filename string, data interface{}) error { + if err := os.MkdirAll(pubdef.OutputDir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", pubdef.OutputDir, err) + } + path := filepath.Join(pubdef.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 %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/web/skd/job/seopush/seopush.go b/web/skd/job/seopush/seopush.go new file mode 100644 index 0000000..457a384 --- /dev/null +++ b/web/skd/job/seopush/seopush.go @@ -0,0 +1,161 @@ +// Package seopush 为搜索引擎推送做兜底:补推已上架短剧,并重试失败的任务。 +package seopush + +import ( + "context" + "strings" + "sync" + "time" + "videoweb/pub/common/log" + "videoweb/pub/models/playletmod" + "videoweb/pub/models/seopushmod" + "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 !pubdef.Enabled() { + discardPendingTasks(ctx) + return + } + webAddr := srv_seopush.SEOBaseURL(ctx) + if webAddr == "" { + return + } + backfillVisiblePlaylets(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 + } + ids := make([]int64, 0, len(list)) + seen := make(map[int64]struct{}, len(list)) + for _, task := range list { + if task == nil || task.VideoId <= 0 { + continue + } + if _, ok := seen[task.VideoId]; ok { + continue + } + seen[task.VideoId] = struct{}{} + ids = append(ids, task.VideoId) + } + count, serr := seopushmod.DelManySeoPushTask(ctx, filter) + if serr != nil { + log.Warn("seopush: discard pending tasks failed", log.E(serr)) + return + } + if len(ids) > 0 { + if serr := playletmod.UpdateManyPlaylet(ctx, + bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}, + bson.M{"$set": bson.M{playletmod.FN.Playlet.IndexNow: int64(0)}}); serr != nil { + log.Warn("seopush: reset playlet indexNow failed", log.E(serr)) + } + } + log.Info("seopush=skip=disabled", log.Any("discarded", count)) +} + +func backfillVisiblePlaylets(ctx context.Context) { + filter := bson.M{ + playletmod.FN.Playlet.Status: playletmod.StatusUp, + "$or": []bson.M{ + {playletmod.FN.Playlet.IndexNow: 0}, + {playletmod.FN.Playlet.IndexNow: bson.M{"$exists": false}}, + }, + } + opt := options.Find(). + SetSort(bson.M{playletmod.FN.Playlet.OnlineAt: -1}). + SetLimit(backfillLimit) + + list, serr := playletmod.FindManyPlaylet(ctx, filter, 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.RegisterPlayletUpdated(ctx, item.Id, item.Status, item.OnlineAt) + } + log.Info("seopush: backfill 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/job/vipexpiry/job.go b/web/skd/job/vipexpiry/job.go new file mode 100644 index 0000000..6b6f10e --- /dev/null +++ b/web/skd/job/vipexpiry/job.go @@ -0,0 +1,45 @@ +package vipexpiry + +import ( + "context" + "time" + "videoweb/pub/common/log" + "videoweb/pub/models/usermod" + "videoweb/pub/pubdef" + + "go.mongodb.org/mongo-driver/bson" +) + +var isRunning = false + +// ResetExpiredVip 将 CurrentVipExpire 已过期但 VipType 仍非 None 的用户批量重置为普通用户。 +// 同时清空 ShowCard* 展示字段,保证 DB 数据与实际状态一致。 +func ResetExpiredVip() { + if isRunning { + return + } + isRunning = true + defer func() { isRunning = false }() + + ctx := context.Background() + now := time.Now() + + filter := bson.M{ + usermod.FN.User.VipType: bson.M{"$gt": pubdef.VipTypeNone}, + usermod.FN.User.CurrentVipExpire: bson.M{"$lt": now}, + } + update := bson.M{ + "$set": bson.M{ + usermod.FN.User.VipType: pubdef.VipTypeNone, + usermod.FN.User.ShowCardId: int64(0), + usermod.FN.User.ShowCardName: "", + usermod.FN.User.UpdatedAt: now, + }, + } + + if serr := usermod.UpdateManyUser(ctx, filter, update); serr != nil { + log.Error("vipexpiry:ResetExpiredVip UpdateManyUser failed", log.Any("err", serr)) + return + } + log.Info("vipexpiry:ResetExpiredVip done") +} 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..22a4ea2 --- /dev/null +++ b/web/skd/playletwatermark/submit.go @@ -0,0 +1,471 @@ +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 + taskAddScanLimit = 200 + mediaTaskAlreadyExists = 6007 + mediaTaskSourceNotReady = 6015 + sourceNotReadyRetryWait = 5 * time.Minute + mediaFileVersionV1 = "v1" + mediaFileVersionV2 = "v2" +) + +// watermarkSubmitResult 是一次 task/add 或重新加水印的结果。 +type watermarkSubmitResult int + +const ( + watermarkSubmitFailed watermarkSubmitResult = iota + watermarkSubmitNotReady + watermarkSubmitOK +) + +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。 +// 同一轮按重试时间、剧集 id 最多检查 taskAddScanLimit 条,成功提交满 taskAddRetryBatch 条即停。 +// 原片未处理完的剧集延后,不再占住队头。 +func submitWatermarkTasks(ctx context.Context, sysCfg *cfg.SysCfg) { + now := time.Now() + list, err := playletchaptermod.FindManyPlayletChapter(ctx, dueRegisteredWatermarkFilter(now), options.Find(). + SetSort(bson.D{ + {Key: playletchaptermod.FN.PlayletChapter.WatermarkRetryAt, Value: 1}, + {Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1}, + }). + SetLimit(taskAddScanLimit)) + if err != nil { + log.Error("playletwatermark:submitWatermarkTasks 查询失败", log.E(err)) + return + } + submitted := 0 + scanned := 0 + for _, item := range list { + if submitted >= taskAddRetryBatch { + break + } + if item == nil || item.MediaSourceId == "" { + continue + } + scanned++ + var result watermarkSubmitResult + if strings.TrimSpace(item.MediaTaskId) != "" { + result = restartWatermarkTask(ctx, item, sysCfg) + } else { + result = addWatermarkTask(ctx, item, sysCfg) + } + switch result { + case watermarkSubmitNotReady: + postponeWatermarkRetry(ctx, item, now) + case watermarkSubmitOK: + if markWatermarkProcessing(ctx, item, now) { + submitted++ + } + } + } + log.Info("playletwatermark:submitWatermarkTasks end", + log.Any("scanned", scanned), + log.Any("submitted", submitted), + log.Any("candidates", len(list))) +} + +// dueRegisteredWatermarkFilter 选出已登记、且到了重试时间的剧集。 +// 没有重试时间的记录优先,原片未就绪被延后的记录排在后面。 +func dueRegisteredWatermarkFilter(now time.Time) bson.M { + return bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkStatus: playletchaptermod.WatermarkStatusRegistered, + playletchaptermod.FN.PlayletChapter.MediaSourceId: bson.M{"$gt": ""}, + "$or": []bson.M{ + {playletchaptermod.FN.PlayletChapter.WatermarkRetryAt: bson.M{"$exists": false}}, + {playletchaptermod.FN.PlayletChapter.WatermarkRetryAt: bson.M{"$lte": now}}, + }, + } +} + +func classifyMediaTaskCode(code int) watermarkSubmitResult { + switch code { + case proxy.Success, mediaTaskAlreadyExists: + return watermarkSubmitOK + case mediaTaskSourceNotReady: + return watermarkSubmitNotReady + default: + return watermarkSubmitFailed + } +} + +func markWatermarkProcessing(ctx context.Context, item *playletchaptermod.PlayletChapter, now time.Time) bool { + if updateErr := 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: now, + }, + "$unset": bson.M{ + playletchaptermod.FN.PlayletChapter.WatermarkRetryAt: "", + }, + }); updateErr != nil { + log.Error("playletwatermark:submitWatermarkTasks 更新为处理中失败", + log.Any("chapterId", item.Id), + log.E(updateErr)) + return false + } + return true +} + +func postponeWatermarkRetry(ctx context.Context, item *playletchaptermod.PlayletChapter, now time.Time) { + retryAt := now.Add(sourceNotReadyRetryWait) + if updateErr := 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.WatermarkRetryAt: retryAt, + }}); updateErr != nil { + log.Error("playletwatermark:submitWatermarkTasks 延后重试失败", + log.Any("chapterId", item.Id), + log.E(updateErr)) + return + } + log.Info("playletwatermark:submitWatermarkTasks 原片未处理完,延后重试", + log.Any("chapterId", item.Id), + log.Any("mediaSourceId", item.MediaSourceId), + log.Any("retryAt", retryAt)) +} + +func addWatermarkTask(ctx context.Context, item *playletchaptermod.PlayletChapter, sysCfg *cfg.SysCfg) watermarkSubmitResult { + 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 watermarkSubmitFailed + } + // 200=新建成功;6007=任务已存在。6015=原片未处理完,延后并不占住本轮名额。 + switch classifyMediaTaskCode(result.Code) { + case watermarkSubmitOK: + return watermarkSubmitOK + case watermarkSubmitNotReady: + return watermarkSubmitNotReady + default: + log.Info("playletwatermark:submitWatermarkTasks task/add 暂未成功", + log.Any("chapterId", item.Id), + log.Any("mediaSourceId", item.MediaSourceId), + log.Any("result", result)) + markFailedIfMediaSourceFailed(ctx, item) + return watermarkSubmitFailed + } +} + +func restartWatermarkTask(ctx context.Context, item *playletchaptermod.PlayletChapter, sysCfg *cfg.SysCfg) watermarkSubmitResult { + 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 watermarkSubmitFailed + } + switch classifyMediaTaskCode(result.Code) { + case watermarkSubmitOK: + return watermarkSubmitOK + case watermarkSubmitNotReady: + return watermarkSubmitNotReady + default: + log.Info("playletwatermark:submitWatermarkTasks update/watermark 暂未成功", + log.Any("chapterId", item.Id), + log.Any("mediaTaskId", item.MediaTaskId), + log.Any("result", result)) + markFailedIfMediaSourceFailed(ctx, item) + return watermarkSubmitFailed + } +} + +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.PlayName) + if playletName == "" { + 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.PlayName: 1, + 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..58bb943 --- /dev/null +++ b/web/skd/skd.go @@ -0,0 +1,99 @@ +package skd + +import ( + "context" + "math/rand" + "time" + "videoweb/pub/common/log" + "videoweb/web/service/srv_aicomment" + "videoweb/web/service/srv_mediaresource" + job "videoweb/web/skd/job/reportdata" + seopushjob "videoweb/web/skd/job/seopush" + "videoweb/web/skd/job/vipexpiry" + "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 0/2 * * * ?", func() { + srv_aicomment.PollAiCommentResults(context.Background()) + }) + _, _ = skdCron.AddFunc("0 0/15 * * * ?", func() { + srv_aicomment.RetryFailedPlaylets(context.Background()) + }) + + // 视频同步:每10分钟从媒资库增量同步到 video_information + _, _ = 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() { // 每秒执行一次批量上报 + job.Report() + }) + + // 每天凌晨 02:00 重置 VIP 已过期用户的 VipType + _, _ = skdCron.AddFunc("0 0 2 * * ?", func() { + vipexpiry.ResetExpiredVip() + }) + + // 搜索引擎推送兜底:补推已上架短剧并重试失败任务。 + // 开关关闭或非正式环境不会真正对外提交,详见 srv_seopush.seoPushEnabled + _, _ = skdCron.AddFunc("0/30 * * * * ?", func() { + seopushjob.Run() + }) + + //if webg.Conf.LsjMediaConf.ApiUrl != "" { + // _, _ = skdCron.AddFunc("0 30 */2 * * ?", func() { + // transcodejob.TranscodeVideoByWatchTimes() + // }) + // _, _ = skdCron.AddFunc("0 */5 * * * ?", func() { + // transcodejob.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..6ea8c64 --- /dev/null +++ b/web/skd/videosync/info.go @@ -0,0 +1,77 @@ +package videosync + +import ( + "time" + "videoweb/pub/models/videoinformationmod" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +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"` // 文件名称 + TempFieldNameFs string `bson:"tempFieldNameFs,omitempty" json:"tempFieldNameFs"` // 临时存储 + Ext string `bson:"ext,omitempty" json:"ext"` // 视频后缀 + UnencryptedPreMp4 string `bson:"unencryptedPreMp4,omitempty" json:"unencryptedPreMp4"` // 不加密的预览MP4 + 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 + UploadUserName string `bson:"uploadUserName" json:"uploadUserName,omitempty"` // 上传用户名 + OptionAccount string `bson:"optionAccount,omitempty" json:"optionAccount"` // 最后操作人 +} diff --git a/web/skd/videosync/videosync.go b/web/skd/videosync/videosync.go new file mode 100644 index 0000000..5807b00 --- /dev/null +++ b/web/skd/videosync/videosync.go @@ -0,0 +1,157 @@ +package videosync + +import ( + "context" + "errors" + 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" +) + +/* +1.小说采集后上传媒资库 +2.业务后台记录最新更新章节ID(数据ID自增) +3.查询对应小说ID是否存在,如果不存在需要先同步媒资库小说信息,再更新小说 +注意:业务后台和媒资库 存储的小说ID和章节ID需一致, 资源以媒资库为准, 不单独更新业务后台小说 +*/ + +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 media.UpdatedAt.After(recordInfo.RecordTime) { + recordInfo.RecordTime = media.UpdatedAt + } + if srv_videoinfo.IsPlayletMedia(ctx, media) { + log.Info("VideoSync skip playlet media", log.Any("mediaId", media.Id.Hex()), log.Any("mediaSourceId", media.MediaSourceId.Hex())) + continue + } + se := srv_videoinfo.SyncOneVideo(ctx, media) + if se != nil { + log.Error("VideoSync SyncOneVideo", log.Any("media", media), log.E(se)) + continue + } + } + + 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)) +} + +// SyncUnencryptedFields 全量补齐历史资源的 UnencryptedImage / UnencryptedPreMp4 字段。 +// 从头分页拉取媒资库,仅在源端有值时写回,本地不存在或字段已有值不覆盖。 +// 独立于增量同步的 SyncRecord,用于一次性修复;可安全重复执行。 +func SyncUnencryptedFields() { + ctx := context.Background() + log.Info("SyncUnencryptedFields begin") + pageSize := int64(200) + lastUpdateTime := time.Time{} + for { + req := proxy.MediaTaskSyncReq{ + AppId: pubdef.AppId, + LastUpdateTime: lastUpdateTime, + PageSize: pageSize, + } + result, err := proxy.SyncMedia(&req, "") + if err != nil || result.Code != nethttp.StatusOK { + log.Error("SyncUnencryptedFields SyncMedia", log.Any("req", req), log.E(err)) + return + } + for _, media := range result.Data { + // 先推进时间戳,保证 continue 也不阻塞分页 + if media.UpdatedAt.After(lastUpdateTime) { + lastUpdateTime = media.UpdatedAt + } + se := srv_videoinfo.SyncUnencryptedOne(ctx, media) + if se != nil && !errors.Is(se.Code, srvdef.ErrNoData) { + log.Warn("SyncUnencryptedFields SyncUnencryptedOne", log.Any("mediaId", media.Id.Hex()), log.E(se)) + } + } + if len(result.Data) < int(pageSize) { + break + } + } + log.Info("SyncUnencryptedFields success", log.Any("lastUpdateTime", lastUpdateTime)) +} 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..9328812 --- /dev/null +++ b/web/webg/webg.go @@ -0,0 +1,82 @@ +package webg + +import "videoweb/pub/payCenter" + +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"` + 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"` + PlayletMediaResourceDBCfg struct { //嘉华 + BaseUrl string `yaml:"baseUrl"` // 域名 + Merchat string `yaml:"merchat"` // 商户号 + Secret string `yaml:"secret"` // 商户密钥 + } `yaml:"playletMediaResourceDBCfg"` + NewInnerStatUrl string `yaml:"newInnerStatUrl"` + DataReport struct { + Url string `yaml:"url"` + } `yaml:"dataReport"` + NewPayCenter payCenter.Conf `yaml:"newPayCenter"` + AiComment struct { + Url string `yaml:"url"` + AppId string `yaml:"appId"` + Secret string `yaml:"secret"` + } `yaml:"aiComment"` + Customer struct { + Url string `yaml:"url"` + AppId string `yaml:"appId"` + Secret string `yaml:"secret"` + } `yaml:"customer"` + DataCenterBackflow struct { + Key string `yaml:"key"` + SelfSourceBaseUrl string `yaml:"selfSourceBaseUrl"` + } `yaml:"dataCenterBackflow"` +} + +func IsDev() bool { + return Conf.RunMod == "dev" || Conf.RunMod == "debug" +}