diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e8ca770
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+.env
+.env.*
+!.env.example
+.build/
+.local/
+*_test.go
+*.key
+*.pem
+*.tar.gz
+*.log
+.DS_Store
+.idea/
+.vscode/
+composed-anvil-*.json
+__pycache__/
+*.pyc
+*.sh
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..479c37d
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,12 @@
+# 花魁 NET 官网后端
+
+遵守 `/home/zhufei/code/AGENTS.md` 和花魁主入口 `/home/zhufei/code/hkdj-server/AGENTS.md`。项目身份 HKDJ / JHA-216,本仓库为独立官网后端 `hkdj-web-server`,不能使用原花魁短剧自动部署路线。
+
+- 来源:黄果 NET `hgdjnet-server` 的 `9bfbafe10d417deea959bd53117bc88a10e60a83`。
+- 开发分支 `feat/net-import`;空仓库基线 `f7534bf`。测试交付版本、构建和接口证据见 `docs/测试交付记录-20261010.md`;不以本地构建成功冒充部署成功。
+- 测试目录 `/server/hkdj_net`,App/Web API 30331/30332;前端后台/H5 预留30333/30334,未部署页面产物。前端代码仅联调与只读核验。
+- 使用独立 `HKDJNET_*` 凭据、业务/日志库与缓存空间。黄果、现有花魁短剧数据和会话均不复用。
+- 本人2026-10-10已批准首次测试初始化;准确方案见 `deploy/test/index-plan.json`,SHA-256 `3f56441d0c496a9817c5d2f269084eab8450a2d73bcaec9f45a415cbd1751541`。只执行清单中的目标/keys/顺序/选项,改变方案需重新批准。部署守卫键 `hkdjnet` 对应独立Jenkins“花魁NET”,不套用原短剧 `hkdj` 路线。
+- 本地测试、构建产物及 `.local/` 审阅工具不得暂存或提交;不维护 Swagger。
+- 未配置的广告、媒资拉取和统计上报保持关闭;Web `mediaJobsEnabled=false`,不自动拉取黄果媒资或执行旧赤果回流。嘉华媒体库接入需另行核对花魁产品身份和接口。
+- 用户另行批准的空ES主分片恢复已执行一次并核验成功;该批准不能用于重复强制分配或其他索引。
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1bbb7ee
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,12 @@
+.DEFAULT_GOAL := help
+.PHONY: help app web check
+
+help:
+ @printf '%s\n' 'make app / make web: build the selected backend' 'make check: compile both backends'
+
+app web:
+ ./scripts/build.sh $@
+
+check:
+ ./scripts/build.sh app
+ ./scripts/build.sh web
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f4ed32c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,54 @@
+# 花魁 NET 官网后端
+
+项目身份:HKDJ / 花魁短剧 / JHA-216。独立仓库 `hkdj-web-server`,不替代现有 `hkdj-server`。
+
+复刻来源为黄果 NET 后端 `jha-1045-hgdjnet-server` 的 `main` 提交 `9bfbafe10d417deea959bd53117bc88a10e60a83`。首次初始化已按本人批准的准确索引方案适配花魁;MongoDB索引定义保持源项目原样,ES使用花魁独立名称。准确版本、构建/运行及接口证据见[测试交付记录](docs/测试交付记录-20261010.md)。
+
+## 服务划分
+
+| 服务 | 代码/责任 | 测试端口 | 目录 |
+| --- | --- | --- | --- |
+| H5 业务 API | 后端 `app` | 30331 | `/server/hkdj_net/app` |
+| 后台管理 API | 后端 `web` | 30332 | `/server/hkdj_net/web` |
+| 后台前端 | 前端静态页面,由 Nginx 提供入口 | 30333 | `/server/hkdj_net/houtai/dist` |
+| H5 前端 | 前端独立 H5/SSR 服务 | 30334 | `/server/hkdj_net/h5` |
+
+2026-10-10 部署前已只读确认四个端口未监听、无 Nginx/Supervisor 配置引用。App/Web按测试部署守卫逐组件安装;前端两个入口保持预留。后台、H5 两个前端服务分别交付;不复用黄果的服务目录、前端产物、监听端口或业务数据库。
+
+## 配置与构建
+
+App/Web 配置只包含非敏感字段和 `${HKDJNET_*}` 环境变量。凭据由权限 600 的独立 `.env` 加载,不能提交。运行配置与测试模板见 `app/conf/app.yaml`、`web/conf/web.yaml`、`deploy/test/`。
+
+```bash
+make app
+make web
+```
+
+构建脚本通过工作区 Go 全局锁,产物写入忽略目录 `.build/`。业务包保留 NET 的 App/Web 能力;不生成、构建或部署 Swagger。
+
+运行目录分别为 `app`、`web`,从 `conf/app.yaml` 或 `conf/web.yaml` 加载配置;缺少必要环境变量会停止。App 健康检查仍为 `POST /api/app/health/ping`,认证标识和签名密钥改为环境变量 `HKDJNET_HEALTH_GROUP_ID`、`HKDJNET_HEALTH_SECRET`,不沿用黄果值。H5播放票据使用本项目 `tsAuthKey`,不同项目票据互不通用。
+
+IndexNow 在 Web 运行目录的 `conf/web.yaml` 中直接配置,默认留空且外部推送关闭:
+
+```yaml
+indexNow:
+ key: ""
+```
+
+将 `indexNow.key` 填为本站的验证 key,并在 SEO 域名下提供同名 `{key}.txt` 文件,文件内容为该 key。SEO 域名优先取系统配置 `foreverUrlAddr`,为空时回退到 `webAddr`。修改 YAML 后需重启 Web 服务。不再读取 `HKDJNET_INDEXNOW_KEY` 环境变量,原环境变量的值需迁移到此配置项;留空或省略该项不影响启动,但无法提交 IndexNow。实际外部推送仍要求代码中的 `seoPushEnabled=true` 且 `runMod=prod`。
+
+M3U8 解密密钥从各服务运行目录下的文件读取:普通密钥 `conf/m3u8.key`、新版密钥 `conf/newm3u8.key`、老司机密钥 `conf/lsjm3u8.key`。文件内容必须是原始 16 字节二进制密钥,不是十六进制文本,不添加换行;不再读取对应的 `HKDJNET_*M3U8_KEY_HEX` 环境变量。App/Web 的 `conf` 目录均需按需配置本项目密钥文件,权限设为 600,不提交仓库。文件缺失、不可读或长度错误时,密钥接口返回错误。
+
+## 数据与外部服务
+
+使用独立测试业务库 `hkdj_net`、日志库 `hkdj_net_log`、Redis DB24 和花魁搜索索引。已完成206项MongoDB索引请求和ES映射回读核验。本人已于2026-10-10批准独立测试库首次初始化;准确keys、顺序和unique/TTL/partial选项见 `deploy/test/index-plan.json`,清单SHA-256为 `3f56441d0c496a9817c5d2f269084eab8450a2d73bcaec9f45a415cbd1751541`。改变该准确方案需重新确认。
+
+支付、短信、媒资、广告、统计上报和内容回流的黄果凭据没有复制。测试模板中的可选外部接入地址留空;按花魁实际产品标识和本项目凭据另行接入。花魁所属媒体库为嘉华,不能沿用黄果独立媒体库。支付与外部内容发布不在本次验证范围内。
+
+未配置广告和媒资地址时跳过拉取;Web `mediaJobsEnabled=false`,媒资同步、封面/水印、AI回流等任务不注册,旧赤果自动回流已移除。未配置统计上报时不启动其定时任务。测试库没有迁移黄果内容,播放功能尚待嘉华媒资接入。
+
+## 分支与交付状态
+
+空仓库本地基线为 `f7534bf`;开发分支 `feat/net-import`。开发分支经检查后提交推送,并集成到独立`test`分支;Jenkins任务“花魁NET”只部署此仓库的准确`test`版本。未初始化正式环境,未发布生产版本。
+
+Web、App 对接说明分别见[后台接入](docs/Web-后台接入.md)、[H5接入](docs/App-H5接入.md)。前端30333/30334尚未监听,Nginx配置仅为交接模板。
diff --git a/app/api/appinfoapi/appinfo.go b/app/api/appinfoapi/appinfo.go
new file mode 100644
index 0000000..166c842
--- /dev/null
+++ b/app/api/appinfoapi/appinfo.go
@@ -0,0 +1,48 @@
+package appinfoapi
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/appser"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/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..7993251
--- /dev/null
+++ b/app/api/authapi/auth.go
@@ -0,0 +1,232 @@
+package authapi
+
+import (
+ "hkdj-web-server/app/service/authser"
+ "hkdj-web-server/app/service/wordsfilterser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/useragent"
+ "hkdj-web-server/pub/pubdef"
+ "strings"
+
+ "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" binding:"required"` // 邮箱
+ 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..5c80d09
--- /dev/null
+++ b/app/api/coinapi/coin.go
@@ -0,0 +1,141 @@
+package coinapi
+
+import (
+ "fmt"
+ "hkdj-web-server/app/service/coinser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/srvdef"
+ "net/url"
+
+ "github.com/gin-gonic/gin"
+)
+
+type buyCoinReq struct {
+ PackageId int64 `json:"packageId" binding:"required"` // 购买套餐ID(金币充值)
+ PayMode string `json:"payMode" binding:"required"` // 支付方式:alipay/wechat
+}
+
+// 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
+ }
+
+ resp, serr := coinser.CreateCoinOrder(ctx, userId, user.NickName, req.PackageId, req.PayMode, sysType, devType, ip, user.DistrictCode)
+ 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..d70cd89
--- /dev/null
+++ b/app/api/commentapi/comment.go
@@ -0,0 +1,86 @@
+package commentapi
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/commentser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/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..5d9bc9d
--- /dev/null
+++ b/app/api/config/config.go
@@ -0,0 +1,64 @@
+package config
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/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..33b28de
--- /dev/null
+++ b/app/api/contactusapi/contactus.go
@@ -0,0 +1,36 @@
+package contactusapi
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/contactusser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/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..fdb343d
--- /dev/null
+++ b/app/api/feedbackapi/feedback.go
@@ -0,0 +1,36 @@
+package feedbackapi
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/feedbackser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/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..92d6f70
--- /dev/null
+++ b/app/api/healthapi/health.go
@@ -0,0 +1,42 @@
+package healthapi
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/healthser"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/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..885ad54
--- /dev/null
+++ b/app/api/home/home.go
@@ -0,0 +1,305 @@
+package home
+
+import (
+ "fmt"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/commentser"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/app/service/translationser"
+ "hkdj-web-server/app/service/videoser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/srvdef"
+ v10 "hkdj-web-server/pub/common/v10"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/pubdef"
+ "net/http"
+ "strconv"
+
+ "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..9e04621
--- /dev/null
+++ b/app/api/navigationapi/header.go
@@ -0,0 +1,27 @@
+package navigationapi
+
+import (
+ "hkdj-web-server/app/service/navigationser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/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..99f61c3
--- /dev/null
+++ b/app/api/playletapi/playlet.go
@@ -0,0 +1,717 @@
+package playletapi
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/playletser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/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)
+}
+
+// HomeConfigDetailList app端分页查询首页配置详情列表
+// @Tags app短剧相关接口
+// @Summary app端分页查询首页配置详情列表--by 王鸣
+// @Description 根据首页配置ID或slug分页查询短剧、视频或题材;homeConfigSlug 优先。sortType=5(分类)时配置ID即为分类ID
+// @Accept json
+// @Produce json
+// @Param params query proto.HomeConfigDetailListReq true "app端分页查询首页配置详情列表请求"
+// @Success 200 {object} proto.HomeConfigDetailListResp "成功"
+// @Failure 400 {string} string "失败"
+// @Router /api/app/playlet/home/config/detail/list [get]
+func HomeConfigDetailList(ctx *gin.Context) {
+ var req proto.HomeConfigDetailListReq
+ if err := ctx.ShouldBindQuery(&req); err != nil {
+ common.ServerJSON(ctx, srvdef.ErrParamError, nil)
+ return
+ }
+ resp, err := playletser.HomeConfigDetailList(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)
+}
+
+// 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.PlayletTopicPageItem} "成功"
+// @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 按运营类型查询已上架短剧;operateType=1短剧 2漫剧;filterType=1最近更新(按上架时间) 2当前热播(按收藏数) 3独家原创(原创且按推荐排序)
+// @Accept json
+// @Produce json
+// @Param params query proto.PlayletOperateListReq true "按运营类型分页查询短剧请求"
+// @Success 200 {object} proto.PlayletOperateListResp{list=[]proto.CommonData} "成功"
+// @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))
+}
+
+// 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..ce7c1d8
--- /dev/null
+++ b/app/api/playlettagapi/tag.go
@@ -0,0 +1,27 @@
+package playlettagapi
+
+import (
+ "hkdj-web-server/app/service/playlettagser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/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..88bcc3d
--- /dev/null
+++ b/app/api/playletthemeapi/theme.go
@@ -0,0 +1,27 @@
+package playletthemeapi
+
+import (
+ "hkdj-web-server/app/service/playletthemeser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/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..1d83903
--- /dev/null
+++ b/app/api/proto/ads.go
@@ -0,0 +1,34 @@
+package proto
+
+import "hkdj-web-server/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..4993c12
--- /dev/null
+++ b/app/api/proto/aitools.go
@@ -0,0 +1,261 @@
+package proto
+
+import "hkdj-web-server/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..5d2d0f5
--- /dev/null
+++ b/app/api/proto/coin.go
@@ -0,0 +1,35 @@
+package proto
+
+import "hkdj-web-server/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..57b6596
--- /dev/null
+++ b/app/api/proto/comment.go
@@ -0,0 +1,65 @@
+package proto
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// 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..6b3b4e5
--- /dev/null
+++ b/app/api/proto/playlet.go
@@ -0,0 +1,617 @@
+package proto
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// HomePlayletResponse 首页响应
+type HomePlayletResponse struct {
+ HomeBannerData []*CommonData `json:"homeBannerData"` // 首页banner短剧信息列表
+ HomeConfigData []*HomeConfigData `json:"homeConfigData"` // 首页配置集合
+ 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"` // 描述
+ Type int32 `json:"type"` // 类型 1-短剧 2-视频 3-题材
+ Rank int64 `json:"rank"` // 排序,越大越靠前
+ 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时返回
+ VideoList []*VideoItemResp `json:"videoList"` // 视频集合,type=2时返回
+ ThemeList []*HomeConfigThemeData `json:"themeList"` // 题材集合,type=3时返回
+ SeoInfo pubdef.SeoValue `json:"seoInfo"` // Seo相关信息
+}
+
+// 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信息
+}
+
+// HomeConfigDetailListReq 首页配置详情分页请求
+type HomeConfigDetailListReq struct {
+ pubdef.Page
+ HomeConfigId int64 `form:"homeConfigId" json:"homeConfigId"` // 首页配置ID;sortType=5(分类)时即分类ID。与 homeConfigSlug 二选一
+ HomeConfigSlug string `form:"homeConfigSlug" json:"homeConfigSlug"` // 首页配置 slug,非空时优先按 slug 查询
+ OperateType int32 `form:"operateType" json:"operateType"` // type=1时生效,运营类型 0-全部 1-短剧 2-漫剧
+ ThemeId int64 `form:"themeId" json:"themeId"` // type=1时生效,题材ID
+ ThemeSlug string `form:"themeSlug" json:"themeSlug"` // type=1时生效,题材slug,非空时优先按slug解析题材ID
+ MediaType int64 `form:"mediaType" json:"mediaType"` // type=2时生效,视频类型 0-全部 1-影集 2-动画
+ TopicId int64 `form:"topicId" json:"topicId"` // type=2时生效,视频题材ID,对应video_topic
+ TimeRange int64 `form:"timeRange" json:"timeRange"` // type=1/type=2时生效,时间范围 0-综合 1-本周 2-本月
+}
+
+// HomeConfigDetailListResp 首页配置详情分页响应
+type HomeConfigDetailListResp struct {
+ HomeConfigId int64 `json:"homeConfigId"` // 首页配置ID
+ HomeConfigSlug string `json:"homeConfigSlug"` // 首页配置 slug
+ Name string `json:"name"` // 名称
+ Desc string `json:"desc"` // 描述
+ Type int32 `json:"type"` // 类型 1-短剧 2-视频 3-题材
+ SortType int32 `json:"sortType"` // 排序字段 1-推荐 2-热播 3-最近更新 4-新片预告 5-分类
+ TotalCount int64 `json:"totalCount"` // 总共数量
+ PageSize int `json:"pageSize"` // 每页数
+ PageNum int `json:"pageNum"` // 页数
+ List []*CommonData `json:"list"` // 短剧集合,type=1时返回
+ VideoList []*VideoItemResp `json:"videoList"` // 视频集合,type=2时返回
+ ThemeList []*HomeConfigThemeData `json:"themeList"` // 题材集合,type=3时返回
+ Advitise AdvitiseData `json:"advitise"` // 广告
+ SeoInfo pubdef.SeoValue `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信息
+}
+
+// CommonData 首页通用数据
+type CommonData struct {
+ Id int64 `json:"id"` // 短剧id
+ Status int32 `json:"status"` // 状态: 0下架 1上架
+ IsFeatured bool `json:"isFeatured"` // 是否爆剧(true-是,false-否)
+ 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图,已拼接图片域名
+ 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
+ OperateType int32 `form:"operateType" json:"operateType"` // 运营类型 1-短剧 2-漫剧
+ FilterType int64 `form:"filterType" json:"filterType"` // 筛选类型 1-最近更新 2-当前热播 3-独家原创
+}
+
+// PlayletOperateListResp 按运营类型分页查询短剧响应
+type PlayletOperateListResp struct {
+ TotalCount int64 `json:"totalCount"` // 总共数量
+ PageSize int `json:"pageSize"` // 每页数
+ PageNum int `json:"pageNum"` // 页数
+ OperateType int32 `json:"operateType"` // 运营类型 1-短剧 2-漫剧
+ FilterType int64 `json:"filterType"` // 筛选类型 1-最近更新 2-当前热播 3-独家原创
+ 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部上架短剧
+}
+
+// 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..b7d64ff
--- /dev/null
+++ b/app/api/proto/playlet_theme.go
@@ -0,0 +1,90 @@
+package proto
+
+import "hkdj-web-server/pub/pubdef"
+
+// PlayletTopicPageListReq 专题剧单分页请求
+type PlayletTopicPageListReq struct {
+ pubdef.Page
+ SortType int64 `form:"sortType" json:"sortType"` // 排序 1-最新 2-最热
+}
+
+// PlayletTopicPageListResp 专题剧单分页响应
+type PlayletTopicPageListResp struct {
+ TotalCount int64 `json:"totalCount"` // 总共数量
+ PageSize int `json:"pageSize"` // 每页数
+ PageNum int `json:"pageNum"` // 页数
+ List []*PlayletTopicPageItem `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"` // 展示封面集合
+ List []*CommonData `json:"list"` // 专题下短剧集合
+ SeoInfo *SeoInfo `json:"seoInfo"` // 专题SEO
+}
+
+// PlayletTopicDetailReq 专题详情请求
+type PlayletTopicDetailReq struct {
+ pubdef.Page
+ Id int64 `form:"id" json:"id" binding:"required"` // 专题ID
+ SortType int64 `form:"sortType" json:"sortType"` // 排序 1-编辑排序 2-最新 3-最热
+}
+
+// PlayletTopicDetailResp 专题详情响应
+type PlayletTopicDetailResp 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"` // 顶部封面集合
+ 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
+}
+
+// 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..0dfd5c5
--- /dev/null
+++ b/app/api/proto/recharge_package.go
@@ -0,0 +1,32 @@
+package proto
+
+import "hkdj-web-server/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..ac3ae5a
--- /dev/null
+++ b/app/api/proto/richtext.go
@@ -0,0 +1,29 @@
+package proto
+
+import "hkdj-web-server/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..866b6dc
--- /dev/null
+++ b/app/api/proto/translation.go
@@ -0,0 +1,25 @@
+package proto
+
+import "hkdj-web-server/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..cbdd198
--- /dev/null
+++ b/app/api/proto/video.go
@@ -0,0 +1,428 @@
+package proto
+
+import (
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+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..d2cf3a4
--- /dev/null
+++ b/app/api/proto/videotopic.go
@@ -0,0 +1,27 @@
+package proto
+
+import (
+ "hkdj-web-server/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..3fcb5bf
--- /dev/null
+++ b/app/api/proto/webdata.go
@@ -0,0 +1,215 @@
+package proto
+
+import (
+ "hkdj-web-server/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..d7930bc
--- /dev/null
+++ b/app/api/rechargeapi/recharge.go
@@ -0,0 +1,85 @@
+package rechargeapi
+
+import (
+ "hkdj-web-server/app/service/rechargeser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/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
+}
+
+// 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
+ }
+
+ resp, serr := rechargeser.CreateOrder(ctx, userId, user.NickName, req.PackageId, req.PayMode, sysType, devType, ip, user.DistrictCode)
+ 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..32abf53
--- /dev/null
+++ b/app/api/rechargepackageapi/package.go
@@ -0,0 +1,30 @@
+package rechargepackageapi
+
+import (
+ "hkdj-web-server/app/service/rechargepackageser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/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) {
+ sysType := ctx.GetString(consts.CtxSysType)
+ if sysType == "" {
+ sysType = "android"
+ }
+ resp, err := rechargepackageser.GetRechargePackageList(ctx, sysType)
+ 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..dc08c01
--- /dev/null
+++ b/app/api/statcenterapi/sync.go
@@ -0,0 +1,437 @@
+package statcenterapi
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/statcenter"
+ "hkdj-web-server/pub/models/cointransmod"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/models/uservisitlog"
+ "hkdj-web-server/pub/models/vipordermod"
+ "hkdj-web-server/pub/pubdef"
+ "math"
+ "net/http"
+ "strconv"
+
+ "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..3523270
--- /dev/null
+++ b/app/api/translationapi/translation.go
@@ -0,0 +1,22 @@
+package translationapi
+
+import (
+ "hkdj-web-server/app/service/translationser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/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..da881b7
--- /dev/null
+++ b/app/api/uploadapi/upload.go
@@ -0,0 +1,49 @@
+package uploadapi
+
+import (
+ "net/http"
+
+ "hkdj-web-server/app/service/uploadser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/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..660a408
--- /dev/null
+++ b/app/api/usercenterapi/user_center.go
@@ -0,0 +1,31 @@
+package usercenterapi
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/usercenterser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/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..d08664a
--- /dev/null
+++ b/app/api/videoapi/video.go
@@ -0,0 +1,246 @@
+package videoapi
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/videoser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/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..0f62bfa
--- /dev/null
+++ b/app/appg/appg.go
@@ -0,0 +1,123 @@
+package appg
+
+import (
+ "hkdj-web-server/pub/common/ipaddr"
+
+ "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"`
+ 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/app.yaml b/app/conf/app.yaml
new file mode 100644
index 0000000..689de0a
--- /dev/null
+++ b/app/conf/app.yaml
@@ -0,0 +1,33 @@
+redis:
+ addr: ${HKDJNET_REDIS_ADDR}
+ db: 24
+ passwd: ${HKDJNET_REDIS_PASSWORD}
+mongodb:
+ addr: ${HKDJNET_MONGODB_URI}
+ logaddr: ${HKDJNET_LOG_MONGODB_URI}
+es:
+ addr: ${HKDJNET_ES_URL}
+ user: ${HKDJNET_ES_USERNAME}
+ pass: ${HKDJNET_ES_PASSWORD}
+jwtKey: ${HKDJNET_APP_JWT_KEY}
+resKey: ${HKDJNET_RES_KEY}
+tsAuthKey: ${HKDJNET_TS_AUTH_KEY}
+runMod: test
+host: "http://172.104.181.99:30334"
+listenAddr: "30331"
+trustedProxies: "127.0.0.1"
+pprof: false
+master: true
+httpOptions:
+ dcCtxTimeOut: 30
+attack:
+ enable: false
+h5attack:
+ enable: false
+tgToken: ""
+dataReport:
+ url: ""
+lsjMediaConf:
+ apiUrl: ""
+adCenter:
+ apiDomain: ""
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/main.go b/app/main.go
new file mode 100644
index 0000000..32a84ae
--- /dev/null
+++ b/app/main.go
@@ -0,0 +1,54 @@
+//go:generate mtool replicate-module -m contest
+package main
+
+import (
+ "hkdj-web-server/app/server"
+ "os"
+)
+
+// @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..d8ab7af
--- /dev/null
+++ b/app/middleware/auth/http.go
@@ -0,0 +1,137 @@
+package auth
+
+import (
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/pubdef"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+// gin 验证中间件
+
+var (
+ // 白名单,不需要验证token的api
+ whiteList = []string{
+ "/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",
+ }
+)
+
+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..a7cfa1b
--- /dev/null
+++ b/app/middleware/ipblacklist/ipblacklist.go
@@ -0,0 +1,18 @@
+package ipblacklist
+
+import (
+ "errors"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/pub/common/ipaddr"
+ "net/http"
+
+ "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..aceb2bf
--- /dev/null
+++ b/app/middleware/ipstat/ipstat.go
@@ -0,0 +1,78 @@
+package ipstat
+
+import (
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/pubdef"
+ "hkdj-web-server/pub/redis"
+ "net"
+ "time"
+
+ "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..acd4bdc
--- /dev/null
+++ b/app/middleware/monitor/monitor.go
@@ -0,0 +1,266 @@
+package monitor
+
+import (
+ "bytes"
+ "fmt"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/godef"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/tgbot"
+ "hkdj-web-server/pub/redis"
+ "io/ioutil"
+ "math"
+ "net/http"
+ "runtime"
+ "sort"
+ "time"
+
+ "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..be08ffe
--- /dev/null
+++ b/app/middleware/reptilerecord/reptilerecord.go
@@ -0,0 +1,50 @@
+package reptilerecord
+
+import (
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/log"
+ "strings"
+
+ "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..bfa80df
--- /dev/null
+++ b/app/middleware/visit/visit.go
@@ -0,0 +1,114 @@
+package visit
+
+import (
+ "context"
+ "hkdj-web-server/app/middleware/reptilerecord"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/useragent"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/models/uservisitlog"
+ "hkdj-web-server/pub/redis"
+ "strings"
+ "time"
+
+ "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..76fefde
--- /dev/null
+++ b/app/router/appinfo.go
@@ -0,0 +1,11 @@
+package router
+
+import (
+ "hkdj-web-server/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..3e4e013
--- /dev/null
+++ b/app/router/auth.go
@@ -0,0 +1,19 @@
+package router
+
+import (
+ "hkdj-web-server/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..a2656fd
--- /dev/null
+++ b/app/router/coin.go
@@ -0,0 +1,17 @@
+package router
+
+import (
+ "hkdj-web-server/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..a4840da
--- /dev/null
+++ b/app/router/comment.go
@@ -0,0 +1,18 @@
+package router
+
+import (
+ "hkdj-web-server/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..3594396
--- /dev/null
+++ b/app/router/config.go
@@ -0,0 +1,13 @@
+package router
+
+import (
+ configapi "hkdj-web-server/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..3f28f4f
--- /dev/null
+++ b/app/router/contactus.go
@@ -0,0 +1,11 @@
+package router
+
+import (
+ "hkdj-web-server/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..0a9fa1a
--- /dev/null
+++ b/app/router/feedback.go
@@ -0,0 +1,11 @@
+package router
+
+import (
+ "hkdj-web-server/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..b94e8c2
--- /dev/null
+++ b/app/router/gin.go
@@ -0,0 +1,118 @@
+package router
+
+import (
+ "hkdj-web-server/app/api/home"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/app/middleware/auth"
+ "hkdj-web-server/app/middleware/cors"
+ "hkdj-web-server/app/middleware/ipblacklist"
+ "hkdj-web-server/app/middleware/ipstat"
+ "hkdj-web-server/app/middleware/reptilerecord"
+ "hkdj-web-server/app/middleware/templatecache"
+ "hkdj-web-server/app/middleware/visit"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/useragent"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/DeanThompson/ginpprof"
+ "github.com/gin-gonic/gin"
+)
+
+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.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)
+ r.GET("/notpage", templatecache.WithCache(time.Minute*1), home.PageNotFound)
+
+}
diff --git a/app/router/health.go b/app/router/health.go
new file mode 100644
index 0000000..917d29a
--- /dev/null
+++ b/app/router/health.go
@@ -0,0 +1,11 @@
+package router
+
+import (
+ "hkdj-web-server/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..1e15919
--- /dev/null
+++ b/app/router/navigation.go
@@ -0,0 +1,11 @@
+package router
+
+import (
+ "hkdj-web-server/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..95cec30
--- /dev/null
+++ b/app/router/playlet.go
@@ -0,0 +1,45 @@
+package router
+
+import (
+ "hkdj-web-server/app/api/playletapi"
+ "hkdj-web-server/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/detail/list", playletapi.HomeConfigDetailList)
+ group.GET("/all/list", playletapi.AllList)
+ 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.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..ec86413
--- /dev/null
+++ b/app/router/playlettheme.go
@@ -0,0 +1,14 @@
+package router
+
+import (
+ "hkdj-web-server/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..5931c04
--- /dev/null
+++ b/app/router/recharge.go
@@ -0,0 +1,15 @@
+package router
+
+import (
+ "hkdj-web-server/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..916390b
--- /dev/null
+++ b/app/router/rechargepackage.go
@@ -0,0 +1,14 @@
+package router
+
+import (
+ "hkdj-web-server/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/statcenter.go b/app/router/statcenter.go
new file mode 100644
index 0000000..3382e31
--- /dev/null
+++ b/app/router/statcenter.go
@@ -0,0 +1,14 @@
+package router
+
+import (
+ "hkdj-web-server/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..03ab5fd
--- /dev/null
+++ b/app/router/translation.go
@@ -0,0 +1,14 @@
+package router
+
+import (
+ "hkdj-web-server/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..0878c6e
--- /dev/null
+++ b/app/router/upload.go
@@ -0,0 +1,14 @@
+package router
+
+import (
+ "hkdj-web-server/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..4654fc4
--- /dev/null
+++ b/app/router/user_center.go
@@ -0,0 +1,14 @@
+package router
+
+import (
+ "hkdj-web-server/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..37e8faa
--- /dev/null
+++ b/app/router/video.go
@@ -0,0 +1,40 @@
+package router
+
+import (
+ "hkdj-web-server/app/api/videoapi"
+ "hkdj-web-server/app/middleware/templatecache"
+ "hkdj-web-server/app/service/videoser"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "hkdj-web-server/app/api/home"
+)
+
+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..6269733
--- /dev/null
+++ b/app/server/server.go
@@ -0,0 +1,187 @@
+package server
+
+import (
+ "fmt"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/app/router"
+ "hkdj-web-server/app/service/appser"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/app/service/playletcache"
+ "hkdj-web-server/app/service/translationser"
+ ipstatsync "hkdj-web-server/app/skd/ipstat"
+ "hkdj-web-server/app/skd/seo"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvversion"
+ "hkdj-web-server/pub/common/tgbot"
+ "hkdj-web-server/pub/dataReport"
+ "hkdj-web-server/pub/esmanager"
+ "hkdj-web-server/pub/models"
+ "hkdj-web-server/pub/mongos"
+ "hkdj-web-server/pub/redis"
+ "hkdj-web-server/pub/runtimecfg"
+ "hkdj-web-server/pub/storage/proxy"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+)
+
+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()
+ if err := initRedis(); err != nil {
+ os.Exit(1)
+ }
+ 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 {
+ return runtimecfg.Load("conf/app.yaml", &appg.Conf)
+}
+
+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..5ec4561
--- /dev/null
+++ b/app/service/appser/appinfo.go
@@ -0,0 +1,12 @@
+package appser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/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..77bf4d5
--- /dev/null
+++ b/app/service/appser/cache.go
@@ -0,0 +1,199 @@
+package appser
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/pub/common/http"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/pubdef"
+ "hkdj-web-server/pub/storage/proxy"
+ "net/url"
+ "strings"
+ "sync"
+
+ "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)
+ if strings.TrimSpace(domain) == "" {
+ return
+ }
+ 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/authser/auth.go b/app/service/authser/auth.go
new file mode 100644
index 0000000..248c85f
--- /dev/null
+++ b/app/service/authser/auth.go
@@ -0,0 +1,486 @@
+package authser
+
+import (
+ "context"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/app/service/reportser"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/useragent"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/mongos"
+ "hkdj-web-server/pub/pubdef"
+ "strings"
+ "time"
+
+ "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, "账号已存在", "")
+ }
+ // 检查邮箱是否已存在
+ 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)
+ applyChannelIfMissing(setDoc, guest, channel)
+ 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)
+ applyChannelIfMissing(setDoc, user, channel)
+ _ = 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..8d3c239
--- /dev/null
+++ b/app/service/authser/district.go
@@ -0,0 +1,68 @@
+package authser
+
+import (
+ "encoding/json"
+ "fmt"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/statcenter"
+ "hkdj-web-server/pub/common/utils"
+ "net/http"
+ "strings"
+)
+
+// 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..e6a33cc
--- /dev/null
+++ b/app/service/authser/guest.go
@@ -0,0 +1,277 @@
+package authser
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/app/middleware/reptilerecord"
+ "hkdj-web-server/app/middleware/visit"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/app/service/reportser"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/useragent"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/mongos"
+ "hkdj-web-server/pub/pubdef"
+ "strings"
+ "time"
+
+ "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)
+ applyChannelIfMissing(setDoc, user, req.Channel)
+ _ = 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 hasDistrictCode(code string) bool {
+ return strings.TrimSpace(code) != ""
+}
+
+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
+}
+
+func applyChannelIfMissing(setDoc bson.M, user *usermod.User, channel string) {
+ if user == nil || hasDistrictCode(user.DistrictCode) {
+ return
+ }
+ if ch := normalizeChannel(channel); ch != "" {
+ setDoc[usermod.FN.User.DistrictCode] = ch
+ user.DistrictCode = ch
+ }
+}
diff --git a/app/service/coinser/coin_buy.go b/app/service/coinser/coin_buy.go
new file mode 100644
index 0000000..07ac0ab
--- /dev/null
+++ b/app/service/coinser/coin_buy.go
@@ -0,0 +1,188 @@
+package coinser
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "hkdj-web-server/app/service/rechargeser/jiahua"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/useragent"
+ "hkdj-web-server/pub/dataReport"
+ "hkdj-web-server/pub/models/eventmod"
+ "hkdj-web-server/pub/models/rechargepackagemod"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/models/vipordermod"
+ "hkdj-web-server/pub/mongos"
+ "hkdj-web-server/pub/pubdef"
+ "strconv"
+ "strings"
+ "time"
+
+ "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, ip string,
+ districtCode 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 := jiahua.SubmitOrder(ctx, userId, userName, tradeNo, pkg.Price, payMode, sysType, devType, 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),
+ 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
+ }
+
+ ua := useragent.UA{SysType: 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),
+ 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 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..a3d1e6c
--- /dev/null
+++ b/app/service/coinser/coin_trans.go
@@ -0,0 +1,254 @@
+package coinser
+
+import (
+ "bytes"
+ "context"
+ "encoding/csv"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/pub/common/response"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/models/cointransmod"
+ "hkdj-web-server/pub/pubdef"
+ "strconv"
+ "time"
+
+ "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..1f24bf8
--- /dev/null
+++ b/app/service/commentser/comment.go
@@ -0,0 +1,705 @@
+package commentser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/app/service/reportser"
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/models/commentlikemod"
+ "hkdj-web-server/pub/models/commentmod"
+ "hkdj-web-server/pub/models/playletchaptermod"
+ "hkdj-web-server/pub/models/playletmod"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/mongos"
+ "hkdj-web-server/pub/redis"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "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..6cea50f
--- /dev/null
+++ b/app/service/configser/announcement.go
@@ -0,0 +1,97 @@
+package configser
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/pub/adcenter"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/storage/proxy"
+ "strconv"
+)
+
+// 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..9a8f7b1
--- /dev/null
+++ b/app/service/configser/catch.go
@@ -0,0 +1,577 @@
+package configser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/translationser"
+ "hkdj-web-server/pub/adcenter"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/dataReport"
+ "hkdj-web-server/pub/models/announcementmod"
+ "hkdj-web-server/pub/models/cfg"
+ "hkdj-web-server/pub/models/ipblackmod"
+ "hkdj-web-server/pub/models/richtextcontentmod"
+ "hkdj-web-server/pub/models/videotagmod"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+
+ "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
+
+ // 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..8c5c250
--- /dev/null
+++ b/app/service/configser/cdn.go
@@ -0,0 +1,47 @@
+package configser
+
+import (
+ "context"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/pub/common/ipaddr"
+ "strings"
+
+ "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..54f8c9f
--- /dev/null
+++ b/app/service/configser/h265.go
@@ -0,0 +1,22 @@
+package configser
+
+import "hkdj-web-server/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..dd7b528
--- /dev/null
+++ b/app/service/contactusser/contactus.go
@@ -0,0 +1,93 @@
+package contactusser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/models/contactusmod"
+ "net/mail"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+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..6b51969
--- /dev/null
+++ b/app/service/feedbackser/feedback.go
@@ -0,0 +1,77 @@
+package feedbackser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/models/feedbackmod"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+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..f90b1b8
--- /dev/null
+++ b/app/service/healthser/health.go
@@ -0,0 +1,220 @@
+package healthser
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "math"
+ "net/http"
+ "os"
+ "runtime"
+ "strings"
+ "time"
+
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "hkdj-web-server/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
+)
+
+func HealthCheck(ctx context.Context, req *proto.HealthCheckRequest) (*proto.HealthCheckData, *srvdef.SrvErr) {
+ healthGroupID := os.Getenv("HKDJNET_HEALTH_GROUP_ID")
+ healthSecretKey := os.Getenv("HKDJNET_HEALTH_SECRET")
+ if healthGroupID == "" || healthSecretKey == "" {
+ return nil, srvdef.New(srvdef.ErrParamError, "health authentication is not configured")
+ }
+ 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..9f91a2d
--- /dev/null
+++ b/app/service/navigationser/header.go
@@ -0,0 +1,134 @@
+package navigationser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/models/translationcategorymod"
+ "hkdj-web-server/pub/models/translationmod"
+ "hkdj-web-server/pub/pubdef"
+ "strings"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+const (
+ headerNavigationCategoryLimit int64 = 30
+ headerNavigationInfoLimit int64 = 50
+)
+
+var (
+ headerNavigationCategoryProjection = bson.D{
+ {Key: translationcategorymod.FN.TranslationCategory.Id, Value: 1},
+ {Key: translationcategorymod.FN.TranslationCategory.Name, Value: 1},
+ {Key: translationcategorymod.FN.TranslationCategory.Icon, Value: 1},
+ {Key: translationcategorymod.FN.TranslationCategory.CategoryType, Value: 1},
+ {Key: translationcategorymod.FN.TranslationCategory.UrlValue, Value: 1},
+ }
+ headerNavigationInfoProjection = bson.D{
+ {Key: translationmod.FN.TranslationInfo.Id, Value: 1},
+ {Key: translationmod.FN.TranslationInfo.Name, Value: 1},
+ {Key: translationmod.FN.TranslationInfo.TransCategoryId, Value: 1},
+ {Key: translationmod.FN.TranslationInfo.IsFriendAds, Value: 1},
+ {Key: translationmod.FN.TranslationInfo.Link, Value: 1},
+ }
+)
+
+// HeaderNavigation 查询头部导航配置
+func HeaderNavigation(ctx context.Context) (proto.AppTranslationInfo, *srvdef.SrvErr) {
+ categoryList, err := translationcategorymod.FindManyTranslationCategorys(ctx, bson.M{
+ translationcategorymod.FN.TranslationCategory.Status: true,
+ translationcategorymod.FN.TranslationCategory.CategoryType: bson.M{
+ "$in": []pubdef.TranslationType{
+ pubdef.TranslationTypeUrl,
+ pubdef.TranslationTypeSub,
+ },
+ },
+ }, options.Find().
+ SetProjection(headerNavigationCategoryProjection).
+ SetSort(bson.D{
+ {Key: translationcategorymod.FN.TranslationCategory.Rank, Value: -1},
+ {Key: translationcategorymod.FN.TranslationCategory.Id, Value: -1},
+ }).
+ SetLimit(headerNavigationCategoryLimit))
+ if err != nil {
+ return proto.AppTranslationInfo{}, err
+ }
+ resp := proto.AppTranslationInfo{CategoryList: make([]proto.TranslationCategory, 0, len(categoryList))}
+ if len(categoryList) == 0 {
+ return resp, nil
+ }
+ categoryIds := make([]int64, 0, len(categoryList))
+ for _, item := range categoryList {
+ if item != nil && item.Id > 0 {
+ categoryIds = append(categoryIds, item.Id)
+ }
+ }
+ translationListByCategory, err := queryHeaderTranslationList(ctx, categoryIds)
+ if err != nil {
+ return proto.AppTranslationInfo{}, err
+ }
+ imgCdn := configser.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,
+ 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..117bf14
--- /dev/null
+++ b/app/service/playletcache/cache.go
@@ -0,0 +1,499 @@
+package playletcache
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/models/hotsearchmod"
+ "hkdj-web-server/pub/models/playletmod"
+ "hkdj-web-server/pub/models/playlettagmod"
+ "hkdj-web-server/pub/models/playletthememod"
+ "hkdj-web-server/pub/pubdef"
+ "sort"
+ "strings"
+ "sync"
+
+ "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..2fc374d
--- /dev/null
+++ b/app/service/playletcache/guess_like.go
@@ -0,0 +1,101 @@
+package playletcache
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/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/playlet.go b/app/service/playletser/playlet.go
new file mode 100644
index 0000000..aa0ab1b
--- /dev/null
+++ b/app/service/playletser/playlet.go
@@ -0,0 +1,5003 @@
+package playletser
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/app/service/playletcache"
+ "hkdj-web-server/app/service/reportser"
+ "hkdj-web-server/app/service/videoser"
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/esmanager"
+ "hkdj-web-server/pub/models/cointransmod"
+ "hkdj-web-server/pub/models/esmod"
+ "hkdj-web-server/pub/models/homeconfigmod"
+ "hkdj-web-server/pub/models/hotsearchmod"
+ "hkdj-web-server/pub/models/playletchaptercollectmod"
+ "hkdj-web-server/pub/models/playletchapterlikemod"
+ "hkdj-web-server/pub/models/playletchaptermod"
+ "hkdj-web-server/pub/models/playletchapterunlockmod"
+ "hkdj-web-server/pub/models/playletcollectmod"
+ "hkdj-web-server/pub/models/playletlikemod"
+ "hkdj-web-server/pub/models/playletmod"
+ "hkdj-web-server/pub/models/playletthememod"
+ "hkdj-web-server/pub/models/playletwatchhistorymod"
+ "hkdj-web-server/pub/models/playletwatchprogressmod"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/mongos"
+ "hkdj-web-server/pub/payment"
+ "hkdj-web-server/pub/pubdef"
+ "hkdj-web-server/pub/redis"
+ "hkdj-web-server/pub/seo/h5path"
+ "hkdj-web-server/web/service/srv_search"
+ "strconv"
+ "strings"
+ "time"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+const (
+ homeBannerLimit int64 = 30 // banner图限制
+ homeConfigPlayletLimit int64 = 12 // 首页配置短剧数量限制
+ homeConfigThemeLimit int = 3 // 首页配置题材数量限制
+ homeConfigVideoLimit int64 = 12 // 首页配置视频数量限制
+ homeConfigDetailSize int64 = 10 // 首页配置详情默认分页条数
+ defaultPageSize int64 = 10 // 默认分页条数
+ guessLikePageSize int64 = 6 // 猜你喜欢默认分页条数
+ hotRankPageSize int64 = 20 // 热榜默认分页条数
+ recommendPageSize int64 = 20 // 今日推荐/热门好剧默认分页条数
+ maxPageSize int64 = 100 // 最大分页条数
+ allListSortLatest int64 = 1 // 最新
+ allListSortHot int64 = 2 // 最热
+ allListSortCollect int64 = 3 // 收藏最多
+ topicListSortLatest int64 = 1 // 最新
+ topicListSortHot int64 = 2 // 最热
+ topicListPlayletLimit int64 = 3 // 专题卡片短剧数量限制
+ topicDetailSortEdit int64 = 1 // 编辑排序
+ topicDetailSortLatest int64 = 2 // 最新
+ topicDetailSortHot int64 = 3 // 最热
+ topicDetailPageSize int64 = 12 // 专题详情默认分页条数
+ topicDetailCoverLimit int64 = 3 // 专题详情顶部封面数量限制
+ topicDetailMoreLimit int64 = 3 // 更多专题数量限制
+ operateFilterLatest int64 = 1 // 最近更新
+ operateFilterHot int64 = 2 // 当前热播
+ operateFilterOriginal int64 = 3 // 独家原创
+ 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-category-v5"
+ // playletH5DetailPath 短剧Web/H5播放页路径,供SEO canonical使用。
+ playletH5DetailPath = h5path.DramaPath
+ // playletChapterH5DetailPath 短剧Web/H5剧集播放页路径,供SEO canonical使用。
+ playletChapterH5DetailPath = h5path.DramaPath
+)
+
+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
+}
+
+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.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.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.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.Type, Value: 1},
+ {Key: homeconfigmod.FN.HomeConfig.Rank, 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},
+}
+
+// homeConfigVideoProjection 查询首页配置视频指定字段
+var homeConfigVideoProjection = 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},
+}
+
+// playletCollectListProjection 我的收藏列表只读取卡片展示和未看集数计算需要的字段。
+var playletCollectListProjection = 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.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
+}
+
+// HomeConfigDetailList 分页查询首页配置详情数据
+func HomeConfigDetailList(ctx context.Context, req *proto.HomeConfigDetailListReq) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) {
+ if req == nil {
+ return nil, srvdef.New(srvdef.ErrParamError, "首页配置无效", "")
+ }
+ config, err := findEnabledHomeConfig(ctx, req.HomeConfigId, req.HomeConfigSlug)
+ if err != nil {
+ return nil, err
+ }
+ pageNum, pageSize := normalizeHomeConfigDetailPage(req.PageNum, req.PageSize)
+ resp := &proto.HomeConfigDetailListResp{
+ HomeConfigId: config.Id,
+ HomeConfigSlug: config.Slug,
+ Name: config.Name,
+ Desc: config.Desc,
+ Type: config.Type,
+ SortType: config.SortType,
+ PageSize: int(pageSize),
+ PageNum: int(pageNum),
+ List: make([]*proto.CommonData, 0),
+ VideoList: make([]*proto.VideoItemResp, 0),
+ ThemeList: make([]*proto.HomeConfigThemeData, 0),
+ Advitise: configser.GetAdvitise(),
+ SeoInfo: config.SeoInfo,
+ }
+ switch config.Type {
+ case homeconfigmod.TypePlaylet:
+ return fillHomeConfigPlayletDetail(ctx, resp, config.SortType, req, pageNum, pageSize)
+ case homeconfigmod.TypeVideo:
+ return fillHomeConfigVideoDetail(ctx, resp, config.SortType, req, pageNum, pageSize)
+ case homeconfigmod.TypeTheme:
+ return fillHomeConfigThemeDetail(ctx, resp, config.SortType, pageNum, pageSize)
+ default:
+ return nil, srvdef.New(srvdef.ErrParamError, "类型无效", "")
+ }
+}
+
+// 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
+}
+
+// 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, totalCount, playlets, err := findPlayletTopicPage(ctx, req.SortType, skip, pageSize)
+ if err != nil {
+ return nil, err
+ }
+ resp := &proto.PlayletTopicPageListResp{
+ TotalCount: totalCount,
+ PageSize: int(pageSize),
+ PageNum: int(pageNum),
+ List: make([]*proto.PlayletTopicPageItem, 0),
+ SeoInfo: buildPlayletListPageSeoInfo(),
+ }
+ if totalCount == 0 || len(groups) == 0 {
+ return resp, nil
+ }
+ themeNameMap, err := buildHomeThemeNameMap(ctx, playlets)
+ if err != nil {
+ log.Error("查询专题剧单题材名称失败", log.E(err))
+ return nil, err
+ }
+ tagNameMap, err := buildHomeTagNameMap(ctx, playlets)
+ if err != nil {
+ log.Error("查询专题剧单标签名称失败", log.E(err))
+ return nil, err
+ }
+ resp.List = playletTopicsToPageData(ctx, groups, themeNameMap, tagNameMap)
+ return resp, nil
+}
+
+// TopicDetail 查询app端专题详情
+func TopicDetail(ctx context.Context, req *proto.PlayletTopicDetailReq) (*proto.PlayletTopicDetailResp, *srvdef.SrvErr) {
+ if req == nil || req.Id <= 0 {
+ return nil, srvdef.New(srvdef.ErrParamError, "专题ID无效", "")
+ }
+ theme := playletcache.GetEnabledTheme(req.Id)
+ if theme == nil {
+ return nil, srvdef.New(srvdef.ErrNoData, "专题不存在或已下架", "专题不存在或已下架")
+ }
+ pageNum, pageSize := normalizePlayletTopicDetailPage(req.PageNum, req.PageSize)
+ filter := homeUpPlayletFilter(bson.M{
+ playletmod.FN.Playlet.ThemeIds: req.Id,
+ })
+ totalCount, err := playletmod.CountPlaylet(ctx, filter)
+ if err != nil {
+ return nil, err
+ }
+ resp := &proto.PlayletTopicDetailResp{
+ Id: theme.Id,
+ Name: theme.Name,
+ Slug: theme.Slug,
+ Desc: strings.TrimSpace(theme.SeoInfo.Description),
+ Icon: withImageDomain(ctx, theme.Icon),
+ Rank: theme.Rank,
+ PlayletCount: totalCount,
+ 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))
+ 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, "", "")
+ }
+ operateType := playletmod.OperateType(req.OperateType)
+ if !playletmod.IsValidOperateType(operateType) {
+ return nil, srvdef.New(srvdef.ErrParamError, "运营类型无效", "")
+ }
+ filterType := req.FilterType
+ if filterType == 0 {
+ filterType = operateFilterLatest
+ }
+ if filterType != operateFilterLatest && filterType != operateFilterHot && filterType != operateFilterOriginal {
+ return nil, srvdef.New(srvdef.ErrParamError, "筛选类型无效", "")
+ }
+ pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
+ filter := homeUpPlayletFilter(operateTypeFilter(operateType))
+ if filterType == operateFilterOriginal {
+ filter[playletmod.FN.Playlet.IsRecommend] = true
+ }
+ totalCount, err := playletmod.CountPlaylet(ctx, filter)
+ if err != nil {
+ return nil, err
+ }
+ resp := &proto.PlayletOperateListResp{
+ TotalCount: totalCount,
+ PageSize: int(pageSize),
+ PageNum: int(pageNum),
+ OperateType: int32(operateType),
+ FilterType: filterType,
+ List: make([]proto.CommonData, 0),
+ }
+ if totalCount == 0 {
+ return resp, nil
+ }
+ skip := (pageNum - 1) * pageSize
+ if skip >= totalCount {
+ return resp, nil
+ }
+ list, err := findHomePlaylets(ctx, filter, playletOperateListSort(filterType), 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
+ }
+ for _, hit := range hits.Hits {
+ if hit == nil || hit.Source == nil {
+ continue
+ }
+ resp.List = append(resp.List, esPlayletToCommonData(ctx, hit.Source))
+ }
+ 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
+ }
+ for _, hit := range hits.Hits {
+ if hit == nil || hit.Source == nil {
+ continue
+ }
+ resp.List = append(resp.List, esPlayletToCommonData(ctx, hit.Source))
+ }
+ 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,
+ 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,
+ 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{}, "", "", h5path.DramaListPath)
+}
+
+// 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 专题详情页相对路径(对齐 hkdj-web /collection/:slug)
+func buildPlayletThemeListCanonicalURL(slug string, themeId int64) string {
+ return h5path.TopicDetail(slug, themeId)
+}
+
+// buildPlayletTagListCanonicalURL 标签列表页相对路径(对齐 hkdj-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),
+ HomeConfigData: make([]*proto.HomeConfigData, 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, configVideoMap, 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)
+ resp.HomeConfigData = homeConfigsToHomeData(ctx, configList, configPlayletMap, configVideoMap, configThemeMap, themeNameMap, tagNameMap)
+ 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()
+}
+
+// fillHomeConfigPlayletDetail 分页填充首页配置短剧数据
+func fillHomeConfigPlayletDetail(ctx context.Context, resp *proto.HomeConfigDetailListResp, sortType int32, req *proto.HomeConfigDetailListReq, pageNum, pageSize int64) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) {
+ if sortType == homeconfigmod.SortTypeRecommend {
+ return fillHomeConfigRecommendPlayletDetail(ctx, resp, req, pageNum, pageSize)
+ }
+ filter, sort := homeConfigPlayletQuery(sortType, homeConfigCategoryID(sortType, resp.HomeConfigId))
+ if err := applyHomeConfigPlayletDetailFilter(ctx, filter, req); err != nil {
+ return nil, err
+ }
+ 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
+ }
+ categoryId := homeConfigCategoryID(sortType, resp.HomeConfigId)
+ list, err := findHomePlayletsByHomeConfigSort(ctx, filter, sortType, categoryId, sort, skip, pageSize)
+ if err != nil {
+ return nil, err
+ }
+ return fillHomeConfigPlayletDetailList(ctx, resp, list)
+}
+
+func fillHomeConfigRecommendPlayletDetail(ctx context.Context, resp *proto.HomeConfigDetailListResp, req *proto.HomeConfigDetailListReq, pageNum, pageSize int64) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) {
+ baseFilter := homeUpPlayletFilter(nil)
+ if err := applyHomeConfigPlayletDetailFilter(ctx, baseFilter, req); err != nil {
+ return nil, err
+ }
+ totalCount, err := playletmod.CountPlaylet(ctx, baseFilter)
+ 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
+ }
+
+ recommendFilter, recommendSort := homeConfigPlayletQuery(homeconfigmod.SortTypeRecommend, 0)
+ if err := applyHomeConfigPlayletDetailFilter(ctx, recommendFilter, req); err != nil {
+ return nil, err
+ }
+ recommendTotal, err := playletmod.CountPlaylet(ctx, recommendFilter)
+ if err != nil {
+ return nil, err
+ }
+
+ list := make(playletmod.PlayletList, 0, pageSize)
+ if skip < recommendTotal {
+ recommendLimit := pageSize
+ if left := recommendTotal - skip; left < recommendLimit {
+ recommendLimit = left
+ }
+ recommendList, err := findHomePlaylets(ctx, recommendFilter, recommendSort, recommendLimit, options.Find().SetSkip(skip))
+ if err != nil {
+ return nil, err
+ }
+ list = append(list, recommendList...)
+ }
+
+ remain := pageSize - int64(len(list))
+ if remain > 0 {
+ fallbackFilter, fallbackSort := homeConfigPlayletQuery(homeconfigmod.SortTypeLatest, 0)
+ if err := applyHomeConfigPlayletDetailFilter(ctx, fallbackFilter, req); err != nil {
+ return nil, err
+ }
+ fallbackFilter[playletmod.FN.Playlet.IsRecommend] = bson.M{"$ne": true}
+ fallbackSkip := int64(0)
+ if skip > recommendTotal {
+ fallbackSkip = skip - recommendTotal
+ }
+ fallbackList, err := findHomePlaylets(ctx, fallbackFilter, fallbackSort, remain, options.Find().SetSkip(fallbackSkip))
+ if err != nil {
+ return nil, err
+ }
+ list = append(list, fallbackList...)
+ }
+ return fillHomeConfigPlayletDetailList(ctx, resp, list)
+}
+
+func fillHomeConfigPlayletDetailList(ctx context.Context, resp *proto.HomeConfigDetailListResp, list playletmod.PlayletList) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) {
+ themeNameMap, err := buildHomeThemeNameMap(ctx, list)
+ if err != nil {
+ return nil, err
+ }
+ tagNameMap, err := buildHomeTagNameMap(ctx, list)
+ if err != nil {
+ return nil, err
+ }
+ resp.List = playletsToHomeCommonData(ctx, list, themeNameMap, tagNameMap)
+ return resp, nil
+}
+
+// applyHomeConfigPlayletDetailFilter 追加首页配置详情短剧筛选条件
+func applyHomeConfigPlayletDetailFilter(ctx context.Context, filter bson.M, req *proto.HomeConfigDetailListReq) *srvdef.SrvErr {
+ if req == nil {
+ return nil
+ }
+ if req.OperateType > 0 {
+ operateType := playletmod.OperateType(req.OperateType)
+ if !playletmod.IsValidOperateType(operateType) {
+ return srvdef.New(srvdef.ErrParamError, "运营类型无效", "")
+ }
+ for key, value := range operateTypeFilter(operateType) {
+ filter[key] = value
+ }
+ }
+ themeID, err := resolveOptionalThemeID(ctx, req.ThemeId, req.ThemeSlug)
+ if err != nil {
+ return err
+ }
+ if themeID > 0 {
+ filter[playletmod.FN.Playlet.ThemeIds] = themeID
+ }
+ return applyHomeConfigTimeRangeFilter(filter, playletmod.FN.Playlet.OnlineAt, req.TimeRange)
+}
+
+// fillHomeConfigVideoDetail 分页填充首页配置视频数据
+func fillHomeConfigVideoDetail(ctx context.Context, resp *proto.HomeConfigDetailListResp, sortType int32, req *proto.HomeConfigDetailListReq, pageNum, pageSize int64) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) {
+ filter := homeConfigVideoFilter()
+ applyHomeConfigVideoCategoryFilter(filter, sortType, resp.HomeConfigId)
+ if err := applyHomeConfigVideoDetailFilter(filter, req); err != nil {
+ return nil, err
+ }
+ totalCount, err := videoinformationmod.CountVideoInformations(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 := findHomeConfigVideosPage(ctx, filter, skip, pageSize)
+ if err != nil {
+ return nil, err
+ }
+ resp.VideoList = videosToHomeConfigData(ctx, list)
+ return resp, nil
+}
+
+// applyHomeConfigVideoDetailFilter 追加首页配置详情视频筛选条件
+func applyHomeConfigVideoDetailFilter(filter bson.M, req *proto.HomeConfigDetailListReq) *srvdef.SrvErr {
+ if req == nil {
+ return nil
+ }
+ mediaType, err := homeConfigDetailVideoMediaType(req.MediaType)
+ if err != nil {
+ return err
+ }
+ if req.MediaType > 0 {
+ filter[videoinformationmod.FN.VideoInformation.MediaType] = mediaType
+ }
+ if req.TopicId > 0 {
+ filter[videoinformationmod.FN.VideoInformation.TopicIds] = req.TopicId
+ }
+ return applyHomeConfigTimeRangeFilter(filter, videoinformationmod.FN.VideoInformation.AddedTime, req.TimeRange)
+}
+
+// fillHomeConfigThemeDetail 分页填充首页配置题材数据
+func fillHomeConfigThemeDetail(ctx context.Context, resp *proto.HomeConfigDetailListResp, sortType int32, pageNum, pageSize int64) (*proto.HomeConfigDetailListResp, *srvdef.SrvErr) {
+ skip := (pageNum - 1) * pageSize
+ groups, totalCount, playlets, err := findHomeConfigThemePlayletsPage(ctx, sortType, skip, pageSize)
+ if err != nil {
+ return nil, err
+ }
+ resp.TotalCount = totalCount
+ if len(groups) == 0 {
+ return resp, nil
+ }
+ themeNameMap, err := buildHomeThemeNameMap(ctx, playlets)
+ if err != nil {
+ return nil, err
+ }
+ tagNameMap, err := buildHomeTagNameMap(ctx, playlets)
+ if err != nil {
+ return nil, err
+ }
+ resp.ThemeList = homeConfigThemesToHomeData(ctx, groups, themeNameMap, tagNameMap)
+ return resp, nil
+}
+
+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,
+ }, options.Find().
+ SetProjection(homeConfigAppProjection).
+ SetSort(bson.D{
+ {Key: homeconfigmod.FN.HomeConfig.Rank, Value: -1},
+ {Key: homeconfigmod.FN.HomeConfig.UpdatedAt, Value: -1},
+ {Key: homeconfigmod.FN.HomeConfig.Id, Value: -1},
+ }))
+}
+
+// findEnabledHomeConfig 按 slug 或 ID 查询启用的首页配置,slug 优先。
+func findEnabledHomeConfig(ctx context.Context, id int64, slug string) (*homeconfigmod.HomeConfig, *srvdef.SrvErr) {
+ slug = strings.TrimSpace(slug)
+ if slug != "" && !utils.IsSlugAllDigits(slug) {
+ return findEnabledHomeConfigByFilter(ctx, bson.M{
+ homeconfigmod.FN.HomeConfig.Slug: slug,
+ homeconfigmod.FN.HomeConfig.Status: true,
+ })
+ }
+ if id <= 0 && utils.IsSlugAllDigits(slug) {
+ if n, err := strconv.ParseInt(slug, 10, 64); err == nil {
+ id = n
+ }
+ }
+ if id <= 0 {
+ return nil, srvdef.New(srvdef.ErrParamError, "首页配置无效", "")
+ }
+ return findEnabledHomeConfigByFilter(ctx, bson.M{
+ homeconfigmod.FN.HomeConfig.Id: id,
+ homeconfigmod.FN.HomeConfig.Status: true,
+ })
+}
+
+func findEnabledHomeConfigByFilter(ctx context.Context, filter bson.M) (*homeconfigmod.HomeConfig, *srvdef.SrvErr) {
+ list, err := homeconfigmod.FindManyHomeConfig(ctx, filter, options.Find().
+ SetProjection(homeConfigAppProjection).
+ SetLimit(1))
+ if err != nil {
+ return nil, err
+ }
+ if len(list) == 0 || list[0] == nil {
+ return nil, srvdef.New(srvdef.ErrNoData, "", "")
+ }
+ return list[0], nil
+}
+
+// findHomeConfigPlaylets 按首页配置查询短剧集合
+func findHomeConfigPlaylets(ctx context.Context, configs homeconfigmod.HomeConfigList) (map[int64]playletmod.PlayletList, map[int64]videoinformationmod.VideoInformationList, map[int64][]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) {
+ configPlayletMap := make(map[int64]playletmod.PlayletList, len(configs))
+ configVideoMap := make(map[int64]videoinformationmod.VideoInformationList, 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 := findHomeConfigPlayletList(ctx, config.SortType, homeConfigCategoryID(config.SortType, config.Id))
+ if err != nil {
+ return nil, nil, nil, nil, err
+ }
+ configPlayletMap[config.Id] = list
+ allPlaylets = append(allPlaylets, list...)
+ case homeconfigmod.TypeVideo:
+ list, err := findHomeConfigVideos(ctx, config.SortType, config.Id)
+ if err != nil {
+ return nil, nil, nil, nil, err
+ }
+ configVideoMap[config.Id] = list
+ case homeconfigmod.TypeTheme:
+ groups, list, err := findHomeConfigThemePlaylets(ctx, config.SortType)
+ if err != nil {
+ return nil, nil, nil, nil, err
+ }
+ configThemeMap[config.Id] = groups
+ allPlaylets = append(allPlaylets, list...)
+ }
+ }
+ return configPlayletMap, configVideoMap, configThemeMap, allPlaylets, nil
+}
+
+func findHomeConfigPlayletList(ctx context.Context, sortType int32, categoryId int64) (playletmod.PlayletList, *srvdef.SrvErr) {
+ filter, sort := homeConfigPlayletQuery(sortType, categoryId)
+ list, err := findHomePlayletsByHomeConfigSort(ctx, filter, sortType, categoryId, sort, 0, homeConfigPlayletLimit)
+ if err != nil {
+ return nil, err
+ }
+ if sortType != homeconfigmod.SortTypeRecommend || int64(len(list)) >= homeConfigPlayletLimit {
+ return list, nil
+ }
+ filter, sort = homeConfigPlayletQuery(homeconfigmod.SortTypeLatest, 0)
+ if len(list) > 0 {
+ filter[playletmod.FN.Playlet.Id] = bson.M{"$nin": playletIdsFromList(list)}
+ }
+ moreList, err := findHomePlaylets(ctx, filter, sort, homeConfigPlayletLimit-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 findHomeConfigVideos(ctx context.Context, sortType int32, configId int64) (videoinformationmod.VideoInformationList, *srvdef.SrvErr) {
+ filter := homeConfigVideoFilter()
+ applyHomeConfigVideoCategoryFilter(filter, sortType, configId)
+ return findHomeConfigVideosPage(ctx, filter, 0, homeConfigVideoLimit)
+}
+
+// findHomeConfigVideosPage 分页查询首页配置视频
+func findHomeConfigVideosPage(ctx context.Context, filter bson.M, skip, limit int64) (videoinformationmod.VideoInformationList, *srvdef.SrvErr) {
+ if filter == nil {
+ filter = homeConfigVideoFilter()
+ }
+ return videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find().
+ SetProjection(homeConfigVideoProjection).
+ SetSort(bson.D{
+ {Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1},
+ {Key: videoinformationmod.FN.VideoInformation.Id, Value: -1},
+ }).
+ SetSkip(skip).
+ SetLimit(limit))
+}
+
+// homeConfigVideoFilter 构建首页配置视频查询条件
+func homeConfigVideoFilter() bson.M {
+ return bson.M{
+ videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
+ }
+}
+
+func applyHomeConfigVideoCategoryFilter(filter bson.M, sortType int32, configId int64) {
+ applyVideoCategoryIdsFilter(filter, homeConfigCategoryID(sortType, configId))
+}
+
+func applyVideoCategoryIdsFilter(filter bson.M, categoryId int64) {
+ if filter == nil || categoryId <= 0 {
+ return
+ }
+ filter[videoinformationmod.FN.VideoInformation.CategoryIds] = bson.M{"$in": []int64{categoryId}}
+}
+
+// resolveOptionalThemeID 解析可选题材ID
+func resolveOptionalThemeID(ctx context.Context, id int64, slug string) (int64, *srvdef.SrvErr) {
+ if strings.TrimSpace(slug) == "" && id <= 0 {
+ return 0, nil
+ }
+ return resolveThemeID(ctx, id, slug)
+}
+
+// homeConfigDetailVideoMediaType 转换首页配置详情视频类型
+func homeConfigDetailVideoMediaType(mediaType int64) (videoinformationmod.MediaType, *srvdef.SrvErr) {
+ switch mediaType {
+ case 0:
+ return 0, nil
+ case 1:
+ return videoinformationmod.MediaType_Collection, nil
+ case 2:
+ return videoinformationmod.MediaType_VideoV2, nil
+ default:
+ return 0, srvdef.New(srvdef.ErrParamError, "类型无效", "")
+ }
+}
+
+// applyHomeConfigTimeRangeFilter 追加首页配置详情时间范围
+func applyHomeConfigTimeRangeFilter(filter bson.M, field string, timeRange int64) *srvdef.SrvErr {
+ switch timeRange {
+ case 0:
+ return nil
+ case 1:
+ filter[field] = bson.M{"$gte": weekStartTime(time.Now())}
+ return nil
+ case 2:
+ filter[field] = bson.M{"$gte": monthStartTime(time.Now())}
+ return nil
+ default:
+ return srvdef.New(srvdef.ErrParamError, "时间范围无效", "")
+ }
+}
+
+// weekStartTime 返回本周开始时间
+func weekStartTime(now time.Time) time.Time {
+ weekday := int(now.Weekday())
+ if weekday == 0 {
+ weekday = 7
+ }
+ return time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location())
+}
+
+// monthStartTime 返回本月开始时间
+func monthStartTime(now time.Time) time.Time {
+ return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
+}
+
+func findHomeConfigThemePlaylets(ctx context.Context, sortType int32) ([]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) {
+ themes := playletcache.ListEnabledThemesByPlayletCount(0)
+ result := make([]homeConfigThemePlaylets, 0, homeConfigThemeLimit)
+ allPlaylets := make(playletmod.PlayletList, 0)
+ for _, theme := range themes {
+ if theme == nil {
+ continue
+ }
+ filter, sort := homeConfigThemePlayletQuery(sortType, theme.Id)
+ list, err := findHomePlaylets(ctx, filter, sort, homeConfigPlayletLimit)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(list) == 0 {
+ continue
+ }
+ result = append(result, homeConfigThemePlaylets{
+ Theme: theme,
+ Playlets: list,
+ })
+ allPlaylets = append(allPlaylets, list...)
+ if len(result) >= homeConfigThemeLimit {
+ break
+ }
+ }
+ return result, allPlaylets, nil
+}
+
+// findHomeConfigThemePlayletsPage 分页查询有短剧数据的首页配置题材
+func findHomeConfigThemePlayletsPage(ctx context.Context, sortType int32, 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, sort := homeConfigThemePlayletQuery(sortType, 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, sort, homeConfigPlayletLimit)
+ if err != nil {
+ return nil, 0, nil, err
+ }
+ result = append(result, homeConfigThemePlaylets{
+ Theme: theme,
+ Playlets: list,
+ })
+ allPlaylets = append(allPlaylets, list...)
+ }
+ totalCount++
+ }
+ return result, totalCount, allPlaylets, nil
+}
+
+// findPlayletTopicPage 分页查询有短剧数据的专题剧单
+func findPlayletTopicPage(ctx context.Context, sortType int64, skip, limit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) {
+ if sortType == topicListSortHot {
+ return findPlayletTopicPageByHot(ctx, skip, limit)
+ }
+ return findPlayletTopicPageByLatest(ctx, skip, limit)
+}
+
+// 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)
+ if err != nil {
+ return nil, nil, err
+ }
+ return groups, playlets, nil
+}
+
+// findPlayletTopicPageByHot 按绑定短剧数量分页查询专题
+func findPlayletTopicPageByHot(ctx context.Context, skip, limit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) {
+ themes := playletcache.ListEnabledThemesByPlayletCount(0)
+ totalCount := int64(len(themes))
+ if skip >= totalCount {
+ return make([]homeConfigThemePlaylets, 0), totalCount, make(playletmod.PlayletList, 0), nil
+ }
+ end := skip + limit
+ if end > totalCount {
+ end = totalCount
+ }
+ return buildPlayletTopicGroups(ctx, themes[skip:end], totalCount, topicListSortHot)
+}
+
+// findPlayletTopicPageByLatest 按更新时间分页查询专题
+func findPlayletTopicPageByLatest(ctx context.Context, skip, limit int64) ([]homeConfigThemePlaylets, int64, playletmod.PlayletList, *srvdef.SrvErr) {
+ list, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{
+ playletthememod.FN.PlayletTheme.Status: true,
+ }, options.Find().
+ SetProjection(bson.D{
+ {Key: playletthememod.FN.PlayletTheme.Id, Value: 1},
+ }).
+ SetSort(bson.D{
+ {Key: playletthememod.FN.PlayletTheme.UpdatedAt, Value: -1},
+ {Key: playletthememod.FN.PlayletTheme.Rank, Value: -1},
+ {Key: playletthememod.FN.PlayletTheme.Id, Value: -1},
+ }))
+ if err != nil {
+ return nil, 0, nil, err
+ }
+ themes := make([]*playletcache.ThemeItem, 0, len(list))
+ for _, item := range list {
+ if item == nil {
+ continue
+ }
+ theme := playletcache.GetEnabledTheme(item.Id)
+ if theme == nil || theme.PlayletCount <= 0 {
+ continue
+ }
+ themes = append(themes, theme)
+ }
+ totalCount := int64(len(themes))
+ if skip >= totalCount {
+ return make([]homeConfigThemePlaylets, 0), totalCount, make(playletmod.PlayletList, 0), nil
+ }
+ end := skip + limit
+ if end > totalCount {
+ end = totalCount
+ }
+ return buildPlayletTopicGroups(ctx, themes[skip:end], totalCount, topicListSortLatest)
+}
+
+// buildPlayletTopicGroups 查询专题卡片需要的短剧数据
+func buildPlayletTopicGroups(ctx context.Context, themes []*playletcache.ThemeItem, totalCount int64, sortType 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, topicListPlayletLimit)
+ 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},
+ }
+}
+
+// homeConfigThemePlayletQuery 构建题材下短剧查询条件
+func homeConfigThemePlayletQuery(sortType int32, themeID int64) (bson.M, bson.D) {
+ filter, sort := homeConfigPlayletQuery(sortType, 0)
+ filter[playletmod.FN.Playlet.ThemeIds] = themeID
+ return filter, sort
+}
+
+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, playletTodayUpdateSort()
+ case homeconfigmod.SortTypePreview:
+ filter[playletmod.FN.Playlet.Preview] = bson.M{"$exists": true, "$nin": []interface{}{"", nil}}
+ 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, configVideoMap map[int64]videoinformationmod.VideoInformationList, 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
+ }
+ if item.Type == homeconfigmod.TypeVideo && len(configVideoMap[item.Id]) == 0 {
+ continue
+ }
+ }
+ data := &proto.HomeConfigData{
+ Id: item.Id,
+ Name: item.Name,
+ Slug: item.Slug,
+ Desc: item.Desc,
+ Type: item.Type,
+ Rank: item.Rank,
+ SortType: item.SortType,
+ Status: item.Status,
+ CreatedAt: item.CreatedAt,
+ UpdatedAt: item.UpdatedAt,
+ List: playletsToHomeCommonData(ctx, configPlayletMap[item.Id], themeNameMap, tagNameMap),
+ VideoList: make([]*proto.VideoItemResp, 0),
+ ThemeList: make([]*proto.HomeConfigThemeData, 0),
+ SeoInfo: item.SeoInfo,
+ }
+ switch item.Type {
+ case homeconfigmod.TypeVideo:
+ data.List = make([]*proto.CommonData, 0)
+ data.VideoList = videosToHomeConfigData(ctx, configVideoMap[item.Id])
+ case homeconfigmod.TypeTheme:
+ data.List = make([]*proto.CommonData, 0)
+ data.ThemeList = homeConfigThemesToHomeData(ctx, configThemeMap[item.Id], themeNameMap, tagNameMap)
+ }
+ result = append(result, data)
+ }
+ return result
+}
+
+// videosToHomeConfigData 转换首页配置视频响应数据
+func videosToHomeConfigData(ctx context.Context, list videoinformationmod.VideoInformationList) []*proto.VideoItemResp {
+ result := make([]*proto.VideoItemResp, 0, len(list))
+ imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx))
+ for _, item := range list {
+ if item == nil {
+ continue
+ }
+ result = append(result, &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,
+ })
+ }
+ 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
+ }
+ 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.Theme.PlayletCount,
+ List: playletsToHomeCommonData(ctx, group.Playlets, themeNameMap, tagNameMap),
+ 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: group.Theme.PlayletCount,
+ 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,
+ 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,
+ })
+ }
+ 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,
+ },
+ },
+ {
+ "multi_match": esmanager.M{
+ "query": keyword,
+ "fields": playletSearchFields(),
+ "type": "best_fields",
+ "operator": "and",
+ },
+ },
+ },
+ "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,
+ }
+}
+
+// esPlayletToCommonData 转换ES短剧文档为app通用短剧响应
+func esPlayletToCommonData(ctx context.Context, item *esmod.ESPlaylet) proto.CommonData {
+ return proto.CommonData{
+ Id: item.Id,
+ Status: int32(item.Status),
+ IsFeatured: item.IsFeatured,
+ IsRecommend: item.IsRecommend,
+ 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},
+ }
+}
+
+// playletTodayUpdateSort 构建今日更新短剧排序
+func playletTodayUpdateSort() bson.D {
+ return bson.D{
+ {Key: playletmod.FN.Playlet.OnlineAt, Value: -1},
+ {Key: playletmod.FN.Playlet.Id, Value: -1},
+ }
+}
+
+// operateTypeFilter 构建运营类型过滤条件;历史未写入字段按短剧处理。
+func operateTypeFilter(operateType playletmod.OperateType) bson.M {
+ if operateType == playletmod.OperateTypeComic {
+ return bson.M{playletmod.FN.Playlet.OperateType: playletmod.OperateTypeComic}
+ }
+ return bson.M{playletmod.FN.Playlet.OperateType: bson.M{"$ne": playletmod.OperateTypeComic}}
+}
+
+// playletOperateListSort 构建运营类型列表排序
+func playletOperateListSort(filterType int64) bson.D {
+ switch filterType {
+ case operateFilterHot:
+ return bson.D{
+ {Key: playletmod.FN.Playlet.CountCollect, Value: -1},
+ {Key: playletmod.FN.Playlet.Id, Value: -1},
+ }
+ case operateFilterOriginal:
+ return bson.D{
+ {Key: playletmod.FN.Playlet.RecommendRank, Value: -1},
+ {Key: playletmod.FN.Playlet.Id, Value: -1},
+ }
+ default:
+ 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
+}
+
+// normalizeHomeConfigDetailPage 规范化首页配置详情分页参数
+func normalizeHomeConfigDetailPage(pageNum, pageSize int64) (int64, int64) {
+ if pageNum <= 0 {
+ pageNum = 1
+ }
+ if pageSize <= 0 {
+ pageSize = homeConfigDetailSize
+ }
+ 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..4581de5
--- /dev/null
+++ b/app/service/playletser/slug.go
@@ -0,0 +1,77 @@
+package playletser
+
+import (
+ "context"
+ "hkdj-web-server/app/service/playletcache"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/models/playletmod"
+ "hkdj-web-server/pub/models/playlettagmod"
+ "hkdj-web-server/pub/models/playletthememod"
+ "strings"
+
+ "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/playlettagser/tag.go b/app/service/playlettagser/tag.go
new file mode 100644
index 0000000..229bac9
--- /dev/null
+++ b/app/service/playlettagser/tag.go
@@ -0,0 +1,89 @@
+package playlettagser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/app/service/playletcache"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/pubdef"
+ "hkdj-web-server/pub/seo/h5path"
+ "strings"
+)
+
+// 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..e899e99
--- /dev/null
+++ b/app/service/playletthemeser/theme.go
@@ -0,0 +1,109 @@
+package playletthemeser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/app/service/playletcache"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/pubdef"
+ "hkdj-web-server/pub/seo/h5path"
+ "strings"
+)
+
+// 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..2adabeb
--- /dev/null
+++ b/app/service/rechargepackageser/package.go
@@ -0,0 +1,108 @@
+package rechargepackageser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/rechargeser/jiahua"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/models/rechargepackagemod"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+// GetRechargePackageList 查询购买套餐页所需的会员畅享和金币充值列表,并为各套餐附上可用支付方式。
+// 内部统一调用一次嘉华通道查询,避免 N+1 请求。
+func GetRechargePackageList(ctx context.Context, sysType string) (*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, sysType)
+
+ 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, sysType string) 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, _ := jiahua.GetPayChannelByMoney(ctx, moneys, sysType)
+ channelMap := make(map[int64][]proto.PayMethodInfo, len(channels))
+ for _, ch := range channels {
+ methods := make([]proto.PayMethodInfo, 0, 3)
+ if ch.Alipay {
+ methods = append(methods, proto.PayMethodInfo{Type: "alipay", TypeName: "支付宝"})
+ }
+ if ch.Wechat {
+ methods = append(methods, proto.PayMethodInfo{Type: "wechat", TypeName: "微信"})
+ }
+ if ch.Union {
+ methods = append(methods, proto.PayMethodInfo{Type: "union", TypeName: "银联"})
+ }
+ channelMap[ch.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/jiahua/define.go b/app/service/rechargeser/jiahua/define.go
new file mode 100644
index 0000000..0da3bbe
--- /dev/null
+++ b/app/service/rechargeser/jiahua/define.go
@@ -0,0 +1,52 @@
+package jiahua
+
+import "time"
+
+// PayTypeParam 查询支付通道请求参数
+type PayTypeParam struct {
+ Moneys []int64 `json:"moneys"`
+ DevType string `json:"devType"`
+ PlatformID string `json:"platformID"`
+}
+
+// RchgType 支付通道信息
+type RchgType struct {
+ Money int64 `json:"money"`
+ Alipay bool `json:"alipay"`
+ Wechat bool `json:"wechat"`
+ Union bool `json:"union"`
+}
+
+// PayCenterMsg 查询支付通道响应
+type PayCenterMsg struct {
+ Code int `json:"code"`
+ Msg string `json:"msg"`
+ Data []RchgType `json:"data"`
+}
+
+// PayCenterRchg 下单请求参数
+type PayCenterRchg struct {
+ TransNo string `json:"transNo"`
+ PlatformID string `json:"platformID"`
+ UID string `json:"uid"`
+ DevID string `json:"devID"`
+ UserIP string `json:"userIP"`
+ Name string `json:"name"`
+ DevType string `json:"devType"`
+ Money int64 `json:"money"`
+ Channel string `json:"channel"`
+ CreatedAt time.Time `json:"createdAt"`
+ NotifyUrl string `json:"notifyUrl"`
+ PType string `json:"pType"`
+ PayMethod string `json:"payMethod"`
+}
+
+// PayCenterRchgMsg 下单响应
+type PayCenterRchgMsg struct {
+ Err string `json:"err"`
+ PayUrl string `json:"payUrl"`
+ OID string `json:"oid"`
+ Mode string `json:"mode"`
+ CID string `json:"cid"`
+ Rate string `json:"rate"`
+}
diff --git a/app/service/rechargeser/jiahua/product.go b/app/service/rechargeser/jiahua/product.go
new file mode 100644
index 0000000..2035f20
--- /dev/null
+++ b/app/service/rechargeser/jiahua/product.go
@@ -0,0 +1,100 @@
+package jiahua
+
+import (
+ "context"
+ "encoding/json"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/pubdef"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+const (
+ notifyURLSuffix = "/api/web/third/paycenter/bill/callback"
+)
+
+// GetPayChannelByMoney 向嘉华支付中心查询指定金额(分)支持的支付方式
+func GetPayChannelByMoney(ctx context.Context, moneys []int64, devType string) ([]RchgType, *srvdef.SrvErr) {
+ if devType != "android" {
+ devType = "ios"
+ }
+ param := PayTypeParam{
+ Moneys: moneys,
+ DevType: devType,
+ PlatformID: pubdef.AppNameZhifu,
+ }
+ reqBody, err := json.Marshal(param)
+ if err != nil {
+ return nil, srvdef.New(srvdef.Err, "", err.Error())
+ }
+ resp := &PayCenterMsg{}
+ url := appg.Conf.TransactionUrl + "/api/recharge/payType"
+ code, err := utils.POSTJsonWithJResp(resp, url, nil, reqBody)
+ if err != nil {
+ log.Error("jiahua:GetPayChannelByMoney failed", log.Any("url", url), log.E(err))
+ return nil, srvdef.New(srvdef.Err, "查询支付通道失败", err.Error())
+ }
+ if code != http.StatusOK || resp.Code != 200 {
+ log.Error("jiahua:GetPayChannelByMoney bad resp", log.Any("code", code), log.Any("resp.Code", resp.Code))
+ return nil, srvdef.New(srvdef.Err, "查询支付通道失败", "")
+ }
+ return resp.Data, nil
+}
+
+// SubmitOrder 向嘉华支付中心提交下单
+// 返回 SubmitVipOrderInfo(含 PayUrl、Oid 等),供调用方存入本地订单。
+func SubmitOrder(ctx context.Context,
+ userId int64, userName string,
+ tradeNo string, amount int64,
+ payMode string, sysType string, devType string, ip string,
+) (*pubdef.SubmitVipOrderInfo, *srvdef.SrvErr) {
+
+ reqParam := &PayCenterRchg{
+ TransNo: tradeNo,
+ PlatformID: pubdef.AppNameZhifu,
+ UID: strconv.FormatInt(userId, 10),
+ Name: userName,
+ UserIP: ip,
+ DevType: sysType,
+ Money: amount,
+ Channel: "self",
+ CreatedAt: time.Now(),
+ PayMethod: payMode,
+ PType: payMode,
+ NotifyUrl: appg.Conf.LocalWebUrl + notifyURLSuffix,
+ }
+
+ reqBody, err := json.Marshal(reqParam)
+ if err != nil {
+ return nil, srvdef.New(srvdef.Err, "", err.Error())
+ }
+
+ resp := &PayCenterRchgMsg{}
+ url := appg.Conf.TransactionUrl + "/api/recharge/recharge"
+ code, err := utils.POSTJsonWithJResp(resp, url, nil, reqBody)
+ if err != nil {
+ log.Error("jiahua:SubmitOrder POSTJsonWithJResp failed", log.Any("url", url), log.E(err))
+ return nil, srvdef.New(srvdef.Err, "调用支付中心失败", err.Error())
+ }
+ if code != http.StatusOK {
+ log.Error("jiahua:SubmitOrder bad http code", log.Any("code", code))
+ return nil, srvdef.New(srvdef.Err, "调用支付中心失败", "")
+ }
+ if resp.Err != "" {
+ log.Error("jiahua:SubmitOrder resp error", log.Any("err", resp.Err), log.Any("tradeNo", tradeNo))
+ return nil, srvdef.New(srvdef.Err, "支付中心下单失败", resp.Err)
+ }
+
+ return &pubdef.SubmitVipOrderInfo{
+ TradeNo: tradeNo,
+ Oid: resp.OID,
+ PayUrl: resp.PayUrl,
+ PayUrlMode: resp.Mode,
+ PayChannel: resp.CID,
+ PayRate: resp.Rate,
+ }, nil
+}
diff --git a/app/service/rechargeser/recharge.go b/app/service/rechargeser/recharge.go
new file mode 100644
index 0000000..18727b9
--- /dev/null
+++ b/app/service/rechargeser/recharge.go
@@ -0,0 +1,182 @@
+package rechargeser
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "hkdj-web-server/app/service/rechargeser/jiahua"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/useragent"
+ "hkdj-web-server/pub/dataReport"
+ "hkdj-web-server/pub/models/eventmod"
+ "hkdj-web-server/pub/models/rechargepackagemod"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/models/vipordermod"
+ "hkdj-web-server/pub/mongos"
+ "hkdj-web-server/pub/pubdef"
+ "strconv"
+ "strings"
+ "time"
+
+ "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, ip string,
+ districtCode 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 := jiahua.SubmitOrder(ctx, userId, userName, tradeNo, amount, payMode, sysType, devType, 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),
+ 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
+ }
+
+ ua := useragent.UA{SysType: 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",
+ 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..2fa05a3
--- /dev/null
+++ b/app/service/reportser/report.go
@@ -0,0 +1,530 @@
+package reportser
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "hkdj-web-server/app/service/playletcache"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/useragent"
+ "hkdj-web-server/pub/dataReport"
+ "hkdj-web-server/pub/models/eventmod"
+ "hkdj-web-server/pub/models/playletchaptermod"
+ "hkdj-web-server/pub/models/playletmod"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/pubdef"
+ "strconv"
+ "strings"
+ "time"
+
+ "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(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(user *usermod.User, ua useragent.UA, ip string, playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter, amount int64, orderId string) {
+ pub := playletToDramaPub(playlet, chapter)
+ 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{}
+ }
+ var chapter *playletchaptermod.PlayletChapter
+ if chapterId > 0 {
+ chapter, _ = playletchaptermod.FindOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: chapterId}, options.FindOne().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.MediaTime, Value: 1},
+ }))
+ }
+ return playletToDramaPub(playlet, 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)
+ if chapter != nil && chapter.Id > 0 {
+ if chapter.CurrentEpisode > 0 {
+ episode = chapter.CurrentEpisode
+ }
+ duration = chapter.MediaTime
+ }
+ return dataReport.DramaEventPub{
+ DramaID: strconv.FormatInt(playlet.Id, 10),
+ DramaTitle: playlet.Title,
+ Episode: dataReport.JsonNumber(episode),
+ 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..95c82be
--- /dev/null
+++ b/app/service/searchser/search.go
@@ -0,0 +1,61 @@
+package searchser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/app/service/translationser"
+ "hkdj-web-server/pub/pubdef"
+ "time"
+
+ "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..8711d14
--- /dev/null
+++ b/app/service/translationser/catch.go
@@ -0,0 +1,173 @@
+package translationser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/models/cfg"
+ "hkdj-web-server/pub/models/translationcategorymod"
+ "hkdj-web-server/pub/models/translationmod"
+ "hkdj-web-server/pub/pubdef"
+ "sync"
+
+ "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..4a0003e
--- /dev/null
+++ b/app/service/uploadser/upload.go
@@ -0,0 +1,127 @@
+package uploadser
+
+import (
+ "bytes"
+ "fmt"
+ "hkdj-web-server/app/service/configser"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/textproto"
+ "strconv"
+
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/pubdef"
+ "hkdj-web-server/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..58db1b8
--- /dev/null
+++ b/app/service/usercenterser/user_center.go
@@ -0,0 +1,61 @@
+package usercenterser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/models/playletchapterunlockmod"
+ "hkdj-web-server/pub/models/playletcollectmod"
+ "hkdj-web-server/pub/models/playletwatchhistorymod"
+ "hkdj-web-server/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..7da186f
--- /dev/null
+++ b/app/service/videoser/h265.go
@@ -0,0 +1,60 @@
+package videoser
+
+import (
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "strings"
+)
+
+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..f49f166
--- /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"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/pub/common/crypt"
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/redis"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "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/"
+ 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 strings.TrimSpace(appg.Conf.TsAuthKey)
+}
+
+// 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..7cc1f4b
--- /dev/null
+++ b/app/service/videoser/m3u8.go
@@ -0,0 +1,349 @@
+package videoser
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/m3u8"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/models/m3u8requestlogmod"
+ "hkdj-web-server/pub/redis"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "time"
+
+ "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..d1578cf
--- /dev/null
+++ b/app/service/videoser/slug.go
@@ -0,0 +1,45 @@
+package videoser
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/models/videotopicmod"
+ "strings"
+
+ "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..b32e26d
--- /dev/null
+++ b/app/service/videoser/video.go
@@ -0,0 +1,1399 @@
+package videoser
+
+import (
+ "context"
+ "hkdj-web-server/app/api/proto"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/app/service/reportser"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/netutil"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/models/richtextcontentmod"
+ "hkdj-web-server/pub/models/videocollectmod"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/models/videolikemod"
+ "hkdj-web-server/pub/models/videotagmod"
+ "hkdj-web-server/pub/models/videotopicmod"
+ "hkdj-web-server/pub/models/videowatchhistorymod"
+ "hkdj-web-server/pub/pubdef"
+ "hkdj-web-server/pub/seo/h5path"
+ "regexp"
+ "strings"
+
+ "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 = h5path.SeriesWatchPath
+)
+
+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/watch/{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..e1b6c73
--- /dev/null
+++ b/app/service/videoser/videocategory.go
@@ -0,0 +1,15 @@
+package videoser
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..ccc58d5
--- /dev/null
+++ b/app/service/videoser/videocreator.go
@@ -0,0 +1,15 @@
+package videoser
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..9d60e13
--- /dev/null
+++ b/app/service/videoser/videotag.go
@@ -0,0 +1,15 @@
+package videoser
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..32548b4
--- /dev/null
+++ b/app/service/videoser/videotopic.go
@@ -0,0 +1,15 @@
+package videoser
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..d670cb0
--- /dev/null
+++ b/app/service/videoser/vtt.go
@@ -0,0 +1,158 @@
+package videoser
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "hkdj-web-server/app/appg"
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/pub/common"
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/common/http"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/m3u8"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/redis"
+ "hkdj-web-server/pub/storage/proxy"
+ "io"
+ http2 "net/http"
+ "strings"
+
+ "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..e846727
--- /dev/null
+++ b/app/skd/ipstat/sync.go
@@ -0,0 +1,49 @@
+package ipstat
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/models/ipdailystatmod"
+ "hkdj-web-server/pub/pubdef"
+ "hkdj-web-server/pub/redis"
+ "time"
+)
+
+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..4f1923b
--- /dev/null
+++ b/app/skd/job/sitemap/data.go
@@ -0,0 +1,486 @@
+package sitemap
+
+import (
+ "context"
+ "hkdj-web-server/pub/models/homeconfigmod"
+ "hkdj-web-server/pub/models/playletchaptermod"
+ "hkdj-web-server/pub/models/playletmod"
+ "hkdj-web-server/pub/models/playlettagmod"
+ "hkdj-web-server/pub/models/playletthememod"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/models/videotopicmod"
+ "hkdj-web-server/pub/seo/h5path"
+ "strings"
+ "sync"
+ "time"
+
+ "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 := time.Now().Format(time.DateOnly)
+ 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)
+}
+
+func hgdjStaticPages() []staticPageEntry {
+ return []staticPageEntry{
+ {path: "/", changeFreq: "daily", priority: 1.0},
+ {path: h5path.DramaListPath, changeFreq: "daily", priority: 0.9},
+ {path: h5path.SeriesPath, changeFreq: "daily", priority: 0.9},
+ {path: h5path.CollectionsPath, changeFreq: "daily", priority: 0.8},
+ {path: h5path.RankingsPath, changeFreq: "daily", priority: 0.8},
+ {path: h5path.RankingsPath + "/2", changeFreq: "daily", priority: 0.7},
+ {path: h5path.RankingsPath + "/3", changeFreq: "daily", priority: 0.7},
+ {path: h5path.LatestPath, changeFreq: "daily", priority: 0.8},
+ {path: h5path.FeaturedPath, changeFreq: "daily", priority: 0.8},
+ {path: h5path.FeaturedPath + "/2", changeFreq: "daily", priority: 0.7},
+ {path: h5path.FeaturedPath + "/3", changeFreq: "daily", priority: 0.7},
+ {path: "/about", changeFreq: "yearly", priority: 0.3},
+ {path: "/help", changeFreq: "yearly", priority: 0.3},
+ {path: "/terms", changeFreq: "yearly", priority: 0.3},
+ {path: "/privacy", changeFreq: "yearly", priority: 0.3},
+ {path: "/2257", changeFreq: "yearly", priority: 0.3},
+ {path: "/dmca", changeFreq: "yearly", priority: 0.3},
+ }
+}
+
+// getURL 并发拉取短剧/视频数据并写入 sitemap 分片,URL 规则对齐 hkdj-web 语义页面路径。
+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
+ videos listedVideos
+ homeConfigs []homeConfigItem
+ )
+
+ wg.Add(5)
+ 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()
+ videos = getVideoList(ctx)
+ }()
+ go func() {
+ defer wg.Done()
+ homeConfigs = getHomeConfigList(ctx)
+ }()
+ wg.Wait()
+
+ // 题材/标签/视频专题没有上架内容时不写入,避免空列表页被收录。
+ themeItems = keepWithListedPlaylets(themeItems, listed.themeIDs)
+ tagItems = keepWithListedPlaylets(tagItems, listed.tagIDs)
+ videoTopics := keepWithListedPlaylets(videos.topics, videos.topicIDs)
+
+ pageURLs := buildStaticURLs(webAddr, hgdjStaticPages())
+ for _, item := range themeItems {
+ pageURLs = append(pageURLs, newURL(webAddr, h5path.ListTheme(item.Slug, item.Id), item.LastMod, "daily", 0.8))
+ pageURLs = append(pageURLs, newURL(webAddr, h5path.TopicDetail(item.Slug, item.Id), item.LastMod, "daily", 0.8))
+ }
+ for _, item := range tagItems {
+ if path := h5path.Tag(item.Slug, item.Id); path != "" {
+ pageURLs = append(pageURLs, newURL(webAddr, path, item.LastMod, "weekly", 0.6))
+ }
+ }
+ for _, item := range videoTopics {
+ pageURLs = append(pageURLs, newURL(webAddr, h5path.SeriesTopic(item.Id), item.LastMod, "daily", 0.7))
+ }
+ for _, item := range homeConfigs {
+ path := ""
+ switch item.Type {
+ case homeconfigmod.TypePlaylet:
+ path = h5path.MoreList(item.Slug, item.Id)
+ case homeconfigmod.TypeVideo:
+ path = h5path.MoreSeriesList(item.Slug, item.Id)
+ }
+ if path != "" {
+ pageURLs = append(pageURLs, newURL(webAddr, path, item.LastMod, "daily", 0.7))
+ }
+ }
+ if len(pageURLs) > 0 {
+ _ = sg.writeSitemapFile("sitemap-pages.xml", pageURLs)
+ }
+
+ // 各集 /drama/{slug}/{ep} 由前端 noindex;sitemap 只保留 /drama/{slug}。
+ // 原创且第一集无水印的播放入口不进 sitemap,避免空壳播放页被提交。
+ populateFirstChapters(ctx, listed.items)
+ if urls := buildPlayletPathURLs(webAddr, listed.items, "weekly", 0.7); len(urls) > 0 {
+ _ = sg.writeSitemapFiles("sitemap-playletdetail", urls)
+ }
+ if urls := buildVideoPlayURLs(webAddr, videos.items, "weekly", 0.6); len(urls) > 0 {
+ _ = sg.writeSitemapFiles("sitemap-seriesplay", urls)
+ }
+}
+
+type listedPlaylets struct {
+ items []idWithTime
+ themeIDs map[int64]struct{}
+ tagIDs map[int64]struct{}
+}
+
+type listedVideos struct {
+ items []idWithTime
+ topics []idWithTime
+ topicIDs 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 := formatLastMod(lastMod)
+ if mod == "" {
+ mod = time.Now().Format(time.DateOnly)
+ }
+ 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 buildPlayletPathURLs(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.Playlet(item.Slug, item.Id)
+ if path == "" {
+ continue
+ }
+ urls = append(urls, newURL(webAddr, path, item.LastMod, changeFreq, priority))
+ }
+ return urls
+}
+
+func buildVideoPlayURLs(webAddr string, items []idWithTime, changeFreq string, priority float32) []URL {
+ urls := make([]URL, 0, len(items))
+ for _, item := range items {
+ path := h5path.VideoPlay(item.Slug, item.Id)
+ if path == "" {
+ continue
+ }
+ urls = append(urls, newURL(webAddr, path, item.LastMod, changeFreq, priority))
+ }
+ return urls
+}
+
+func playletIDs(items []idWithTime) []int64 {
+ ids := make([]int64, 0, len(items))
+ for _, item := range items {
+ if item.Id > 0 {
+ ids = append(ids, item.Id)
+ }
+ }
+ return ids
+}
+
+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 formatLastMod(t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ return t.Format(time.DateOnly)
+}
+
+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 getVideoList(ctx context.Context) listedVideos {
+ result := listedVideos{
+ items: make([]idWithTime, 0),
+ topics: make([]idWithTime, 0),
+ topicIDs: make(map[int64]struct{}),
+ }
+ filter := bson.M{
+ videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
+ }
+ pageSize := int64(1000)
+ for i := int64(0); i < 500; i++ {
+ skip := i * pageSize
+ list, _ := videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find().
+ SetSort(bson.M{videoinformationmod.FN.VideoInformation.Id: -1}).
+ SetSkip(skip).
+ SetLimit(pageSize).
+ SetProjection(bson.M{
+ videoinformationmod.FN.VideoInformation.Id: 1,
+ videoinformationmod.FN.VideoInformation.Slug: 1,
+ videoinformationmod.FN.VideoInformation.UpdatedAt: 1,
+ videoinformationmod.FN.VideoInformation.AddedTime: 1,
+ videoinformationmod.FN.VideoInformation.TopicIds: 1,
+ }))
+ for _, info := range list {
+ if info == nil || info.Id <= 0 {
+ continue
+ }
+ lastMod := info.UpdatedAt
+ if lastMod.IsZero() {
+ lastMod = info.AddedTime
+ }
+ result.items = append(result.items, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: lastMod})
+ addListedIDs(result.topicIDs, info.TopicIds)
+ }
+ if len(list) < int(pageSize) {
+ break
+ }
+ }
+
+ topicFilter := bson.M{videotopicmod.FN.VideoTopicModel.Status: true}
+ for i := int64(0); i < 100; i++ {
+ skip := i * pageSize
+ list, _ := videotopicmod.FindManyVideoTopicModels(ctx, topicFilter, options.Find().
+ SetSort(bson.M{videotopicmod.FN.VideoTopicModel.Rank: -1}).
+ SetSkip(skip).
+ SetLimit(pageSize))
+ for _, info := range list {
+ if info == nil || info.Id <= 0 || strings.TrimSpace(info.Name) == "" {
+ continue
+ }
+ result.topics = append(result.topics, idWithTime{Id: info.Id, Slug: info.Slug, LastMod: info.UpdatedAt})
+ }
+ if len(list) < int(pageSize) {
+ break
+ }
+ }
+ return result
+}
+
+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, homeconfigmod.TypeVideo},
+ },
+ }, 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..ea14792
--- /dev/null
+++ b/app/skd/job/sitemap/generate.go
@@ -0,0 +1,35 @@
+package sitemap
+
+import (
+ "hkdj-web-server/app/service/configser"
+ "hkdj-web-server/pub/common/log"
+ "strings"
+ "sync"
+)
+
+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..1eff533
--- /dev/null
+++ b/app/skd/job/sitemap/job.go
@@ -0,0 +1,35 @@
+package sitemap
+
+import (
+ "hkdj-web-server/pub/common/log"
+ "os"
+ "time"
+)
+
+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..a795c0d
--- /dev/null
+++ b/app/skd/job/sitemap/sitemap.go
@@ -0,0 +1,265 @@
+package sitemap
+
+import (
+ "encoding/xml"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ stylesheetIndex = `` + "\n"
+ stylesheetURLSet = `` + "\n"
+
+ defaultMaxURLsPerFile = 40000
+
+ // OutputDir 分片 sitemap 输出目录(相对 app 进程 cwd)。
+ // 对齐 jha-1045-hgdjnet-web:/server/web_91ys/h5/template/pages/sitemaps
+ 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
+}
+
+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 := time.Now().Format(time.DateOnly)
+ 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 buildRobotsTXT(webAddr string) string {
+ base := strings.TrimSuffix(strings.TrimSpace(webAddr), "/")
+ if base == "" {
+ return ""
+ }
+ body := "User-agent: *\nAllow: /\n\n" +
+ "# 非 HTML 接口与播放器工具无需被搜索引擎抓取。\n" +
+ "Disallow: /api/\n" +
+ "Disallow: /h5/\n" +
+ "Disallow: /ui/\n" +
+ "Disallow: /healthz\n" +
+ "Disallow: /enkey\n" +
+ "Disallow: /newenkey\n" +
+ "Disallow: /cdn-cgi/\n\n" +
+ "# 搜索、登录和账户 HTML 保持可抓取,以便爬虫读取页面内 noindex。\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..4f91459
--- /dev/null
+++ b/app/skd/seo/seo.go
@@ -0,0 +1,59 @@
+package seo
+
+import (
+ "context"
+ "hkdj-web-server/app/skd/job/sitemap"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/seo/sitemapdirty"
+ "sync"
+ "time"
+
+ "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/deploy/test/index-plan.json b/deploy/test/index-plan.json
new file mode 100644
index 0000000..1496fb2
--- /dev/null
+++ b/deploy/test/index-plan.json
@@ -0,0 +1,4057 @@
+{
+ "elasticsearch": {
+ "webhkdjnet_playlet_index_v1": {
+ "mappings": {
+ "properties": {
+ "anchor": {
+ "analyzer": "ik_smart",
+ "search_analyzer": "ik_smart",
+ "type": "text"
+ },
+ "banner": {
+ "index": false,
+ "type": "keyword"
+ },
+ "countBrowse": {
+ "type": "long"
+ },
+ "countCollect": {
+ "type": "long"
+ },
+ "countLike": {
+ "type": "long"
+ },
+ "cover": {
+ "index": false,
+ "type": "keyword"
+ },
+ "createdAt": {
+ "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss",
+ "type": "date"
+ },
+ "desc": {
+ "analyzer": "ik_smart",
+ "search_analyzer": "ik_smart",
+ "type": "text"
+ },
+ "description": {
+ "index": false,
+ "type": "keyword"
+ },
+ "heatValue": {
+ "type": "long"
+ },
+ "id": {
+ "type": "long"
+ },
+ "isFeatured": {
+ "type": "boolean"
+ },
+ "isRecommend": {
+ "type": "boolean"
+ },
+ "onlineAt": {
+ "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss",
+ "type": "date"
+ },
+ "operateType": {
+ "type": "integer"
+ },
+ "playName": {
+ "analyzer": "ik_max_word",
+ "search_analyzer": "ik_smart",
+ "type": "text"
+ },
+ "seoInfo": {
+ "properties": {
+ "description": {
+ "index": false,
+ "type": "keyword"
+ },
+ "keyworld": {
+ "index": false,
+ "type": "keyword"
+ },
+ "title": {
+ "index": false,
+ "type": "keyword"
+ }
+ }
+ },
+ "showOnBanner": {
+ "type": "boolean"
+ },
+ "slug": {
+ "type": "keyword"
+ },
+ "status": {
+ "type": "integer"
+ },
+ "tagIds": {
+ "type": "long"
+ },
+ "tagNames": {
+ "analyzer": "ik_max_word",
+ "search_analyzer": "ik_smart",
+ "type": "text"
+ },
+ "themeIds": {
+ "type": "long"
+ },
+ "themeNames": {
+ "analyzer": "ik_max_word",
+ "search_analyzer": "ik_smart",
+ "type": "text"
+ },
+ "title": {
+ "analyzer": "ik_max_word",
+ "search_analyzer": "ik_smart",
+ "type": "text"
+ },
+ "totalEpisode": {
+ "type": "long"
+ },
+ "updateStatus": {
+ "type": "integer"
+ },
+ "updatedAt": {
+ "format": "strict_date_optional_time||epoch_millis||yyyy-MM-dd HH:mm:ss",
+ "type": "date"
+ }
+ }
+ },
+ "settings": {
+ "analysis": {
+ "analyzer": {
+ "ik": {
+ "tokenizer": "ik_smart"
+ }
+ }
+ },
+ "number_of_replicas": 1,
+ "number_of_shards": 1
+ }
+ }
+ },
+ "environment": "test",
+ "host": "172.104.181.99",
+ "mongodb": [
+ {
+ "collection": "_comment",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "_id",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 29,
+ "options": {
+ "name": "_id_1_createdAt_-1"
+ },
+ "source": "pub/models/commentmod/comment.go"
+ },
+ {
+ "collection": "_comment",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 35,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/commentmod/comment.go"
+ },
+ {
+ "collection": "_comment",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "objectId",
+ 1
+ ],
+ [
+ "objectType",
+ 1
+ ],
+ [
+ "reviewStatus",
+ 1
+ ],
+ [
+ "parentsId",
+ 1
+ ],
+ [
+ "countLike",
+ -1
+ ]
+ ],
+ "line": 63,
+ "options": {
+ "name": "objectId_1_objectType_1_reviewStatus_1_parentsId_1_countLike_-1"
+ },
+ "source": "pub/models/commentmod/comment.go"
+ },
+ {
+ "collection": "_comment",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "objectId",
+ 1
+ ],
+ [
+ "objectType",
+ 1
+ ],
+ [
+ "reviewStatus",
+ 1
+ ],
+ [
+ "parentsId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 47,
+ "options": {
+ "name": "objectId_1_objectType_1_reviewStatus_1_parentsId_1_createdAt_-1"
+ },
+ "source": "pub/models/commentmod/comment.go"
+ },
+ {
+ "collection": "_comment",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "objectId",
+ 1
+ ],
+ [
+ "reviewStatus",
+ 1
+ ],
+ [
+ "level",
+ 1
+ ]
+ ],
+ "line": 40,
+ "options": {
+ "name": "objectId_1_reviewStatus_1_level_1"
+ },
+ "source": "pub/models/commentmod/comment.go"
+ },
+ {
+ "collection": "_comment",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "objectId",
+ 1
+ ]
+ ],
+ "line": 24,
+ "options": {
+ "name": "objectId_1"
+ },
+ "source": "pub/models/commentmod/comment.go"
+ },
+ {
+ "collection": "_comment",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "parentsId",
+ 1
+ ],
+ [
+ "level",
+ 1
+ ],
+ [
+ "reviewStatus",
+ 1
+ ],
+ [
+ "createdAt",
+ 1
+ ],
+ [
+ "_id",
+ 1
+ ]
+ ],
+ "line": 72,
+ "options": {
+ "name": "parentsId_1_level_1_reviewStatus_1_createdAt_1__id_1"
+ },
+ "source": "pub/models/commentmod/comment.go"
+ },
+ {
+ "collection": "_comment",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "topRank",
+ -1
+ ]
+ ],
+ "line": 81,
+ "options": {
+ "name": "topRank_-1"
+ },
+ "source": "pub/models/commentmod/comment.go"
+ },
+ {
+ "collection": "_comment",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "objectType",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 56,
+ "options": {
+ "name": "userId_1_objectType_1_createdAt_-1"
+ },
+ "source": "pub/models/commentmod/comment.go"
+ },
+ {
+ "collection": "announcement",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/announcementmod/announcement.go"
+ },
+ {
+ "collection": "announcement",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "sort",
+ -1
+ ]
+ ],
+ "line": 20,
+ "options": {
+ "name": "status_1_sort_-1"
+ },
+ "source": "pub/models/announcementmod/announcement.go"
+ },
+ {
+ "collection": "coin_trans",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "orderNo",
+ 1
+ ]
+ ],
+ "line": 32,
+ "options": {
+ "name": "orderNo_1"
+ },
+ "source": "pub/models/cointransmod/trans.go"
+ },
+ {
+ "collection": "coin_trans",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "tranType",
+ 1
+ ]
+ ],
+ "line": 30,
+ "options": {
+ "name": "tranType_1"
+ },
+ "source": "pub/models/cointransmod/trans.go"
+ },
+ {
+ "collection": "coin_trans",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 25,
+ "options": {
+ "name": "userId_1_createdAt_-1"
+ },
+ "source": "pub/models/cointransmod/trans.go"
+ },
+ {
+ "collection": "comment_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "commentId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "commentId_1_createdAt_-1"
+ },
+ "source": "pub/models/commentlikemod/like.go"
+ },
+ {
+ "collection": "comment_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "objectType",
+ 1
+ ],
+ [
+ "objectId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "objectType_1_objectId_1_createdAt_-1"
+ },
+ "source": "pub/models/commentlikemod/like.go"
+ },
+ {
+ "collection": "comment_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "commentId",
+ 1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "userId_1_commentId_1",
+ "unique": true
+ },
+ "source": "pub/models/commentlikemod/like.go"
+ },
+ {
+ "collection": "contactus",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 46,
+ "options": {
+ "name": "createdAt_-1__id_-1"
+ },
+ "source": "pub/models/contactusmod/contactus.go"
+ },
+ {
+ "collection": "contactus",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "questionType",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "questionType_1_createdAt_-1__id_-1"
+ },
+ "source": "pub/models/contactusmod/contactus.go"
+ },
+ {
+ "collection": "contactus",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 32,
+ "options": {
+ "name": "status_1_createdAt_-1__id_-1"
+ },
+ "source": "pub/models/contactusmod/contactus.go"
+ },
+ {
+ "collection": "contactus",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 25,
+ "options": {
+ "name": "userId_1_createdAt_-1__id_-1"
+ },
+ "source": "pub/models/contactusmod/contactus.go"
+ },
+ {
+ "collection": "detail_nav",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 20,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/detailnavmod/detailnav.go"
+ },
+ {
+ "collection": "es_record",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "esIndex",
+ 1
+ ]
+ ],
+ "line": 38,
+ "options": {
+ "name": "esIndex_1",
+ "unique": true
+ },
+ "source": "pub/models/esmod/esop.go"
+ },
+ {
+ "collection": "event",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 38,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/eventmod/index.go"
+ },
+ {
+ "collection": "event",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "eventId",
+ 1
+ ]
+ ],
+ "line": 27,
+ "options": {
+ "name": "eventId_1"
+ },
+ "source": "pub/models/eventmod/index.go"
+ },
+ {
+ "collection": "event",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "eventType",
+ 1
+ ]
+ ],
+ "line": 22,
+ "options": {
+ "name": "eventType_1"
+ },
+ "source": "pub/models/eventmod/index.go"
+ },
+ {
+ "collection": "event",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "retryCount",
+ 1
+ ]
+ ],
+ "line": 32,
+ "options": {
+ "name": "status_1_retryCount_1"
+ },
+ "source": "pub/models/eventmod/index.go"
+ },
+ {
+ "collection": "event",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 17,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/eventmod/index.go"
+ },
+ {
+ "collection": "feedback",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "createdAt_-1__id_-1"
+ },
+ "source": "pub/models/feedbackmod/feedback.go"
+ },
+ {
+ "collection": "feedback",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 32,
+ "options": {
+ "name": "status_1_createdAt_-1__id_-1"
+ },
+ "source": "pub/models/feedbackmod/feedback.go"
+ },
+ {
+ "collection": "feedback",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 25,
+ "options": {
+ "name": "userId_1_createdAt_-1__id_-1"
+ },
+ "source": "pub/models/feedbackmod/feedback.go"
+ },
+ {
+ "collection": "generate_id",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "businessId",
+ 1
+ ]
+ ],
+ "line": 122,
+ "options": {
+ "name": "businessId_1",
+ "unique": true
+ },
+ "source": "pub/mongos/generate_id.go"
+ },
+ {
+ "collection": "home_banner",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 38,
+ "options": {
+ "name": "createdAt_-1__id_-1"
+ },
+ "source": "pub/models/homebannermod/banner.go"
+ },
+ {
+ "collection": "home_banner",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "name",
+ 1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "name_1"
+ },
+ "source": "pub/models/homebannermod/banner.go"
+ },
+ {
+ "collection": "home_banner",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 44,
+ "options": {
+ "name": "status_1_createdAt_-1__id_-1"
+ },
+ "source": "pub/models/homebannermod/banner.go"
+ },
+ {
+ "collection": "home_banner",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "rank",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 51,
+ "options": {
+ "name": "status_1_rank_-1__id_-1"
+ },
+ "source": "pub/models/homebannermod/banner.go"
+ },
+ {
+ "collection": "home_banner",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "updatedAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "status_1_updatedAt_-1__id_-1"
+ },
+ "source": "pub/models/homebannermod/banner.go"
+ },
+ {
+ "collection": "home_config",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 46,
+ "options": {
+ "name": "createdAt_-1__id_-1"
+ },
+ "source": "pub/models/homeconfigmod/home_config.go"
+ },
+ {
+ "collection": "home_config",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "name",
+ 1
+ ]
+ ],
+ "line": 36,
+ "options": {
+ "name": "name_1"
+ },
+ "source": "pub/models/homeconfigmod/home_config.go"
+ },
+ {
+ "collection": "home_config",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "slug",
+ 1
+ ]
+ ],
+ "line": 41,
+ "options": {
+ "name": "slug_1"
+ },
+ "source": "pub/models/homeconfigmod/home_config.go"
+ },
+ {
+ "collection": "home_config",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "rank",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 52,
+ "options": {
+ "name": "status_1_rank_-1__id_-1"
+ },
+ "source": "pub/models/homeconfigmod/home_config.go"
+ },
+ {
+ "collection": "home_config",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "type",
+ 1
+ ],
+ [
+ "sortType",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "rank",
+ -1
+ ],
+ [
+ "updatedAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "type_1_sortType_1_status_1_rank_-1_updatedAt_-1__id_-1"
+ },
+ "source": "pub/models/homeconfigmod/home_config.go"
+ },
+ {
+ "collection": "hot_search",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "count",
+ -1
+ ],
+ [
+ "updatedAt",
+ -1
+ ]
+ ],
+ "line": 28,
+ "options": {
+ "name": "count_-1_updatedAt_-1"
+ },
+ "source": "pub/models/hotsearchmod/hotsearch.go"
+ },
+ {
+ "collection": "hot_search",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "keyword",
+ 1
+ ]
+ ],
+ "line": 24,
+ "options": {
+ "name": "keyword_1",
+ "unique": true
+ },
+ "source": "pub/models/hotsearchmod/hotsearch.go"
+ },
+ {
+ "collection": "ip_black",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "ip",
+ 1
+ ]
+ ],
+ "line": 22,
+ "options": {
+ "name": "ip_1",
+ "unique": true
+ },
+ "source": "pub/models/ipblackmod/ipblack.go"
+ },
+ {
+ "collection": "ip_daily_stat",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "appId",
+ 1
+ ],
+ [
+ "date",
+ 1
+ ]
+ ],
+ "line": 19,
+ "options": {
+ "name": "appId_1_date_1",
+ "unique": true
+ },
+ "source": "pub/models/ipdailystatmod/ipdailystat.go"
+ },
+ {
+ "collection": "m3u8_request_log",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "clientIP",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 36,
+ "options": {
+ "name": "clientIP_1_createdAt_-1"
+ },
+ "source": "pub/models/m3u8requestlogmod/m3u8_request_log.go"
+ },
+ {
+ "collection": "m3u8_request_log",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 19,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/m3u8requestlogmod/m3u8_request_log.go"
+ },
+ {
+ "collection": "m3u8_request_log",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "host",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 42,
+ "options": {
+ "name": "host_1_createdAt_-1"
+ },
+ "source": "pub/models/m3u8requestlogmod/m3u8_request_log.go"
+ },
+ {
+ "collection": "m3u8_request_log",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "m3u8Path",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 30,
+ "options": {
+ "name": "m3u8Path_1_createdAt_-1"
+ },
+ "source": "pub/models/m3u8requestlogmod/m3u8_request_log.go"
+ },
+ {
+ "collection": "m3u8_request_log",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 48,
+ "options": {
+ "name": "status_1_createdAt_-1"
+ },
+ "source": "pub/models/m3u8requestlogmod/m3u8_request_log.go"
+ },
+ {
+ "collection": "m3u8_request_log",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "tokenOK",
+ 1
+ ],
+ [
+ "pathOK",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 54,
+ "options": {
+ "name": "tokenOK_1_pathOK_1_createdAt_-1"
+ },
+ "source": "pub/models/m3u8requestlogmod/m3u8_request_log.go"
+ },
+ {
+ "collection": "m3u8_request_log",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "videoId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 24,
+ "options": {
+ "name": "videoId_1_createdAt_-1"
+ },
+ "source": "pub/models/m3u8requestlogmod/m3u8_request_log.go"
+ },
+ {
+ "collection": "mw_acc",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "_id",
+ 1
+ ]
+ ],
+ "line": 30,
+ "options": {
+ "name": "_id_1"
+ },
+ "source": "pub/models/mwacc/drop.go"
+ },
+ {
+ "collection": "mw_acc_token",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "_id",
+ 1
+ ]
+ ],
+ "line": 56,
+ "options": {
+ "name": "_id_1"
+ },
+ "source": "pub/models/mwacc/drop.go"
+ },
+ {
+ "collection": "mw_op_log",
+ "database": "hkdj_net_log",
+ "keys": [
+ [
+ "actId",
+ 1
+ ],
+ [
+ "action",
+ 1
+ ],
+ [
+ "path",
+ 1
+ ]
+ ],
+ "line": 27,
+ "options": {
+ "name": "actId_1_action_1_path_1"
+ },
+ "source": "pub/models/oplog/oplog.go"
+ },
+ {
+ "collection": "mw_op_log",
+ "database": "hkdj_net_log",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 34,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/oplog/oplog.go"
+ },
+ {
+ "collection": "mw_role",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "_id",
+ 1
+ ]
+ ],
+ "line": 43,
+ "options": {
+ "name": "_id_1"
+ },
+ "source": "pub/models/mwacc/drop.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "categoryIds",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "updatedAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 57,
+ "options": {
+ "name": "categoryIds_1_status_1_updatedAt_-1__id_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "contentType",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 151,
+ "options": {
+ "name": "contentType_1_createdAt_-1__id_-1",
+ "partialFilterExpression": {
+ "mediaCenterId": {
+ "$gt": 0
+ }
+ }
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 88,
+ "options": {
+ "name": "createdAt_-1__id_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "isFeatured",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 65,
+ "options": {
+ "name": "isFeatured_1_status_1_createdAt_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "mediaCenterId",
+ 1
+ ]
+ ],
+ "line": 134,
+ "options": {
+ "name": "mediaCenterId_1",
+ "partialFilterExpression": {
+ "mediaCenterId": {
+ "$gt": 0
+ }
+ },
+ "unique": true
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "operateType",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 144,
+ "options": {
+ "name": "operateType_1_status_1_createdAt_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "showOnBanner",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "topRank",
+ -1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 72,
+ "options": {
+ "name": "showOnBanner_1_status_1_topRank_-1_createdAt_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "slug",
+ 1
+ ]
+ ],
+ "line": 129,
+ "options": {
+ "name": "slug_1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "countBrowse",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 101,
+ "options": {
+ "name": "status_1_countBrowse_-1__id_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "countCollect",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 94,
+ "options": {
+ "name": "status_1_countCollect_-1__id_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "heatValue",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 108,
+ "options": {
+ "name": "status_1_heatValue_-1__id_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "isRecommend",
+ 1
+ ],
+ [
+ "recommendRank",
+ -1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 80,
+ "options": {
+ "name": "status_1_isRecommend_1_recommendRank_-1_createdAt_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "onlineAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 115,
+ "options": {
+ "name": "status_1_onlineAt_-1__id_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "updatedAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 122,
+ "options": {
+ "name": "status_1_updatedAt_-1__id_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "tagIds",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "updatedAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 49,
+ "options": {
+ "name": "tagIds_1_status_1_updatedAt_-1__id_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "themeIds",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "countCollect",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "themeIds_1_status_1_countCollect_-1__id_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "themeIds",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "themeIds_1_status_1_createdAt_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "themeIds",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "updatedAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 41,
+ "options": {
+ "name": "themeIds_1_status_1_updatedAt_-1__id_-1"
+ },
+ "source": "pub/models/playletmod/playlet.go"
+ },
+ {
+ "collection": "playlet_backflow_daily_stat",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "dataDate",
+ 1
+ ],
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 34,
+ "options": {
+ "name": "uniq_dataDate_playletId",
+ "unique": true
+ },
+ "source": "pub/models/playletbackflowstatmod/stat.go"
+ },
+ {
+ "collection": "playlet_backflow_daily_stat",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "dataDate",
+ 1
+ ],
+ [
+ "reportStatus",
+ 1
+ ],
+ [
+ "_id",
+ 1
+ ]
+ ],
+ "line": 27,
+ "options": {
+ "name": "dataDate_1_reportStatus_1__id_1"
+ },
+ "source": "pub/models/playletbackflowstatmod/stat.go"
+ },
+ {
+ "collection": "playlet_chapter",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 41,
+ "options": {
+ "name": "createdAt_-1__id_-1"
+ },
+ "source": "pub/models/playletchaptermod/chapter.go"
+ },
+ {
+ "collection": "playlet_chapter",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "currentEpisode",
+ 1
+ ],
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 68,
+ "options": {
+ "name": "uniq_playlet_chapter_episode",
+ "unique": true
+ },
+ "source": "pub/models/playletchaptermod/chapter.go"
+ },
+ {
+ "collection": "playlet_chapter",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "mediaSourceId",
+ 1
+ ]
+ ],
+ "line": 53,
+ "options": {
+ "name": "mediaSourceId_1",
+ "partialFilterExpression": {
+ "mediaSourceId": {
+ "$gt": ""
+ }
+ }
+ },
+ "source": "pub/models/playletchaptermod/chapter.go"
+ },
+ {
+ "collection": "playlet_chapter",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "playletId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 35,
+ "options": {
+ "name": "playletId_1_createdAt_-1"
+ },
+ "source": "pub/models/playletchaptermod/chapter.go"
+ },
+ {
+ "collection": "playlet_chapter",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "playletId",
+ 1
+ ],
+ [
+ "currentEpisode",
+ 1
+ ]
+ ],
+ "line": 29,
+ "options": {
+ "name": "playletId_1_currentEpisode_1"
+ },
+ "source": "pub/models/playletchaptermod/chapter.go"
+ },
+ {
+ "collection": "playlet_chapter",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "playletId",
+ 1
+ ],
+ [
+ "mediaContentId",
+ 1
+ ]
+ ],
+ "line": 78,
+ "options": {
+ "name": "playletId_1_mediaContentId_1",
+ "partialFilterExpression": {
+ "mediaContentId": {
+ "$gt": 0
+ }
+ },
+ "unique": true
+ },
+ "source": "pub/models/playletchaptermod/chapter.go"
+ },
+ {
+ "collection": "playlet_chapter",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "watermarkStatus",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 47,
+ "options": {
+ "name": "watermarkStatus_1_createdAt_-1"
+ },
+ "source": "pub/models/playletchaptermod/chapter.go"
+ },
+ {
+ "collection": "playlet_chapter_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "chapterId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "chapterId_1_createdAt_-1"
+ },
+ "source": "pub/models/playletchaptercollectmod/collect.go"
+ },
+ {
+ "collection": "playlet_chapter_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "playletId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "playletId_1_createdAt_-1"
+ },
+ "source": "pub/models/playletchaptercollectmod/collect.go"
+ },
+ {
+ "collection": "playlet_chapter_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "chapterId",
+ 1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "userId_1_chapterId_1",
+ "unique": true
+ },
+ "source": "pub/models/playletchaptercollectmod/collect.go"
+ },
+ {
+ "collection": "playlet_chapter_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 45,
+ "options": {
+ "name": "userId_1_createdAt_-1"
+ },
+ "source": "pub/models/playletchaptercollectmod/collect.go"
+ },
+ {
+ "collection": "playlet_chapter_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "chapterId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "chapterId_1_createdAt_-1"
+ },
+ "source": "pub/models/playletchapterlikemod/like.go"
+ },
+ {
+ "collection": "playlet_chapter_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "playletId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "playletId_1_createdAt_-1"
+ },
+ "source": "pub/models/playletchapterlikemod/like.go"
+ },
+ {
+ "collection": "playlet_chapter_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "chapterId",
+ 1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "userId_1_chapterId_1",
+ "unique": true
+ },
+ "source": "pub/models/playletchapterlikemod/like.go"
+ },
+ {
+ "collection": "playlet_chapter_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 45,
+ "options": {
+ "name": "userId_1_createdAt_-1"
+ },
+ "source": "pub/models/playletchapterlikemod/like.go"
+ },
+ {
+ "collection": "playlet_chapter_unlock",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ 1
+ ],
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "createdAt_1_playletId_1"
+ },
+ "source": "pub/models/playletchapterunlockmod/unlock.go"
+ },
+ {
+ "collection": "playlet_chapter_unlock",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "chapterId",
+ 1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "userId_1_chapterId_1",
+ "unique": true
+ },
+ "source": "pub/models/playletchapterunlockmod/unlock.go"
+ },
+ {
+ "collection": "playlet_chapter_unlock",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "userId_1_playletId_1"
+ },
+ "source": "pub/models/playletchapterunlockmod/unlock.go"
+ },
+ {
+ "collection": "playlet_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ 1
+ ],
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 46,
+ "options": {
+ "name": "createdAt_1_playletId_1"
+ },
+ "source": "pub/models/playletcollectmod/collect.go"
+ },
+ {
+ "collection": "playlet_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "playletId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "playletId_1_createdAt_-1"
+ },
+ "source": "pub/models/playletcollectmod/collect.go"
+ },
+ {
+ "collection": "playlet_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "userId_1_createdAt_-1__id_-1"
+ },
+ "source": "pub/models/playletcollectmod/collect.go"
+ },
+ {
+ "collection": "playlet_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "userId_1_playletId_1",
+ "unique": true
+ },
+ "source": "pub/models/playletcollectmod/collect.go"
+ },
+ {
+ "collection": "playlet_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ 1
+ ],
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "createdAt_1_playletId_1"
+ },
+ "source": "pub/models/playletlikemod/like.go"
+ },
+ {
+ "collection": "playlet_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "playletId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "playletId_1_createdAt_-1"
+ },
+ "source": "pub/models/playletlikemod/like.go"
+ },
+ {
+ "collection": "playlet_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "userId_1_playletId_1",
+ "unique": true
+ },
+ "source": "pub/models/playletlikemod/like.go"
+ },
+ {
+ "collection": "playlet_tag",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 43,
+ "options": {
+ "name": "createdAt_-1__id_-1"
+ },
+ "source": "pub/models/playlettagmod/playlettag.go"
+ },
+ {
+ "collection": "playlet_tag",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "name",
+ 1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "name_1"
+ },
+ "source": "pub/models/playlettagmod/playlettag.go"
+ },
+ {
+ "collection": "playlet_tag",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "slug",
+ 1
+ ]
+ ],
+ "line": 38,
+ "options": {
+ "name": "slug_1"
+ },
+ "source": "pub/models/playlettagmod/playlettag.go"
+ },
+ {
+ "collection": "playlet_tag",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "rank",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "status_1_rank_-1__id_-1"
+ },
+ "source": "pub/models/playlettagmod/playlettag.go"
+ },
+ {
+ "collection": "playlet_theme",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 50,
+ "options": {
+ "name": "createdAt_-1__id_-1"
+ },
+ "source": "pub/models/playletthememod/theme.go"
+ },
+ {
+ "collection": "playlet_theme",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "isRecommend",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "rank",
+ -1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "isRecommend_1_status_1_rank_-1"
+ },
+ "source": "pub/models/playletthememod/theme.go"
+ },
+ {
+ "collection": "playlet_theme",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "name",
+ 1
+ ]
+ ],
+ "line": 40,
+ "options": {
+ "name": "name_1"
+ },
+ "source": "pub/models/playletthememod/theme.go"
+ },
+ {
+ "collection": "playlet_theme",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "slug",
+ 1
+ ]
+ ],
+ "line": 45,
+ "options": {
+ "name": "slug_1"
+ },
+ "source": "pub/models/playletthememod/theme.go"
+ },
+ {
+ "collection": "playlet_theme",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "rank",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "status_1_rank_-1__id_-1"
+ },
+ "source": "pub/models/playletthememod/theme.go"
+ },
+ {
+ "collection": "playlet_watch_history",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "updatedAt",
+ 1
+ ]
+ ],
+ "line": 42,
+ "options": {
+ "expireAfterSeconds": 7776000,
+ "name": "updatedAt_1"
+ },
+ "source": "pub/models/playletwatchhistorymod/history.go"
+ },
+ {
+ "collection": "playlet_watch_history",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 28,
+ "options": {
+ "name": "userId_1_playletId_1",
+ "unique": true
+ },
+ "source": "pub/models/playletwatchhistorymod/history.go"
+ },
+ {
+ "collection": "playlet_watch_history",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "updatedAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 35,
+ "options": {
+ "name": "userId_1_updatedAt_-1__id_-1"
+ },
+ "source": "pub/models/playletwatchhistorymod/history.go"
+ },
+ {
+ "collection": "playlet_watch_progress",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 45,
+ "options": {
+ "name": "playletId_1"
+ },
+ "source": "pub/models/playletwatchprogressmod/progress.go"
+ },
+ {
+ "collection": "playlet_watch_progress",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "isChasing",
+ 1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "userId_1_isChasing_1"
+ },
+ "source": "pub/models/playletwatchprogressmod/progress.go"
+ },
+ {
+ "collection": "playlet_watch_progress",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "playletId",
+ 1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "userId_1_playletId_1",
+ "unique": true
+ },
+ "source": "pub/models/playletwatchprogressmod/progress.go"
+ },
+ {
+ "collection": "playlet_watch_progress",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "updatedAt",
+ -1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "userId_1_updatedAt_-1"
+ },
+ "source": "pub/models/playletwatchprogressmod/progress.go"
+ },
+ {
+ "collection": "recharge_package",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 34,
+ "options": {
+ "name": "createdAt_-1__id_-1"
+ },
+ "source": "pub/models/rechargepackagemod/package.go"
+ },
+ {
+ "collection": "recharge_package",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "packageType",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "sort",
+ 1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "packageType_1_status_1_sort_1__id_-1"
+ },
+ "source": "pub/models/rechargepackagemod/package.go"
+ },
+ {
+ "collection": "rich_text_content",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 23,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/richtextcontentmod/richtextcontent.go"
+ },
+ {
+ "collection": "rich_text_content",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 28,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/richtextcontentmod/richtextcontent.go"
+ },
+ {
+ "collection": "seo_push_task",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 34,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/seopushmod/push.go"
+ },
+ {
+ "collection": "seo_push_task",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "googleStatus",
+ 1
+ ],
+ [
+ "indexNowStatus",
+ 1
+ ]
+ ],
+ "line": 28,
+ "options": {
+ "name": "googleStatus_1_indexNowStatus_1"
+ },
+ "source": "pub/models/seopushmod/push.go"
+ },
+ {
+ "collection": "seo_push_task",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "url",
+ 1
+ ],
+ [
+ "action",
+ 1
+ ]
+ ],
+ "line": 20,
+ "options": {
+ "name": "url_1_action_1",
+ "unique": true
+ },
+ "source": "pub/models/seopushmod/push.go"
+ },
+ {
+ "collection": "sync_record",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 22,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/syncfilemod/sync.go"
+ },
+ {
+ "collection": "translation_category",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 29,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/translationcategorymod/category.go"
+ },
+ {
+ "collection": "translation_category",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 24,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/translationcategorymod/category.go"
+ },
+ {
+ "collection": "translation_info",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 29,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/translationmod/translation.go"
+ },
+ {
+ "collection": "translation_info",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 24,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/translationmod/translation.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "agentInviteCode",
+ -1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "agentInviteCode_-1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ 1
+ ]
+ ],
+ "line": 36,
+ "options": {
+ "name": "createdAt_1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "devID",
+ 1
+ ]
+ ],
+ "line": 41,
+ "options": {
+ "name": "devID_1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "devV2",
+ 1
+ ]
+ ],
+ "line": 51,
+ "options": {
+ "name": "devV2_1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "districtCode",
+ 1
+ ]
+ ],
+ "line": 61,
+ "options": {
+ "name": "districtCode_1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "inviteCode",
+ 1
+ ]
+ ],
+ "line": 31,
+ "options": {
+ "name": "inviteCode_1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "mac",
+ 1
+ ]
+ ],
+ "line": 46,
+ "options": {
+ "name": "mac_1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "nickName",
+ "text"
+ ]
+ ],
+ "line": 71,
+ "options": {
+ "name": "nickName_text"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "recharge",
+ 1
+ ]
+ ],
+ "line": 56,
+ "options": {
+ "name": "recharge_1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "type",
+ 1
+ ]
+ ],
+ "line": 76,
+ "options": {
+ "name": "status_1_type_1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 21,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "type",
+ 1
+ ]
+ ],
+ "line": 66,
+ "options": {
+ "name": "type_1"
+ },
+ "source": "pub/models/usermod/user.go"
+ },
+ {
+ "collection": "user_login",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 22,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/userlogin/userlogin.go"
+ },
+ {
+ "collection": "user_login",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "user_id",
+ 1
+ ],
+ [
+ "date",
+ 1
+ ]
+ ],
+ "line": 27,
+ "options": {
+ "name": "user_id_1_date_1"
+ },
+ "source": "pub/models/userlogin/userlogin.go"
+ },
+ {
+ "collection": "user_visit_log",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ -1
+ ]
+ ],
+ "line": 25,
+ "options": {
+ "name": "userId_-1"
+ },
+ "source": "pub/models/uservisitlog/dbop.go"
+ },
+ {
+ "collection": "user_visit_log",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "visitAt",
+ -1
+ ]
+ ],
+ "line": 30,
+ "options": {
+ "expireAfterSeconds": 432000,
+ "name": "visitAt_-1"
+ },
+ "source": "pub/models/uservisitlog/dbop.go"
+ },
+ {
+ "collection": "video_category",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "contentType",
+ 1
+ ]
+ ],
+ "line": 29,
+ "options": {
+ "name": "contentType_1"
+ },
+ "source": "pub/models/videocategorymod/category.go"
+ },
+ {
+ "collection": "video_category",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 34,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/videocategorymod/category.go"
+ },
+ {
+ "collection": "video_category",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "slug",
+ 1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "slug_1"
+ },
+ "source": "pub/models/videocategorymod/category.go"
+ },
+ {
+ "collection": "video_category",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 24,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/videocategorymod/category.go"
+ },
+ {
+ "collection": "video_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ 1
+ ],
+ [
+ "videoId",
+ 1
+ ]
+ ],
+ "line": 46,
+ "options": {
+ "name": "createdAt_1_videoId_1"
+ },
+ "source": "pub/models/videocollectmod/collect.go"
+ },
+ {
+ "collection": "video_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "userId_1_createdAt_-1__id_-1"
+ },
+ "source": "pub/models/videocollectmod/collect.go"
+ },
+ {
+ "collection": "video_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "videoId",
+ 1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "userId_1_videoId_1",
+ "unique": true
+ },
+ "source": "pub/models/videocollectmod/collect.go"
+ },
+ {
+ "collection": "video_collect",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "videoId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "videoId_1_createdAt_-1"
+ },
+ "source": "pub/models/videocollectmod/collect.go"
+ },
+ {
+ "collection": "video_creater",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 29,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/videocreatermod/creater.go"
+ },
+ {
+ "collection": "video_creater",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "slug",
+ 1
+ ]
+ ],
+ "line": 34,
+ "options": {
+ "name": "slug_1"
+ },
+ "source": "pub/models/videocreatermod/creater.go"
+ },
+ {
+ "collection": "video_creater",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 24,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/videocreatermod/creater.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "addedTime",
+ -1
+ ]
+ ],
+ "line": 24,
+ "options": {
+ "name": "addedTime_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "categoryIds",
+ 1
+ ]
+ ],
+ "line": 29,
+ "options": {
+ "name": "categoryIds_1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 49,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "creatorId",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ],
+ [
+ "addedTime",
+ -1
+ ],
+ [
+ "real.watchTimes",
+ -1
+ ]
+ ],
+ "line": 131,
+ "options": {
+ "name": "creatorId_1_status_1_addedTime_-1_real.watchTimes_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "creatorId",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 106,
+ "options": {
+ "name": "creatorId_1_status_1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "creatorId",
+ 1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "creatorId_1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "hevcStatus",
+ 1
+ ],
+ [
+ "hevcFailCount",
+ 1
+ ],
+ [
+ "real.watchTimes",
+ -1
+ ]
+ ],
+ "line": 174,
+ "options": {
+ "name": "hevcStatus_1_hevcFailCount_1_real.watchTimes_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "hevcStatus",
+ 1
+ ]
+ ],
+ "line": 181,
+ "options": {
+ "name": "hevcStatus_1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "isTop",
+ -1
+ ],
+ [
+ "addedTime",
+ -1
+ ]
+ ],
+ "line": 162,
+ "options": {
+ "name": "isTop_-1_addedTime_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "nodes.nodeId",
+ 1
+ ]
+ ],
+ "line": 112,
+ "options": {
+ "name": "nodes.nodeId_1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "slug",
+ 1
+ ]
+ ],
+ "line": 186,
+ "options": {
+ "name": "slug_1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "sort",
+ -1
+ ],
+ [
+ "addedTime",
+ -1
+ ]
+ ],
+ "line": 168,
+ "options": {
+ "name": "sort_-1_addedTime_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "addedTime",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 83,
+ "options": {
+ "name": "status_1_addedTime_-1__id_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "addedTime",
+ -1
+ ],
+ [
+ "isTop",
+ -1
+ ]
+ ],
+ "line": 155,
+ "options": {
+ "name": "status_1_addedTime_-1_isTop_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "addedTime",
+ -1
+ ],
+ [
+ "real.watchTimes",
+ -1
+ ]
+ ],
+ "line": 117,
+ "options": {
+ "name": "status_1_addedTime_-1_real.watchTimes_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "addedTime",
+ -1
+ ],
+ [
+ "sort",
+ -1
+ ]
+ ],
+ "line": 124,
+ "options": {
+ "name": "status_1_addedTime_-1_sort_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "addedTime",
+ -1
+ ]
+ ],
+ "line": 77,
+ "options": {
+ "name": "status_1_addedTime_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "categoryIds",
+ 1
+ ],
+ [
+ "addedTime",
+ -1
+ ]
+ ],
+ "line": 99,
+ "options": {
+ "name": "status_1_categoryIds_1_addedTime_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "categoryIds",
+ 1
+ ]
+ ],
+ "line": 54,
+ "options": {
+ "name": "status_1_categoryIds_1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "mediaType",
+ 1
+ ],
+ [
+ "addedTime",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 91,
+ "options": {
+ "name": "status_1_mediaType_1_addedTime_-1__id_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "real.watchTimes",
+ -1
+ ]
+ ],
+ "line": 65,
+ "options": {
+ "name": "status_1_real.watchTimes_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "sort",
+ -1
+ ]
+ ],
+ "line": 71,
+ "options": {
+ "name": "status_1_sort_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "tags",
+ 1
+ ],
+ [
+ "addedTime",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 147,
+ "options": {
+ "name": "status_1_tags_1_addedTime_-1__id_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "tags",
+ 1
+ ],
+ [
+ "addedTime",
+ -1
+ ],
+ [
+ "real.watchTimes",
+ -1
+ ]
+ ],
+ "line": 139,
+ "options": {
+ "name": "status_1_tags_1_addedTime_-1_real.watchTimes_-1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 44,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "tags",
+ 1
+ ]
+ ],
+ "line": 34,
+ "options": {
+ "name": "tags_1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_information",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "video.fileId",
+ 1
+ ]
+ ],
+ "line": 60,
+ "options": {
+ "name": "video.fileId_1"
+ },
+ "source": "pub/models/videoinformationmod/information.go"
+ },
+ {
+ "collection": "video_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ 1
+ ],
+ [
+ "videoId",
+ 1
+ ]
+ ],
+ "line": 39,
+ "options": {
+ "name": "createdAt_1_videoId_1"
+ },
+ "source": "pub/models/videolikemod/like.go"
+ },
+ {
+ "collection": "video_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "videoId",
+ 1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "userId_1_videoId_1",
+ "unique": true
+ },
+ "source": "pub/models/videolikemod/like.go"
+ },
+ {
+ "collection": "video_like",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "videoId",
+ 1
+ ],
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 33,
+ "options": {
+ "name": "videoId_1_createdAt_-1"
+ },
+ "source": "pub/models/videolikemod/like.go"
+ },
+ {
+ "collection": "video_taginfo",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 29,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/videotagmod/taginfo.go"
+ },
+ {
+ "collection": "video_taginfo",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "slug",
+ 1
+ ]
+ ],
+ "line": 46,
+ "options": {
+ "name": "slug_1"
+ },
+ "source": "pub/models/videotagmod/taginfo.go"
+ },
+ {
+ "collection": "video_taginfo",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "sort",
+ -1
+ ]
+ ],
+ "line": 34,
+ "options": {
+ "name": "status_1_sort_-1"
+ },
+ "source": "pub/models/videotagmod/taginfo.go"
+ },
+ {
+ "collection": "video_taginfo",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "watchTimes",
+ -1
+ ]
+ ],
+ "line": 40,
+ "options": {
+ "name": "status_1_watchTimes_-1"
+ },
+ "source": "pub/models/videotagmod/taginfo.go"
+ },
+ {
+ "collection": "video_taginfo",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 24,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/videotagmod/taginfo.go"
+ },
+ {
+ "collection": "video_topic",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 41,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/videotopicmod/videotopic.go"
+ },
+ {
+ "collection": "video_topic",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "slug",
+ 1
+ ]
+ ],
+ "line": 46,
+ "options": {
+ "name": "slug_1"
+ },
+ "source": "pub/models/videotopicmod/videotopic.go"
+ },
+ {
+ "collection": "video_topic",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ],
+ [
+ "rank",
+ -1
+ ]
+ ],
+ "line": 29,
+ "options": {
+ "name": "status_1_rank_-1"
+ },
+ "source": "pub/models/videotopicmod/videotopic.go"
+ },
+ {
+ "collection": "video_topic",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 24,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/videotopicmod/videotopic.go"
+ },
+ {
+ "collection": "video_topic",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "videoCategoryId",
+ 1
+ ],
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 35,
+ "options": {
+ "name": "videoCategoryId_1_status_1"
+ },
+ "source": "pub/models/videotopicmod/videotopic.go"
+ },
+ {
+ "collection": "video_watch_history",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "updatedAt",
+ 1
+ ]
+ ],
+ "line": 42,
+ "options": {
+ "expireAfterSeconds": 7776000,
+ "name": "updatedAt_1"
+ },
+ "source": "pub/models/videowatchhistorymod/history.go"
+ },
+ {
+ "collection": "video_watch_history",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "updatedAt",
+ -1
+ ],
+ [
+ "_id",
+ -1
+ ]
+ ],
+ "line": 35,
+ "options": {
+ "name": "userId_1_updatedAt_-1__id_-1"
+ },
+ "source": "pub/models/videowatchhistorymod/history.go"
+ },
+ {
+ "collection": "video_watch_history",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ 1
+ ],
+ [
+ "videoId",
+ 1
+ ]
+ ],
+ "line": 28,
+ "options": {
+ "name": "userId_1_videoId_1",
+ "unique": true
+ },
+ "source": "pub/models/videowatchhistorymod/history.go"
+ },
+ {
+ "collection": "vip_order",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "createdAt",
+ -1
+ ]
+ ],
+ "line": 30,
+ "options": {
+ "name": "createdAt_-1"
+ },
+ "source": "pub/models/vipordermod/order.go"
+ },
+ {
+ "collection": "vip_order",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "districtCode",
+ 1
+ ]
+ ],
+ "line": 29,
+ "options": {
+ "name": "districtCode_1"
+ },
+ "source": "pub/models/vipordermod/order.go"
+ },
+ {
+ "collection": "vip_order",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "status",
+ 1
+ ]
+ ],
+ "line": 28,
+ "options": {
+ "name": "status_1"
+ },
+ "source": "pub/models/vipordermod/order.go"
+ },
+ {
+ "collection": "vip_order",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "tradeNo",
+ 1
+ ]
+ ],
+ "line": 27,
+ "options": {
+ "name": "tradeNo_1",
+ "unique": true
+ },
+ "source": "pub/models/vipordermod/order.go"
+ },
+ {
+ "collection": "vip_order",
+ "database": "hkdj_net",
+ "keys": [
+ [
+ "userId",
+ -1
+ ]
+ ],
+ "line": 26,
+ "options": {
+ "name": "userId_-1"
+ },
+ "source": "pub/models/vipordermod/order.go"
+ }
+ ],
+ "source_revision": "9bfbafe10d417deea959bd53117bc88a10e60a83",
+ "target_project": "花魁NET / HKDJ / JHA-216"
+}
diff --git a/deploy/test/nginx.conf b/deploy/test/nginx.conf
new file mode 100644
index 0000000..36616cb
--- /dev/null
+++ b/deploy/test/nginx.conf
@@ -0,0 +1,26 @@
+# Backend API services: app 30331, web 30332.
+# Frontend owns its admin build and H5 server. These are separate entrances.
+server {
+ listen 30333;
+ server_name _;
+ root /server/hkdj_net/houtai/dist;
+ index index.html;
+
+ location /api/web/ {
+ proxy_pass http://127.0.0.1:30332;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+ location /wbapi/hkdjnet/ {
+ proxy_pass http://127.0.0.1:30332;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+ location / { try_files $uri $uri/ /index.html; }
+}
+
+
+# H5 frontend service independently listens on 30334.
+# Its /api/app/* and /h5/* requests target http://127.0.0.1:30331.
+# The frontend team supplies the H5/SSR runtime and matching API configuration.
diff --git a/deploy/test/supervisor.conf b/deploy/test/supervisor.conf
new file mode 100644
index 0000000..e56257d
--- /dev/null
+++ b/deploy/test/supervisor.conf
@@ -0,0 +1,25 @@
+[program:hkdj_net_app]
+directory=/server/hkdj_net/app
+command=/bin/bash -c 'set -a; source /server/hkdj_net/.env; set +a; exec ./hkdj-net-app'
+autostart=true
+autorestart=true
+startsecs=10
+stopasgroup=true
+killasgroup=true
+stdout_logfile=/server/hkdj_net/logs/app.log
+stdout_logfile_maxbytes=20MB
+stdout_logfile_backups=2
+redirect_stderr=true
+
+[program:hkdj_net_web]
+directory=/server/hkdj_net/web
+command=/bin/bash -c 'set -a; source /server/hkdj_net/.env; set +a; exec ./hkdj-net-web'
+autostart=true
+autorestart=true
+startsecs=10
+stopasgroup=true
+killasgroup=true
+stdout_logfile=/server/hkdj_net/logs/web.log
+stdout_logfile_maxbytes=20MB
+stdout_logfile_backups=2
+redirect_stderr=true
diff --git a/docs/App-H5接入.md b/docs/App-H5接入.md
new file mode 100644
index 0000000..e83b15d
--- /dev/null
+++ b/docs/App-H5接入.md
@@ -0,0 +1,28 @@
+# 花魁 NET H5 接入
+
+来源版本:黄果 NET `9bfbafe10d417deea959bd53117bc88a10e60a83`;花魁适配已推送开发分支并集成到独立test分支;本地构建、vet、配置/密钥隔离核验已通过,测试环境HTTP结果见交付记录。
+
+H5 页面服务预留30334,目录 `/server/hkdj_net/h5`;H5 业务 API 为 `http://172.104.181.99:30331`。用户提供的测试页面域名为 `https://hkdjneth5.remoces.com`,域名代理待运维落地,不以域名已提供作为已部署页面的证明。
+
+这里的“服务端渲染”指源黄果NET前端仓库 `ruisheng/jha-1021-91jhs/frontend/jha-1045-hgdjnet-web` 的Go页面程序读取模板并返回HTML。2026-10-10只读核验其Jenkins `Web_91YS_H5`:通过 `go build` 构建 `web_91ys_h5`,打包二进制、`static/`、`template/`,由Supervisor启动;源测试目录也存在对应静态资源和模板。因此H5需由前端交付独立Go页面程序及资源,不能套用HT的npm/dist上传脚本。
+
+建议交接关系:H5测试域名的页面请求进入本项目30334页面服务;该页面服务/浏览器业务请求使用30331的App API。API服务和页面服务分别部署;源项目额外导航端口不在本次两前端入口规划内。
+
+| 用途 | 方法 | 路径 |
+| --- | --- | --- |
+| 页面配置 | GET | `/api/app/config/info` |
+| 短剧首页 | GET | `/api/app/playlet/home` |
+| 短剧搜索 | GET | `/api/app/playlet/search` |
+| 应用列表 | GET | `/api/app/applist/list` |
+| 游客登录 | POST | `/api/app/auth/guest` |
+| 依赖健康检查 | POST | `/api/app/health/ping` |
+
+`/api/app/*` 路径及 NET 的 JSON 返回协议保留。页面路由仍使用源项目的 `/play/...`、`/topic-detail/...`、`/tags/...` 等路径;前端不应直接替换为现有花魁短剧 H5 的路由。
+
+游客、账号和 token 属于独立花魁 NET 测试库;不能向黄果或现有花魁短剧接口发送本次联调请求。支付、购买和外部发布不做测试。播放所需的花魁媒资地址、密钥和资源记录尚未接入,不能沿用黄果资源。
+
+后台前端30333和H5前端30334是两个独立服务入口。H5采用独立SSR进程时,直接监听30334,不额外复制黄果导航服务;本次只准备后端与端口规划,H5前端服务尚未部署。
+
+测试已通过页面配置、首页、搜索、应用列表和游客登录(HTTP200、业务200);专用游客ID10001,设备标识 `codex_hkdjnet_guest_20261010`。这些结果不代表已有可播放内容。`/enkey/`、`/newenkey/` 缺少有效播放票据时返回业务6020;鉴权后的 `/lsjenkey` 因未配置密钥返回6020。无效H5播放票据按源逻辑走占位视频路径,当前媒资域名未配置,最终返回6020;不能将该结果当作真实播放已通过。健康检查拒绝其他项目标识,合法签名下Redis/MongoDB/ES均为ok。
+
+前端交接还需配置品牌、域名、SSR监听和API转发,使用花魁NET的token与播放票据;广告、应用媒资、支付、短信及真实播放资源需使用花魁自己的接入资料。准确版本及核验明细见[测试交付记录](测试交付记录-20261010.md)。
diff --git a/docs/Web-后台接入.md b/docs/Web-后台接入.md
new file mode 100644
index 0000000..40fc977
--- /dev/null
+++ b/docs/Web-后台接入.md
@@ -0,0 +1,35 @@
+# 花魁 NET 后台接入
+
+来源版本:黄果 NET `9bfbafe10d417deea959bd53117bc88a10e60a83`;花魁适配已推送开发分支并集成到独立test分支;本地构建、vet、配置/密钥隔离核验已通过,测试环境HTTP结果见交付记录。
+
+后台前端独立入口计划为 `http://172.104.181.99:30333/`,静态产物目录 `/server/hkdj_net/houtai/dist`。后台 API 为 `http://172.104.181.99:30332`;`deploy/test/nginx.conf` 是前端部署时使用的转发模板,当前尚未安装。
+
+用户2026-10-10提供测试后台域名 `https://hkdjnetht.remoces.com`,由运维配置域名入口;后台页面使用本项目静态目录,`/api/web/` 与 `/wbapi/hkdjnet/` 转发到 `127.0.0.1:30332`。域名已提供不代表Nginx或前端产物已部署。
+
+用户提供的HT前端发布脚本如下,供前端/运维在已适配花魁的HT前端仓库执行;本次仅记录,未执行构建、上传或替换目录:
+
+```bash
+npm run build
+zip -q -r dist.zip dist
+scp -P 22889 dist.zip root@172.104.181.99:/server/hkdj_net/houtai/
+ssh -p 22889 root@172.104.181.99 "cd /server/hkdj_net/houtai && rm -rf dist && unzip -o dist.zip && rm dist.zip"
+rm dist.zip
+```
+
+该脚本仅上传HT的静态dist,不负责安装Nginx域名/代理配置,也不构建H5的Go模板页面服务。
+
+| 用途 | 方法 | 路径 |
+| --- | --- | --- |
+| 后台登录 | POST | `/api/web/login/acc` |
+| 后台配置 | GET | `/api/web/syscfg/get` |
+| 角色权限 | GET | `/api/web/role/getauth` |
+| IP统计 | GET | `/wbapi/hkdjnet/ipstat` |
+| 充值统计 | GET | `/wbapi/hkdjnet/recharge/summary` |
+
+原 `/api/web/*` 业务路径保留;原黄果专属 `/wbapi/hgdjnet/*` 改为 `/wbapi/hkdjnet/*`。登录后的鉴权继续使用 NET 原接口返回的 token,不能沿用黄果或现有花魁短剧 token。
+
+前端交接:将后台名称改为花魁 NET,API 使用本入口同源地址,并交付独立后台静态产物。本次后端工作未修改或复制黄果前端产物。
+
+新测试库已初始化专用管理员、角色和系统配置(ID均为1);凭据仅在根.env的 `HKDJNET_TEST_ADMIN_*` 中。角色权限查询带 `id=<登录返回roleId>`;角色前端菜单未复制黄果,前端需按自己的版本配置。后台页面尚未部署,后台API登录以交付记录为准。
+
+上述五个接口已使用test主机curl核验HTTP200、业务200,统计测试日期为2026-10-10。旧 `/wbapi/hgdjnet/ipstat` 返回404;未配置媒体密钥时,鉴权后的 `/api/web/enkey`、`/api/web/newenkey`、`/api/web/lsjenkey` 返回业务6020。媒资定时任务默认关闭,手动媒资接口仍需完成嘉华产品身份/协议适配后再使用。准确版本及核验明细见[测试交付记录](测试交付记录-20261010.md)。
diff --git a/docs/测试交付记录-20261010.md b/docs/测试交付记录-20261010.md
new file mode 100644
index 0000000..ff34df4
--- /dev/null
+++ b/docs/测试交付记录-20261010.md
@@ -0,0 +1,75 @@
+# 花魁 NET 测试交付记录
+
+2026-10-10,北京时间17:07核验。项目为HKDJ / JHA-216,独立官网仓库 `hkdj-web-server`;GitLab路径中的jha-215不改变项目归属。仓库已可通过配置的Git VPN/SSH访问,代码已提交推送。仅完成测试后端交付,正式环境未部署。
+
+## 准确版本与构建
+
+| 项目 | 版本/证据 |
+| --- | --- |
+| 复刻来源 | 黄果NET `9bfbafe10d417deea959bd53117bc88a10e60a83` |
+| 开发分支 | `feat/net-import` |
+| 业务开发提交 | `7d4925970afc7ca645b0d146340ec7392d3a2e3e` |
+| 部署test提交 | `eb0af077f729cd492931efaef8e7a4ff877e4124`,包含上述开发提交 |
+| App Jenkins | [花魁NET #4](http://192.53.172.70:8080/job/%E8%8A%B1%E9%AD%81NET/4/),SUCCESS |
+| Web Jenkins | [花魁NET #5](http://192.53.172.70:8080/job/%E8%8A%B1%E9%AD%81NET/5/),SUCCESS |
+
+两个构建的SCM均为本仓库,参数 `BUILD_BRANCHE=origin/test`、对应 `BUILD_ITEM`、完整 `EXPECTED_COMMIT`,部署锁覆盖构建到核验。原生Go build info核验module为 `hkdj-web-server`、package分别为 `hkdj-web-server/app` / `hkdj-web-server/web`,`vcs.revision`均为上述test提交,`vcs.modified=false`。后续仅交付文档,不改变已运行版本。
+
+## 进程、端口与健康
+
+测试主机 `172.104.181.99`,部署根目录 `/server/hkdj_net`。
+
+| 组件 | Supervisor / PID | 监听 | 二进制SHA256 |
+| --- | --- | --- | --- |
+| App/H5 API | `hkdj_net_app` / 3288320,RUNNING | 30331,归属该PID | `23107fd6188a8a4dbe8730d11ab8b8fb106d3c1720988590ab21630bf9f48581` |
+| Web/后台 API | `hkdj_net_web` / 3300065,RUNNING | 30332,归属该PID | `3ec6b84e320f3d24d5dc0f4930da29fd7d5242d6849a27a3c7b40721740fe395` |
+| 后台前端 | 预留 `/server/hkdj_net/houtai/dist` | 30333未监听 | 前端产物未部署 |
+| H5前端 | 预留 `/server/hkdj_net/h5` | 30334未监听 | 独立H5/SSR待前端交付 |
+
+App签名POST `/api/app/health/ping` 返回HTTP200、业务200,Redis/MongoDB/Elasticsearch均为 `ok`。Web无专用健康接口,以准确运行版本、稳定PID、二进制校验和及30332监听归属证明,另行验证业务接口。运行配置均为 `runMod=test`、Redis DB24;`/server/hkdj_net/.env` 权限600。
+
+## test主机curl结果
+
+请求在测试主机通过curl执行,密码、token和签名通过stdin传递;不记录凭据。成功接口均为HTTP200、业务200。
+
+| 端 | 方法与接口 | 核验 |
+| --- | --- | --- |
+| App | GET `/api/app/config/info` | 配置品牌为花魁短剧web站点 |
+| App | GET `/api/app/playlet/home` | 首页返回正常,独立空内容库 |
+| App | GET `/api/app/playlet/search?keyword=codex` | 搜索正常,未导入内容 |
+| App | GET `/api/app/applist/list` | 应用列表返回正常,未配置媒资拉取 |
+| App | POST `/api/app/auth/guest` | 专用设备 `codex_hkdjnet_guest_20261010`,用户10001,返回本项目token |
+| Web | POST `/api/web/login/acc` | 专用管理员登录正常 |
+| Web | GET `/api/web/syscfg/get` | 配置品牌正确 |
+| Web | GET `/api/web/role/getauth?id=1` | 专用角色权限查询正常 |
+| Web | GET `/wbapi/hkdjnet/ipstat` | 2026-10-10起止日期统计正常 |
+| Web | GET `/wbapi/hkdjnet/recharge/summary` | 同日期统计正常,无充值/支付测试 |
+
+边界核验:外项目groupId的健康请求返回HTTP200/业务1001;旧 `/wbapi/hgdjnet/ipstat` 返回404。App `/enkey/`、`/newenkey/` 的无有效票据请求返回6020,鉴权后的 `/lsjenkey` 因未配置密钥返回6020;Web鉴权后的 `/api/web/enkey`、`/api/web/newenkey`、`/api/web/lsjenkey` 同样返回6020。无效H5票据被识别并按源逻辑转占位视频路径,因当前未配置媒资域名最终返回6020,不代表真实播放验证通过。本地票据测试另验证本项目票据可校验、其他密钥票据及缺失密钥不能校验。
+
+## 初始化与索引回读
+
+用户批准的清单 `deploy/test/index-plan.json`,SHA256 `3f56441d0c496a9817c5d2f269084eab8450a2d73bcaec9f45a415cbd1751541`。206条MongoDB索引请求全部按有序keys及声明unique/TTL/partial选项回读一致;206包含复用系统_id索引的请求,不等于206个新增物理索引。浏览历史TTL90天,访问日志TTL5天。
+
+仅初始化 `hkdj_net` / `hkdj_net_log`,专用MongoDB用户角色仅这两库readWrite。管理员、角色、系统配置ID均为1,游客10001;没有迁移黄果管理员或内容数据。管理员凭据只保存在根.env的 `HKDJNET_TEST_ADMIN_*` 中,前端菜单 `role.router` 尚待前端按其版本配置。
+
+ES仅初始化 `webhkdjnet_playlet_index_v1`,mapping及analysis回读一致。用户另行批准后,在测试部署锁内再次核对UUID `RTK05ByzST-q2pVxnAjDfQ`、创建时间1791621305061、从未启动的 `INDEX_CREATED` 状态,对primary0执行一次 `allocate_empty_primary`。最终primary active=1、文档0、状态yellow;没有修改共享集群设置或其他项目索引。单节点副本未分配,共享节点磁盘超过90%高水位的问题仍需运维处理。
+
+## 检查、日志与回滚资料
+
+改动相关包测试、vet、App/Web构建、Python/Shell语法及提交差异检查已通过。配置加载、健康签名、媒体密钥、H5票据、未配置广告时不走旧域名、媒资任务关闭均有针对性验证。本地测试文件和构建产物未提交;未生成或部署Swagger。
+
+仅检查最新test SHA启动段:两库、Redis、ES和模型初始化成功,两端启动正常;未再出现旧广告备用域名、空媒资URL或旧赤果自动回流错误。App有本次游客请求缺少SDK公共字段的警告,未影响登录;其余告警对应主动测试的错误groupId、无效票据、缺失媒体密钥/域名,属于上述预期返回。
+
+最终安装前保留并核验旧二进制回滚副本(旧SHA `f5c179a1aa27e627b2d193f794ceaec90ade91b9`):
+
+- `/server/hkdj_net/app/.rollback/hkdj-net-app.f5c179a1aa27e627b2d193f794ceaec90ade91b9.d857e0b19c1dd1438588b4351d9d4b95271c40d6fe27fb22494b988fd0518322`
+- `/server/hkdj_net/web/.rollback/hkdj-net-web.f5c179a1aa27e627b2d193f794ceaec90ade91b9.65db902ade235ae24a1c40b8c6403374230e583b2bd6cfa69ffa9d13c60a7558`
+
+以上为二进制备份,不是数据库回滚;需要回滚时仍通过本项目部署守卫恢复并验证准确版本。旧版本存在未配置外部接入时仍发起请求的问题,不能将其视为等价于最终版本的可用状态。本次不删除初始化索引,不重复强制分配ES主分片。
+
+## 前端交接与剩余接入
+
+[后台Web接入](Web-后台接入.md)与[H5 App接入](App-H5接入.md)分别交付。前端需要完成花魁品牌、域名、API配置、菜单以及独立后台静态/H5 SSR产物;30333/30334仅预留,Nginx文件为模板,当前没有可登录的后台/H5页面。
+
+支付、短信、媒资、广告、统计上报、内容回流没有复制黄果凭据,默认关闭相应外部拉取;Web `mediaJobsEnabled=false`。花魁使用嘉华媒体库,后续手动/自动媒资接口还需核对本项目产品身份、协议和资源记录,不能接入黄果独立媒体库。本次不声称真实播放、支付或前端页面已完成。
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..df35b01
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,111 @@
+module hkdj-web-server
+
+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/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
+ golang.org/x/text v0.32.0
+ 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/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // 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/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/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/montanaflynn/stats v0.7.1 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
+ github.com/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/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
+ 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..5631915
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,492 @@
+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/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/ant0ine/go-json-rest v3.3.2+incompatible/go.mod h1:q6aCt0GfU6LhpBsnZ/2U+mwe+0XB5WStbmwyoPfc+sk=
+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aws/aws-sdk-go v1.34.28 h1:sscPpn/Ns3i0F4HPEWAVcwdIRaZZCuL7llJ2/60yPIk=
+github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48=
+github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394/go.mod h1:Q8n74mJTIgjX4RBBcHnJ05h//6/k6foqmgE45jTQtxg=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
+github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
+github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
+github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
+github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
+github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
+github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ddliu/go-httpclient v0.6.9 h1:/3hsBVpcgCJwqm1dkVlnAJ9NWuYInbRc+i9FyUXX/LE=
+github.com/ddliu/go-httpclient v0.6.9/go.mod h1:zM9P0OxV4OGGz1pt/ibuj0ooX2SWH9a6MvXZLbT0JMc=
+github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 h1:AqeKSZIG/NIC75MNQlPy/LM3LxfpLwahICJBHwSMFNc=
+github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3/go.mod h1:hEfFauPHz7+NnjR/yHJGhrKo1Za+zStgwUETx3yzqgY=
+github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/elastic/go-elasticsearch/v7 v7.17.10 h1:TCQ8i4PmIJuBunvBS6bwT2ybzVFxxUhhltAs3Gyu1yo=
+github.com/elastic/go-elasticsearch/v7 v7.17.10/go.mod h1:OJ4wdbtDNk5g503kvlHLyErCgQwwzmDtaFC4XyOxXA4=
+github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
+github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/gin-contrib/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-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/jroimartin/gocui v0.4.0/go.mod h1:7i7bbj99OgFHzo7kB2zPb8pXLqMBSQegY7azfqXMkyY=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/kardianos/service v1.0.0/go.mod h1:8CzDhVuCuugtsHyZoTvsOBuvonN/UDBvl0kH+BUxvbo=
+github.com/karrick/godirwalk v1.10.3 h1:lOpSw2vJP0y5eLBW906QwKsUK/fe/QDyoqM5rnnuPDY=
+github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
+github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20251124080701-096d68ea7706 h1:yi4Usm9TUC5itUsw6DPqWXZnfSui0Y3qWEoeFlq778s=
+github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20251124080701-096d68ea7706/go.mod h1:+mNMTBuDMdEGhWzoQgc6kBdqeaQpWh5ba8zqmp2MxCU=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
+github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
+github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
+github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
+github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
+github.com/muesli/cache2go v0.0.0-20200423001931-a100c5aac93f/go.mod h1:414R+qZrt4f9S2TO/s6YVQMNAXR2KdwqQ7pW+O4oYzU=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/nicksnyder/go-i18n/v2 v2.0.3/go.mod h1:oDab7q8XCYMRlcrBnaY/7B1eOectbvj6B1UPBT+p5jo=
+github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
+github.com/nsqio/go-nsq v1.0.8/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY=
+github.com/nyaruka/phonenumbers v1.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.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/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/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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+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-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.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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/eapache/queue.v1 v1.1.0 h1:EldqoJEGtXYiVCMRo2C9mePO2UUGnYn2+qLmlQSqPdc=
+gopkg.in/eapache/queue.v1 v1.1.0/go.mod h1:wNtmx1/O7kZSR9zNT1TTOJ7GLpm3Vn7srzlfylFbQwU=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
+gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
+gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M=
+gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
+gopkg.in/go-with/wxpay.v1 v1.3.0/go.mod h1:12lWy92n19pAUSSE3BrOiEZbWRkl+9tneOd/aU/LU6g=
+gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
+gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
diff --git a/project.md b/project.md
new file mode 100644
index 0000000..2f5255b
--- /dev/null
+++ b/project.md
@@ -0,0 +1,14 @@
+---
+项目名称: 花魁NET官网后端
+一级部门: 北斗
+二级部门: 瑞升
+DMP编码: JHA-216
+项目类型: 服务
+开发语言: GO
+项目负责人: 朱飞
+项目状态: 开发中
+核心分支: prod
+代码仓库:
+ - ssh://git@bd-git-sh.jsyyds.com:8082/ruisheng/jha-215/backend/hkdj-web-server.git
+备注: 黄果NET后端独立复刻;app提供H5业务API,web提供后台API。仓库路径jha-215不改变花魁JHA-216身份。
+---
diff --git a/pub/adcenter/adcenter.go b/pub/adcenter/adcenter.go
new file mode 100644
index 0000000..be754c9
--- /dev/null
+++ b/pub/adcenter/adcenter.go
@@ -0,0 +1,302 @@
+package adcenter
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "hkdj-web-server/pub/common/crypt"
+ "hkdj-web-server/pub/common/godef"
+ "hkdj-web-server/pub/common/httputil"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/redis"
+ "net/http"
+ "path"
+ "sort"
+ "strings"
+ "time"
+)
+
+/*
+测试:
+部门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) {
+ if strings.TrimSpace(apiDomain) == "" {
+ return []AdSlot{}, nil
+ }
+
+ 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..34bc221
--- /dev/null
+++ b/pub/common/comm/comm.go
@@ -0,0 +1,138 @@
+package comm
+
+import (
+ "encoding/json"
+ "hkdj-web-server/pub/common/godef"
+ "hkdj-web-server/pub/common/lock"
+ "hkdj-web-server/pub/common/log"
+ "sync"
+ "time"
+
+ "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..bc20c94
--- /dev/null
+++ b/pub/common/common.go
@@ -0,0 +1,173 @@
+package common
+
+import (
+ "errors"
+ "fmt"
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/ipaddr"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/useragent"
+ "net/http"
+ "os"
+ "time"
+
+ "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..784045c
--- /dev/null
+++ b/pub/common/cronmgr/crommgr.go
@@ -0,0 +1,119 @@
+package cronmgr
+
+import (
+ "context"
+ "errors"
+ "hkdj-web-server/pub/common/log"
+ "sync"
+
+ "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..f74fa97
--- /dev/null
+++ b/pub/common/cronx/cron.go
@@ -0,0 +1,148 @@
+package cronx
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "sync"
+ "time"
+
+ "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/rsa.go b/pub/common/crypt/rsa.go
new file mode 100644
index 0000000..fd1b837
--- /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"
+ "hkdj-web-server/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..11109ac
--- /dev/null
+++ b/pub/common/enum/redisconst/const.go
@@ -0,0 +1,211 @@
+package redisconst
+
+import (
+ "fmt"
+ "hkdj-web-server/pub/pubdef"
+ "strings"
+ "time"
+)
+
+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..631d4b3
--- /dev/null
+++ b/pub/common/godef/godef.go
@@ -0,0 +1,26 @@
+package godef
+
+import (
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/tgbot"
+ "runtime"
+)
+
+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..b993106
--- /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: "",
+ 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/httputil/httputil.go b/pub/common/httputil/httputil.go
new file mode 100644
index 0000000..99ffe02
--- /dev/null
+++ b/pub/common/httputil/httputil.go
@@ -0,0 +1,321 @@
+package httputil
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "io"
+ "net/url"
+ "strconv"
+
+ "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/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..8b7ac1c
--- /dev/null
+++ b/pub/common/linode/linode.go
@@ -0,0 +1,207 @@
+package linode
+
+import (
+ "bytes"
+ "errors"
+ "hkdj-web-server/pub/common/fconst"
+ "hkdj-web-server/pub/common/log"
+ "io"
+ "mime"
+ "os"
+ "path"
+ "path/filepath"
+
+ "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 (
+ 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/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..32d2acc
--- /dev/null
+++ b/pub/common/log/log.go
@@ -0,0 +1,128 @@
+package log
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log/zapcorex"
+ "log"
+ "os"
+ "time"
+
+ "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..fbe94b3
--- /dev/null
+++ b/pub/common/log/zapcorex/console_encoder.go
@@ -0,0 +1,150 @@
+package zapcorex
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+
+ "hkdj-web-server/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..e23d19e
--- /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"
+
+ "hkdj-web-server/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..c42ac03
--- /dev/null
+++ b/pub/common/m3u8/enkey.go
@@ -0,0 +1,31 @@
+package m3u8
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+)
+
+func configuredKey(path string) (*bytes.Buffer, error) {
+ key, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read media key %s: %w", path, err)
+ }
+ if len(key) != 16 {
+ return nil, fmt.Errorf("media key must contain 16 bytes: %s", path)
+ }
+ return bytes.NewBuffer(key), nil
+}
+
+func Enkey(ctx context.Context) (*bytes.Buffer, error) {
+ return configuredKey("conf/m3u8.key")
+}
+
+func NewEnkey(ctx context.Context) (*bytes.Buffer, error) {
+ return configuredKey("conf/newm3u8.key")
+}
+
+func LsjEnkey(ctx context.Context) (*bytes.Buffer, error) {
+ return configuredKey("conf/lsjm3u8.key")
+}
diff --git a/pub/common/m3u8/media_play_list.go b/pub/common/m3u8/media_play_list.go
new file mode 100644
index 0000000..eac2369
--- /dev/null
+++ b/pub/common/m3u8/media_play_list.go
@@ -0,0 +1,192 @@
+package m3u8
+
+import (
+ "hkdj-web-server/pub/common/utils"
+ "net/url"
+ "path"
+ "strings"
+ "time"
+
+ "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..c5cba5b
--- /dev/null
+++ b/pub/common/m3u8/play_list.go
@@ -0,0 +1,199 @@
+package m3u8
+
+import (
+ "bytes"
+ "fmt"
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/common/http"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/redis"
+ "hkdj-web-server/pub/storage/proxy"
+ "io"
+ "strings"
+
+ "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..d8b4fab
--- /dev/null
+++ b/pub/common/m3u8/transcode_source.go
@@ -0,0 +1,116 @@
+package m3u8
+
+import (
+ "bytes"
+ "fmt"
+ "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/storage/proxy"
+ "strings"
+
+ "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..dbf6f38
--- /dev/null
+++ b/pub/common/m3u8/transformer.go
@@ -0,0 +1,153 @@
+package m3u8
+
+import (
+ "bytes"
+ "crypto/md5"
+ "encoding/hex"
+ "fmt"
+ "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/pubdef"
+ "hkdj-web-server/pub/storage/proxy"
+ "strconv"
+ "strings"
+ "time"
+
+ "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..b9cdadc
--- /dev/null
+++ b/pub/common/m3u8/vtt.go
@@ -0,0 +1,96 @@
+package m3u8
+
+import (
+ "bufio"
+ "bytes"
+ "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/storage/proxy"
+ "io"
+ "strings"
+)
+
+// 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/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..e45cfbb
--- /dev/null
+++ b/pub/common/playletcache/cache.go
@@ -0,0 +1,20 @@
+package playletcache
+
+import (
+ "hkdj-web-server/pub/common/enum/redisconst"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/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..328e5d3
--- /dev/null
+++ b/pub/common/recomapi/feedback.go
@@ -0,0 +1,45 @@
+package recomapi
+
+import (
+ "encoding/json"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+// 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..e3bed64
--- /dev/null
+++ b/pub/common/recomapi/itemapi.go
@@ -0,0 +1,106 @@
+package recomapi
+
+import (
+ "encoding/json"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "net/http"
+)
+
+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..18c6fce
--- /dev/null
+++ b/pub/common/recomapi/recomapi.go
@@ -0,0 +1,36 @@
+package recomapi
+
+import (
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "net/http"
+ "strconv"
+)
+
+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/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..357d2f6
--- /dev/null
+++ b/pub/common/slugx/slugx.go
@@ -0,0 +1,70 @@
+package slugx
+
+import (
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..c29fe64
--- /dev/null
+++ b/pub/common/sms/fengniao/fengniao.go
@@ -0,0 +1,76 @@
+package fengniao
+
+import (
+ "crypto/sha1"
+ "errors"
+ "fmt"
+ "hkdj-web-server/pub/common/httputil"
+ "hkdj-web-server/pub/common/log"
+ "net/http"
+)
+
+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..e4c2c5d
--- /dev/null
+++ b/pub/common/sms/sms.go
@@ -0,0 +1,50 @@
+package sms
+
+import (
+ "errors"
+ "hkdj-web-server/pub/common/sms/fengniao"
+ "os"
+ "strings"
+)
+
+type Provider = int
+
+const (
+ tmplID1 = "6029f19a99fd8b404062e4d2" // 大陆
+ tmplID2 = "5f8412c39396d16f787e561b" // 非大陆
+)
+
+const (
+ FengNiao Provider = 1 //蜂鸟
+ YunPian Provider = 2 //云片
+)
+
+func sendByFengNiao(mobile string, code string) error {
+ if os.Getenv("HKDJNET_SMS_CN_SECRET") == "" || os.Getenv("HKDJNET_SMS_GLOBAL_SECRET") == "" {
+ return errors.New("SMS provider is not configured")
+ }
+ var tmplID string
+ if strings.HasPrefix(mobile, "+86") {
+ tmplID = tmplID1
+ return (&fengniao.FengNiao{
+ Url: "https://t9x6k.cc/api/sms/sendSms",
+ MercId: "10177",
+ Secret: os.Getenv("HKDJNET_SMS_CN_SECRET"),
+ Tmpl: tmplID,
+ }).Send(mobile, code)
+ } else {
+ tmplID = tmplID2
+ return (&fengniao.FengNiao{
+ Url: "https://t9x6k.cc/api/sms/sendSms",
+ MercId: "10228",
+ Secret: os.Getenv("HKDJNET_SMS_GLOBAL_SECRET"),
+ Tmpl: tmplID,
+ }).Send(mobile, code)
+ }
+ // return
+}
+
+// Send 发送短信
+func Send(mobile string, code string) error {
+ return sendByFengNiao(mobile, code)
+}
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..d1251f5
--- /dev/null
+++ b/pub/common/statcenter/stat.go
@@ -0,0 +1,227 @@
+package statcenter
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+
+ "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/synclock/lock.go b/pub/common/synclock/lock.go
new file mode 100644
index 0000000..e2abd2e
--- /dev/null
+++ b/pub/common/synclock/lock.go
@@ -0,0 +1,102 @@
+package synclock
+
+import (
+ "fmt"
+ "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/redis"
+ "time"
+)
+
+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..f5f8769
--- /dev/null
+++ b/pub/common/tgbot/helper.go
@@ -0,0 +1,88 @@
+package tgbot
+
+import (
+ "hkdj-web-server/pub/common/srvdef"
+)
+
+//获取群聊id
+
+var (
+ //配置
+ tgToken = ""
+ programTgGroupIds = []int64{}
+ monitorTgGroupIds = []int64{}
+ adminTgIds = []int64{}
+ prefix = "[花魁NET]"
+ 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..d65898e
--- /dev/null
+++ b/pub/common/tgbot/tgbot.go
@@ -0,0 +1,169 @@
+package tgbot
+
+import (
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "time"
+
+ 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/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..d0b5db0
--- /dev/null
+++ b/pub/common/useragent/ua.go
@@ -0,0 +1,173 @@
+package useragent
+
+import (
+ "hkdj-web-server/pub/common/enum/consts"
+ "hkdj-web-server/pub/common/log"
+ "net/url"
+ "strings"
+
+ "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)
+}
+
+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..041e8a8
--- /dev/null
+++ b/pub/common/utils/redis.go
@@ -0,0 +1,56 @@
+package utils
+
+import (
+ "bytes"
+ "hkdj-web-server/pub/common/enum/redisconst"
+ sErr "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/redis"
+ "time"
+)
+
+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..abc46ef
--- /dev/null
+++ b/pub/common/utils/strings.go
@@ -0,0 +1,129 @@
+package utils
+
+import (
+ "encoding/json"
+ "hkdj-web-server/pub/common/netutil"
+ "math/rand/v2"
+ "strings"
+
+ "github.com/gosimple/slug"
+)
+
+// BinarySearch BinarySearch
+func BinarySearch(s []string, t string, n int) (idx int) {
+ idx = -1
+ if n == 0 {
+ return
+ }
+ start, end := 0, n-1
+ for start <= end {
+ mid := (start + end) >> 1
+ if s[mid] < t {
+ start = mid + 1
+ } else if s[mid] > t {
+ end = mid - 1
+ } else {
+ idx = mid
+ break
+ }
+ }
+ return
+}
+
+var YesOrNo = map[bool]string{
+ true: "是",
+ false: "否",
+}
+
+// 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..293711e
--- /dev/null
+++ b/pub/common/utils/time.go
@@ -0,0 +1,286 @@
+package utils
+
+import (
+ "fmt"
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+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/uasplit.go b/pub/common/utils/uasplit.go
new file mode 100644
index 0000000..6d4d39f
--- /dev/null
+++ b/pub/common/utils/uasplit.go
@@ -0,0 +1,30 @@
+package utils
+
+import (
+ "hkdj-web-server/pub/common/enum/consts"
+ "strings"
+)
+
+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..5db8cf3
--- /dev/null
+++ b/pub/common/utils/utils.go
@@ -0,0 +1,63 @@
+package utils
+
+import (
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "math/rand"
+ "runtime/debug"
+ "strings"
+ "time"
+
+ "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/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/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/dataReport/handle.go b/pub/dataReport/handle.go
new file mode 100644
index 0000000..e7aebb0
--- /dev/null
+++ b/pub/dataReport/handle.go
@@ -0,0 +1,250 @@
+package dataReport
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "hkdj-web-server/pub/common/godef"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/useragent"
+ "hkdj-web-server/pub/pubdef"
+ "io/ioutil"
+ "math"
+ "math/rand"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "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) {
+ if strings.TrimSpace(config.ApiUrl) == "" {
+ return
+ }
+ // 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 channel == "" {
+ channel = "system"
+ }
+ if eventId == "" {
+ eventId = GenerateGlobalEventID()
+ }
+ // 五项公共字段和 app_version 使用当前请求中的 SDK 原值,旧客户端缺失时留空。
+ // 不能用业务 DevID、用户表会话或推算的平台补齐,否则仍会与客户端埋点不一致。
+ sdk := ua.SDKCommon
+ if missing := sdk.MissingFields(); len(missing) > 0 {
+ log.Warn("【SDK公共参数缺失】NewCommonFieldAt",
+ log.Any("接口", ua.RequestPath), log.Any("事件", event),
+ log.Any("eventId", eventId), log.Any("userId", uid), log.Any("客户端版本", ua.Ver),
+ log.Any("已获取SDK参数", sdk != nil), log.Any("缺失字段", missing))
+ }
+ if sdk == nil {
+ sdk = &useragent.SDKCommonFields{}
+ }
+ return CommonField{
+ UID: uid,
+ EventID: eventId,
+ Event: event,
+ Channel: channel,
+ AppID: pubdef.TrackerAppId(),
+ AppVersion: sdk.AppVersion,
+ ClientTS: fmt.Sprintf("%v", clientTS),
+ Device: sdk.Device,
+ DeviceID: sdk.DeviceID,
+ DeviceFingerprint: sdk.DeviceFingerprint,
+ FPVersion: sdk.FPVersion,
+ UserAgent: strings.TrimSpace(ua.UserAgent),
+ DeviceBrand: strings.TrimSpace(ua.DeviceBrand),
+ DeviceModel: strings.TrimSpace(ua.DeviceModel),
+ SystemName: strings.TrimSpace(ua.SystemName),
+ SystemVersion: strings.TrimSpace(ua.SystemVersion),
+ SID: sdk.SID,
+ IP: ip,
+ }
+}
+
+func JsonNumber(v int64) json.Number {
+ return json.Number(strconv.FormatInt(v, 10))
+}
+func JsonRawMessage(v interface{}) json.RawMessage {
+ b, _ := json.Marshal(v)
+ return b
+}
+
+// 批量上报事件
+func BatchReport(ctx context.Context, data interface{}) error {
+ if strings.TrimSpace(config.ApiUrl) == "" {
+ return nil
+ }
+ 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..bd8d493
--- /dev/null
+++ b/pub/dataReport/sdk_config.go
@@ -0,0 +1,131 @@
+package dataReport
+
+import (
+ "encoding/json"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/redis"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "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..f7627fc
--- /dev/null
+++ b/pub/dataReport/type.go
@@ -0,0 +1,222 @@
+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(短剧ID)
+ DramaTitle string `json:"drama_title"` // 剧集名称
+ Episode json.Number `json:"episode"` // 第几集,默认1
+ 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/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/analyticsdailystat/analytics.go b/pub/models/analyticsdailystat/analytics.go
new file mode 100644
index 0000000..f55a7b9
--- /dev/null
+++ b/pub/models/analyticsdailystat/analytics.go
@@ -0,0 +1,182 @@
+package analyticsdailystat
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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 100644
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..6c330bc
--- /dev/null
+++ b/pub/models/analyticsstat/analytics.go
@@ -0,0 +1,182 @@
+package analyticsstat
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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 100644
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..8b35189
--- /dev/null
+++ b/pub/models/announcementmod/announcement.go
@@ -0,0 +1,102 @@
+package announcementmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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/dbhelper.go b/pub/models/cfg/dbhelper.go
new file mode 100644
index 0000000..b1d6401
--- /dev/null
+++ b/pub/models/cfg/dbhelper.go
@@ -0,0 +1,9 @@
+package cfg
+
+import (
+ "hkdj-web-server/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..faf2788
--- /dev/null
+++ b/pub/models/cfg/dbop.go
@@ -0,0 +1,55 @@
+package cfg
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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 100644
index 0000000..cb514fb
--- /dev/null
+++ b/pub/models/cfg/tag.go
@@ -0,0 +1,88 @@
+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
+}
+
+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"
+ TN.SysCfg = "sys_cfg"
+}
diff --git a/pub/models/cfg/type.go b/pub/models/cfg/type.go
new file mode 100644
index 0000000..6ce0edf
--- /dev/null
+++ b/pub/models/cfg/type.go
@@ -0,0 +1,73 @@
+package cfg
+
+import "strings"
+
+// @table:sys_cfg
+type SysCfg struct {
+ Id int64 `json:"id" bson:"_id"`
+ 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 地址播放
+}
+
+// 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..1cf0855
--- /dev/null
+++ b/pub/models/cointransmod/trans.go
@@ -0,0 +1,80 @@
+package cointransmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..66b8890
--- /dev/null
+++ b/pub/models/commentlikemod/like.go
@@ -0,0 +1,90 @@
+package commentlikemod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..40a527b
--- /dev/null
+++ b/pub/models/commentmod/comment.go
@@ -0,0 +1,226 @@
+package commentmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+// 表名
+
+var db *mongos.Collection
+
+func Init(ctx context.Context) {
+ // 获取数据库表
+ db = mongos.Shop.Coll(TN.Comment)
+ // 获取数据库表
+ indexes := []mongo.IndexModel{
+ {
+ Keys: bson.D{
+ {Key: FN.Comment.ObjectId, Value: 1},
+ },
+ },
+ {
+ Keys: bson.D{
+ {Key: FN.Comment.Id, Value: 1},
+ {Key: FN.Comment.CreatedAt, Value: -1},
+ },
+ },
+ {
+ Keys: bson.D{
+ {Key: FN.Comment.CreatedAt, Value: -1},
+ },
+ },
+ {
+ Keys: bson.D{
+ {Key: FN.Comment.ObjectId, Value: 1},
+ {Key: FN.Comment.ReviewStatus, Value: 1},
+ {Key: FN.Comment.Level, Value: 1},
+ },
+ },
+ {
+ Keys: bson.D{
+ {Key: FN.Comment.ObjectId, Value: 1},
+ {Key: FN.Comment.ObjectType, Value: 1},
+ {Key: FN.Comment.ReviewStatus, Value: 1},
+ {Key: FN.Comment.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))
+ }
+}
+
+// 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 100644
index 0000000..f830294
--- /dev/null
+++ b/pub/models/commentmod/tag.go
@@ -0,0 +1,54 @@
+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
+ 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.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..0a6460c
--- /dev/null
+++ b/pub/models/commentmod/type.go
@@ -0,0 +1,31 @@
+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 审核失败
+ 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..38ad4fa
--- /dev/null
+++ b/pub/models/contactusmod/contactus.go
@@ -0,0 +1,100 @@
+package contactusmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..ba9490a
--- /dev/null
+++ b/pub/models/detailnavmod/detailnav.go
@@ -0,0 +1,102 @@
+package detailnavmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..ea7be51
--- /dev/null
+++ b/pub/models/esmod/eshelper.go
@@ -0,0 +1,58 @@
+package esmod
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/srvdef"
+ "time"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/bson/primitive"
+)
+
+const (
+ FiveMinuteScale = 5 //对齐时间(分钟)
+)
+
+var (
+ videoIndex = "webhkdjnet_video_index_v1"
+ playletIndex = "webhkdjnet_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..0a3578b
--- /dev/null
+++ b/pub/models/esmod/esop.go
@@ -0,0 +1,277 @@
+package esmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/esmanager"
+ "hkdj-web-server/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..06764c1
--- /dev/null
+++ b/pub/models/esmod/recordop.go
@@ -0,0 +1,144 @@
+package esmod
+
+import (
+ "context"
+ "go.mongodb.org/mongo-driver/bson/primitive"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "time"
+
+ "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 100644
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..6b6a72a
--- /dev/null
+++ b/pub/models/esmod/type.go
@@ -0,0 +1,136 @@
+//go:generate gen_bson_tag -m esmod
+//esmod essearch数据结构定义
+
+package esmod
+
+import (
+ "hkdj-web-server/pub/models/playletmod"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/pubdef"
+ "time"
+
+ "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..1071784
--- /dev/null
+++ b/pub/models/eventmod/index.go
@@ -0,0 +1,47 @@
+package eventmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/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..7f3618a
--- /dev/null
+++ b/pub/models/eventmod/model.go
@@ -0,0 +1,130 @@
+package eventmod
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/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..4998932
--- /dev/null
+++ b/pub/models/feedbackmod/feedback.go
@@ -0,0 +1,93 @@
+package feedbackmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..085cf81
--- /dev/null
+++ b/pub/models/homebannermod/banner.go
@@ -0,0 +1,135 @@
+package homebannermod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..42a628f
--- /dev/null
+++ b/pub/models/homeconfigmod/home_config.go
@@ -0,0 +1,136 @@
+package homeconfigmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..19e7a2d
--- /dev/null
+++ b/pub/models/homeconfigmod/tag.go
@@ -0,0 +1,45 @@
+package homeconfigmod
+
+// TagHomeConfig 保存 home_config 集合字段名
+type TagHomeConfig struct {
+ Id string
+ Name string
+ Slug string
+ Desc string
+ Type string
+ Rank 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.Type = "type"
+ FN.HomeConfig.Rank = "rank"
+ 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..923d8bd
--- /dev/null
+++ b/pub/models/homeconfigmod/type.go
@@ -0,0 +1,40 @@
+package homeconfigmod
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+const (
+ TypePlaylet int32 = 1 // 短剧
+ TypeVideo int32 = 2 // 视频
+ TypeTheme int32 = 3 // 题材
+)
+
+const (
+ SortTypeRecommend int32 = 1 // 推荐
+ SortTypeHot int32 = 2 // 热播
+ SortTypeLatest int32 = 3 // 最近更新
+ SortTypePreview 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"` // 描述
+ Type int32 `json:"type" bson:"type"` // 类型 1-短剧 2-视频 3-题材
+ Rank int64 `json:"rank" bson:"rank"` // 排序,越大越靠前
+ 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
diff --git a/pub/models/hotsearchmod/hotsearch.go b/pub/models/hotsearchmod/hotsearch.go
new file mode 100644
index 0000000..eb8a165
--- /dev/null
+++ b/pub/models/hotsearchmod/hotsearch.go
@@ -0,0 +1,105 @@
+package hotsearchmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "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/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..2fde979
--- /dev/null
+++ b/pub/models/ipblackmod/ipblack.go
@@ -0,0 +1,109 @@
+package ipblackmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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 100644
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..2b5a191
--- /dev/null
+++ b/pub/models/ipdailystatmod/ipdailystat.go
@@ -0,0 +1,59 @@
+package ipdailystatmod
+
+import (
+ "context"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..1b4949f
--- /dev/null
+++ b/pub/models/m3u8requestlogmod/m3u8_request_log.go
@@ -0,0 +1,73 @@
+package m3u8requestlogmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..7d3b38b
--- /dev/null
+++ b/pub/models/models.go
@@ -0,0 +1,109 @@
+package models
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/models/announcementmod"
+ "hkdj-web-server/pub/models/cfg"
+ "hkdj-web-server/pub/models/cointransmod"
+ "hkdj-web-server/pub/models/commentlikemod"
+ "hkdj-web-server/pub/models/commentmod"
+ "hkdj-web-server/pub/models/contactusmod"
+ "hkdj-web-server/pub/models/detailnavmod"
+ "hkdj-web-server/pub/models/esmod"
+ "hkdj-web-server/pub/models/eventmod"
+ "hkdj-web-server/pub/models/feedbackmod"
+ "hkdj-web-server/pub/models/homebannermod"
+ "hkdj-web-server/pub/models/homeconfigmod"
+ "hkdj-web-server/pub/models/hotsearchmod"
+ "hkdj-web-server/pub/models/ipblackmod"
+ "hkdj-web-server/pub/models/ipdailystatmod"
+ "hkdj-web-server/pub/models/m3u8requestlogmod"
+ "hkdj-web-server/pub/models/mwacc"
+ "hkdj-web-server/pub/models/oplog"
+ "hkdj-web-server/pub/models/playletbackflowstatmod"
+ "hkdj-web-server/pub/models/playletchaptercollectmod"
+ "hkdj-web-server/pub/models/playletchapterlikemod"
+ "hkdj-web-server/pub/models/playletchaptermod"
+ "hkdj-web-server/pub/models/playletchapterunlockmod"
+ "hkdj-web-server/pub/models/playletcollectmod"
+ "hkdj-web-server/pub/models/playletlikemod"
+ "hkdj-web-server/pub/models/playletmod"
+ "hkdj-web-server/pub/models/playlettagmod"
+ "hkdj-web-server/pub/models/playletthememod"
+ "hkdj-web-server/pub/models/playletwatchhistorymod"
+ "hkdj-web-server/pub/models/playletwatchprogressmod"
+ "hkdj-web-server/pub/models/rechargepackagemod"
+ "hkdj-web-server/pub/models/richtextcontentmod"
+ "hkdj-web-server/pub/models/seopushmod"
+ "hkdj-web-server/pub/models/syncfilemod"
+ "hkdj-web-server/pub/models/translationcategorymod"
+ "hkdj-web-server/pub/models/translationmod"
+ "hkdj-web-server/pub/models/userlogin"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/models/uservisitlog"
+ "hkdj-web-server/pub/models/videocategorymod"
+ "hkdj-web-server/pub/models/videocollectmod"
+ "hkdj-web-server/pub/models/videocreatermod"
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/models/videolikemod"
+ "hkdj-web-server/pub/models/videotagmod"
+ "hkdj-web-server/pub/models/videotopicmod"
+ "hkdj-web-server/pub/models/videowatchhistorymod"
+ "hkdj-web-server/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)
+ 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)
+ 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..1df4fd8
--- /dev/null
+++ b/pub/models/mwacc/acc.go
@@ -0,0 +1,170 @@
+package mwacc
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/errors"
+ sErr "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/bson/primitive"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+// 过滤查询数量
+func AccCountByFilter(ctx context.Context, filters primitive.M, opts ...*options.CountOptions) (c int64, err error) {
+ c, err = mwAccColl.Count(ctx, filters)
+ return
+}
+
+// 过滤查询账号
+func FindOneAccByFilter(ctx context.Context, filters primitive.M, opts ...*options.FindOneOptions) (*MwAcc, error) {
+ var res MwAcc
+ err := mwAccColl.FindOne(ctx, &res, filters, opts...)
+ if err != nil {
+ log.Error("mwacc FindOneAccByFilter err", log.Any("filters", filters), log.E(err))
+ }
+ return &res, err
+}
+
+// 过滤更新
+func UpOneAccByFilter(ctx context.Context, query primitive.M, update primitive.M) (err error) {
+ _, err = mwAccColl.UpdateOne(ctx, query, primitive.M{"$set": update})
+ return
+}
+
+// 过滤删除角色
+func DelAccByFilter(ctx context.Context, filters primitive.M) (err error) {
+ _, err = mwAccColl.DeleteOne(ctx, filters)
+ if err != nil {
+ log.Error("mwacc DelAccByFilter err", log.Any("filters", filters), log.E(err))
+ }
+ return
+}
+
+// // 更新最后登录时间
+// func UpAccLastTimeById(ctx context.Context, id int64) (*mongo.UpdateResult, error) {
+// ret, err := mwAccColl.UpdateOne(ctx, primitive.M{FN.MwAcc.Id: id}, primitive.M{"$set": primitive.M{FN.MwAcc.LastTime: time.Now()}})
+// if err != nil {
+// log.Error("mvacc UpLastTimeById err", log.Any(FN.MwAcc.Id, id), log.E(err))
+// }
+// return ret, err
+// }
+
+// 绑定二维码
+func BindAccQurl(ctx context.Context, acc string) (ok bool, err error) {
+
+ res, dataErr := FindOneAccByFilter(ctx, primitive.M{
+ FN.MwAcc.Acc: acc,
+ })
+
+ if dataErr != nil || res.Id == 0 {
+ err = sErr.MakeWithMsg(sErr.Logo, "用户不存在!")
+ return false, err
+ }
+
+ if res.IsEnb == false {
+ err = sErr.MakeWithMsg(sErr.UserPermission, "用户被禁用!")
+ return false, err
+ }
+ err = UpOneAccByFilter(ctx, primitive.M{
+ FN.MwAcc.Acc: acc,
+ }, primitive.M{
+ FN.MwAcc.IsBase32: true,
+ })
+
+ if err != nil {
+ return false, err
+ }
+ return true, err
+}
+
+// 插入角色
+func AccAdd(ctx context.Context, insertReq *AccInsertDoc) (id int64, ok bool, err error) {
+
+ insertReq.Id = int64(mongos.Lid.MwAcc.Get())
+ insertReq.CreatedAt = time.Now()
+
+ _, err = mwAccColl.InsertOne(ctx, insertReq)
+ if err != nil {
+ log.ZapLog.Error(fmt.Sprintf("InsertRole mongo error %v", err))
+ err = errors.MakeWithErr(errors.DBInsert, err)
+ return 0, false, err
+ }
+ return insertReq.Id, true, nil
+}
+
+// 获取角色列表
+func GetAccList(ctx context.Context, search primitive.M, sort []primitive.M) (
+ total int64, data []*AccGetListRec, err error) {
+
+ var countPipe []primitive.M
+ countPipe = append(countPipe, search, bson.M{"$count": "total"})
+ var aggCount AggStatCount
+ err = mwAccColl.ObjectAggregate(ctx, &aggCount, countPipe)
+ if err != nil {
+ return 0, nil, err
+ }
+ total = aggCount.Total
+
+ var pip []primitive.M
+ pip = append(pip, search)
+ pip = append(pip, sort...)
+ pip = append(pip,
+ primitive.M{
+ "$lookup": primitive.M{
+ "localField": FN.MwAcc.RoleId,
+ "from": TN.MwRole,
+ "foreignField": FN.MwRole.Id,
+ "as": "roles",
+ },
+ },
+ primitive.M{"$unwind": primitive.M{
+ "path": "$roles",
+ "preserveNullAndEmptyArrays": true,
+ },
+ },
+ primitive.M{
+ "$project": primitive.M{
+ FN.MwAcc.Id: 1,
+ FN.MwAcc.UserName: 1,
+ FN.MwAcc.Acc: 1,
+ FN.MwAcc.Pwd: 1,
+ FN.MwAcc.Email: 1,
+ FN.MwAcc.RoleId: 1,
+ FN.MwAcc.RoleName: "$roles.name",
+ FN.MwAcc.Base32: 1,
+ FN.MwAcc.IsBase32: 1,
+ // mwacc.FN.MwAcc.LastTime: 1,
+ FN.MwAcc.IsEnb: 1,
+ FN.MwAcc.Feedback: 1,
+ FN.MwAcc.CreatedAt: 1,
+ FN.MwAcc.UpdatedAt: 1,
+ FN.MwAcc.LimitIP: 1,
+ },
+ })
+
+ err = mwAccColl.Aggregate(ctx, &data, pip)
+ if err != nil {
+ err = errors.MakeWithErr(errors.DBQuery, err)
+ return
+ }
+ return
+}
+
+// 过滤查询账号
+func FindManyAccByFilter(ctx context.Context, filters primitive.M, opts ...*options.FindOptions) ([]*MwAcc, *srvdef.SrvErr) {
+ var res []*MwAcc
+ err := mwAccColl.Find(ctx, &res, filters, opts...)
+ if err != nil {
+ log.Error("mwacc FindOneAccByFilter err", log.Any("filters", filters), log.E(err))
+ return res, srvdef.New(srvdef.ErrDbQueryError, "", "")
+ }
+ log.Info("success")
+
+ return res, nil
+}
diff --git a/pub/models/mwacc/dbhelper.go b/pub/models/mwacc/dbhelper.go
new file mode 100644
index 0000000..69adbec
--- /dev/null
+++ b/pub/models/mwacc/dbhelper.go
@@ -0,0 +1,13 @@
+package mwacc
+
+import "hkdj-web-server/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..2ba8628
--- /dev/null
+++ b/pub/models/mwacc/drop.go
@@ -0,0 +1,66 @@
+package mwacc
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/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..df8995e
--- /dev/null
+++ b/pub/models/mwacc/fun.go
@@ -0,0 +1,61 @@
+package mwacc
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "strconv"
+
+ "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..5d4d169
--- /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"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/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..e888b16
--- /dev/null
+++ b/pub/models/mwacc/role.go
@@ -0,0 +1,158 @@
+package mwacc
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/pageopt"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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 100644
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..71e86bc
--- /dev/null
+++ b/pub/models/mwacc/token.go
@@ -0,0 +1,84 @@
+package mwacc
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/bson/primitive"
+)
+
+// 过滤更新
+func UpOneTokenByFilter(ctx context.Context, query primitive.M, update primitive.M) (err error) {
+ _, err = mwAccTokenColl.UpdateOne(ctx, query, primitive.M{"$set": update})
+ return
+}
+
+func SaveToken(ctx context.Context, accId int64, token string) (ok bool, err error) {
+ var booToken MwAccToken
+
+ // 查找
+ err = mwAccTokenColl.FindOne(ctx, &booToken, primitive.M{FN.MwAccToken.AccId: accId})
+
+ if booToken.AccId > 0 {
+ // 更新
+ err = UpOneTokenByFilter(ctx, primitive.M{
+ FN.MwAccToken.AccId: accId,
+ }, primitive.M{
+ FN.MwAccToken.Token: token,
+ FN.MwAccToken.UpdatedAt: time.Now(),
+ })
+ if err != nil {
+ return false, err
+ }
+ return true, nil
+ }
+
+ if err != nil || booToken.AccId == 0 {
+ if _, err := mwAccTokenColl.InsertOne(ctx,
+ primitive.M{
+ FN.MwAccToken.Id: mongos.Lid.MwAccToken.Get(),
+ FN.MwAccToken.AccId: accId,
+ FN.MwAccToken.Token: token,
+ FN.MwAccToken.CreatedAt: time.Now(),
+ }); err != nil {
+ return false, err
+ }
+ }
+
+ return true, nil
+}
+
+func CheckToken(ctx context.Context, token string) (ok bool, err error) {
+ var booToken MwAccToken
+ Terr := mwAccTokenColl.FindOne(ctx, &booToken, primitive.M{FN.MwAccToken.Token: token})
+ if Terr != nil || booToken.AccId == 0 {
+ err = errors.MakeWithMsg(errors.NoToken, "token 不存在或已过期")
+ return false, err
+ }
+ return true, nil
+}
+
+// // 检测Token
+// func CheckToken(ctx context.Context, token string) (ok bool, err error) {
+// var booToken WebToken
+// Terr := tokenColl.FindOne(ctx, &booToken, primitive.M{"token": token})
+// if Terr != nil || booToken.Id == 0 {
+// err = errors.MakeWithMsg(errors.NoToken, "token 不存在或已过期")
+// return
+// }
+// return true, nil
+// }
+
+// 删除token
+func DeleteOneToken(ctx context.Context, filter bson.M) *srvdef.SrvErr {
+ if _, err := mwAccTokenColl.DeleteOne(ctx, filter); err != nil {
+ log.Warn("mwacc:DeleteOneToken db.DeleteOne failed", log.E(err), log.Any("filter", filter))
+ return srvdef.New(srvdef.ErrDbDeleteError, "", "")
+ }
+ return nil
+}
diff --git a/pub/models/mwacc/type.go b/pub/models/mwacc/type.go
new file mode 100644
index 0000000..1f5e471
--- /dev/null
+++ b/pub/models/mwacc/type.go
@@ -0,0 +1,78 @@
+package mwacc
+
+import (
+ "time"
+)
+
+type Meta struct {
+ Title string `bson:"title"`
+ Icon string `bson:"icon"`
+ NoCache bool `bson:"noCache"`
+}
+
+type Apiurl struct {
+ Url []string `bson:"url"`
+ Desc string `bson:"desc"`
+ Status bool `bson:"status"`
+ Method []string `bson:"method"`
+}
+
+// 编辑返回去的数据
+type Router struct {
+ Path string `bson:"path"`
+ Component string `bson:"component"`
+ Redirect string `bson:"redirect"`
+ Name string `bson:"name"`
+ IsShow bool `bson:"isShow"`
+ Hidden bool `bson:"hidden"`
+ Meta Meta `bson:"meta"`
+ Children []Router `bson:"children"`
+ ApiUrl []Apiurl `bson:"apiUrl"`
+}
+
+// 角色
+// @table:mw_role
+type MwRole struct {
+ Id int64 `bson:"_id"`
+ Name string `bson:"name"` // 角色名称
+ Desc string `bson:"desc"` // 描述
+ IsEnb bool `bson:"isEnb"` // 是否启用 false:禁用 true:启用
+ ApiMenus []string `bson:"apiMenus"` // 已有权限的api
+ Router []Router `bson:"router"`
+ CreatedAt time.Time `bson:"createdAt"` // 创建时间
+ UpdatedAt time.Time `bson:"updatedAt"` // 更新时间
+}
+
+// 账号
+// @table:mw_acc
+type MwAcc struct {
+ Id int64 `bson:"_id"` // id
+ UserName string `bson:"userName"` // 用户名
+ Acc string `bson:"acc"` // 账号
+ Pwd string `bson:"pwd"` // 密码
+ Email string `bson:"email"` // 邮箱
+ RoleId int64 `bson:"roleId"` // 角色Id
+ RoleName string `bson:"roleName"` // 角色名
+ Base32 string `bson:"base32"` // 二步签名
+ IsBase32 bool `bson:"isBase32"` // 是否已签名
+ Code string `bson:"code"` // 验证码
+ Qurl []byte `bson:"qurl"` // 二维码链接
+ Token string `bson:"token"` // token
+ LastTime time.Time `bson:"lastTime"` // 登录时间
+ IsEnb bool `bson:"isEnb"` // 使能
+ Feedback string `bson:"feedback"` // 禁用原因
+ CreatedAt time.Time `bson:"createdAt"` // 创建时间
+ UpdatedAt time.Time `bson:"updatedAt"` // 更新时间
+ LimitIP []string `bson:"limitIP"` // 限制IP
+}
+type MwAccList []*MwAcc
+
+// 账号Token
+// @table:mw_acc_token
+type MwAccToken struct {
+ Id int64 `bson:"_id"` // id
+ AccId int64 `bson:"accId"` // 账号id
+ Token string `bson:"token"` // token
+ CreatedAt time.Time `bson:"createdAt"` // 创建时间
+ UpdatedAt time.Time `bson:"updatedAt"` // 更新时间
+}
diff --git a/pub/models/mwacc/web.go b/pub/models/mwacc/web.go
new file mode 100644
index 0000000..ec5f3fb
--- /dev/null
+++ b/pub/models/mwacc/web.go
@@ -0,0 +1,53 @@
+package mwacc
+
+import (
+ "time"
+)
+
+type AccGetListRec struct {
+ Id int64 `bson:"_id"` // id
+ Username string `bson:"username"` // 用户名
+ Acc string `bson:"acc"` // 用户名
+ Pwd string `bson:"pwd"` // 密码
+ Email string `bson:"email"` // 邮箱
+ RoleId int64 `bson:"roleId"` // 角色Id
+ RoleName string `bson:"roleName"` // 角色名
+ // Base32 string `bson:"base32"` // 二步签名
+ IsBase32 bool `bson:"isBase32"` // 是否已签名
+ IsEnb bool `bson:"isEnb"` // 使能
+ Feedback string `bson:"feedback"` // 禁用原因
+ CreatedAt time.Time `bson:"createdAt"` // 创建时间
+ UpdatedAt time.Time `bson:"updatedAt"` // 更新时间
+ LimitIP []string `bson:"limitIP"` // 允许访问的IP
+}
+
+type RoleGetListRec struct {
+ Id int64 `bson:"_id"` // 角色Id
+ Name string `bson:"name"` // 角色名称
+ Desc string `bson:"desc"` // 描述
+ IsEnb bool `bson:"isEnb"` // 是否启用 false:禁用 true:启用
+ ApiMenus []string `bson:"apiMenus"` // 已有权限的api
+ CreatedAt time.Time `bson:"createdAt"` // 创建时间
+ UpdatedAt time.Time `bson:"updatedAt"` // 更新时间
+ AccCount int64 `bson:"accCount"` // 账户数量
+}
+
+type AccInsertDoc struct {
+ Id int64 `bson:"_id"` // id
+ UserName string `bson:"userName"` // 用户名
+ Pwd string `bson:"pwd"` // 密码
+ Acc string `bson:"acc"` // 用户名
+ Email string `bson:"email"` // 邮箱
+ RoleId int64 `bson:"roleId"` // 角色Id
+ Base32 string `bson:"base32"` // 二步签名
+ IsBase32 bool `bson:"isBase32"` // 是否已签名
+ IsEnb bool `bson:"isEnb"` // 使能
+ LastTime time.Time `bson:"lastTime"` // 登录时间
+ CreatedAt time.Time `bson:"createdAt"` // 创建时间
+ LimitIP []string `bson:"limitIP"` // 允许访问的IP
+}
+
+type RoleDropDoc struct {
+ Id int64 `bson:"_id"` // 角色Id
+ Name string `bson:"name"` // 角色名称
+}
diff --git a/pub/models/oplog/dbhelper.go b/pub/models/oplog/dbhelper.go
new file mode 100644
index 0000000..e0b95af
--- /dev/null
+++ b/pub/models/oplog/dbhelper.go
@@ -0,0 +1,9 @@
+package oplog
+
+import (
+ "hkdj-web-server/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..730fa13
--- /dev/null
+++ b/pub/models/oplog/oplog.go
@@ -0,0 +1,175 @@
+package oplog
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/mongos"
+ "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"
+)
+
+// 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 100644
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..393fffd
--- /dev/null
+++ b/pub/models/oplog/type.go
@@ -0,0 +1,22 @@
+package oplog
+
+import (
+ "hkdj-web-server/pub/common"
+ "time"
+)
+
+// 操作日志
+// @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..e9100f5
--- /dev/null
+++ b/pub/models/playletbackflowstatmod/stat.go
@@ -0,0 +1,171 @@
+package playletbackflowstatmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "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"
+)
+
+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.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..1ff8b9d
--- /dev/null
+++ b/pub/models/playletbackflowstatmod/tag.go
@@ -0,0 +1,76 @@
+package playletbackflowstatmod
+
+// TagPlayletBackflowDailyStat 保存 playlet_backflow_daily_stat 集合字段名。
+type TagPlayletBackflowDailyStat struct {
+ Id string
+ DataDate string
+ PlayletId string
+ ChapterId string
+ CurrentEpisode string
+ MediaCenterId 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.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..8cb68b1
--- /dev/null
+++ b/pub/models/playletbackflowstatmod/type.go
@@ -0,0 +1,54 @@
+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
+ 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..acd124f
--- /dev/null
+++ b/pub/models/playletchaptercollectmod/collect.go
@@ -0,0 +1,115 @@
+package playletchaptercollectmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..06c7922
--- /dev/null
+++ b/pub/models/playletchapterlikemod/like.go
@@ -0,0 +1,105 @@
+package playletchapterlikemod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..4ab162b
--- /dev/null
+++ b/pub/models/playletchaptermod/chapter.go
@@ -0,0 +1,335 @@
+package playletchaptermod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "strings"
+ "time"
+
+ "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..20011a4
--- /dev/null
+++ b/pub/models/playletchaptermod/tag.go
@@ -0,0 +1,74 @@
+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
+ 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.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..def3dbb
--- /dev/null
+++ b/pub/models/playletchaptermod/type.go
@@ -0,0 +1,86 @@
+package playletchaptermod
+
+import (
+ "hkdj-web-server/pub/models/playletmod"
+ "time"
+
+ "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-处理失败
+ 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..9f9713c
--- /dev/null
+++ b/pub/models/playletchapterunlockmod/unlock.go
@@ -0,0 +1,132 @@
+package playletchapterunlockmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/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..bcab0dc
--- /dev/null
+++ b/pub/models/playletcollectmod/collect.go
@@ -0,0 +1,130 @@
+package playletcollectmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..4c494d7
--- /dev/null
+++ b/pub/models/playletlikemod/like.go
@@ -0,0 +1,103 @@
+package playletlikemod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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.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
+}
+
+// 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..89d0a76
--- /dev/null
+++ b/pub/models/playletmod/playlet.go
@@ -0,0 +1,333 @@
+package playletmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..467022a
--- /dev/null
+++ b/pub/models/playletmod/tag.go
@@ -0,0 +1,128 @@
+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
+ MediaBusiness string
+ IsRecommend string
+ ShowOnBanner string
+ Banner 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.MediaBusiness = "mediaBusiness"
+ FN.Playlet.IsRecommend = "isRecommend"
+ FN.Playlet.ShowOnBanner = "showOnBanner"
+ FN.Playlet.Banner = "banner"
+ 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..962c877
--- /dev/null
+++ b/pub/models/playletmod/type.go
@@ -0,0 +1,164 @@
+package playletmod
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// 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
+ MediaBusiness string `json:"mediaBusiness" bson:"mediaBusiness"` // 媒资业务来源,按媒资标题前缀记录,如 CGDJ;空表示未标记
+ IsRecommend bool `json:"isRecommend" bson:"isRecommend"` // 是否原创(推荐) true-推荐,false-不推荐
+ ShowOnBanner bool `json:"showOnBanner" bson:"showOnBanner"` // 是否显示在首页banner
+ Banner string `json:"banner" bson:"banner"` // banner图
+ ReviewAt time.Time `json:"reviewAt" bson:"reviewAt"` // 审核时间
+ OnlineAt time.Time `json:"onlineAt" bson:"onlineAt"` // 上架时间
+ 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..19e575a
--- /dev/null
+++ b/pub/models/playlettagmod/playlettag.go
@@ -0,0 +1,126 @@
+package playlettagmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..bd43671
--- /dev/null
+++ b/pub/models/playlettagmod/type.go
@@ -0,0 +1,23 @@
+package playlettagmod
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// 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..31227ca
--- /dev/null
+++ b/pub/models/playletthememod/theme.go
@@ -0,0 +1,143 @@
+package playletthememod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..ea9b1b6
--- /dev/null
+++ b/pub/models/playletthememod/type.go
@@ -0,0 +1,25 @@
+package playletthememod
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// 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/playletwatchhistorymod/history.go b/pub/models/playletwatchhistorymod/history.go
new file mode 100644
index 0000000..d37e533
--- /dev/null
+++ b/pub/models/playletwatchhistorymod/history.go
@@ -0,0 +1,109 @@
+package playletwatchhistorymod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..b731d79
--- /dev/null
+++ b/pub/models/playletwatchprogressmod/progress.go
@@ -0,0 +1,155 @@
+package playletwatchprogressmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..6e31da0
--- /dev/null
+++ b/pub/models/rechargepackagemod/package.go
@@ -0,0 +1,128 @@
+package rechargepackagemod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..3b8329b
--- /dev/null
+++ b/pub/models/richtextcontentmod/richtextcontent.go
@@ -0,0 +1,153 @@
+package richtextcontentmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+
+ "hkdj-web-server/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 100644
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..cf2c37d
--- /dev/null
+++ b/pub/models/richtextcontentmod/type.go
@@ -0,0 +1,22 @@
+package richtextcontentmod
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// 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..035fb56
--- /dev/null
+++ b/pub/models/seopushmod/push.go
@@ -0,0 +1,116 @@
+package seopushmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..1942a84
--- /dev/null
+++ b/pub/models/skdrecord/record.go
@@ -0,0 +1,153 @@
+package skdrecord
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+
+ "hkdj-web-server/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 100644
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..088189d
--- /dev/null
+++ b/pub/models/syncfilemod/sync.go
@@ -0,0 +1,118 @@
+package syncfilemod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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 100644
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..3d18311
--- /dev/null
+++ b/pub/models/translationcategorymod/category.go
@@ -0,0 +1,112 @@
+package translationcategorymod
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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 100644
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..ae50c87
--- /dev/null
+++ b/pub/models/translationcategorymod/type.go
@@ -0,0 +1,21 @@
+package translationcategorymod
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// @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 100644
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..139a03e
--- /dev/null
+++ b/pub/models/translationmod/translation.go
@@ -0,0 +1,112 @@
+package translationmod
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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 100644
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..f3fda84
--- /dev/null
+++ b/pub/models/userlogin/userlogin.go
@@ -0,0 +1,108 @@
+package userlogin
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..c912a33
--- /dev/null
+++ b/pub/models/usermod/def.go
@@ -0,0 +1,18 @@
+package usermod
+
+import (
+ "hkdj-web-server/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 100644
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..5915071
--- /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 (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// 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..455c61e
--- /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 (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+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..54b5169
--- /dev/null
+++ b/pub/models/usermod/user.go
@@ -0,0 +1,406 @@
+package usermod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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},
+ },
+ },
+ }
+ 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 统计用户数量
+func CountUsers(ctx context.Context, filter bson.M) (int64, *srvdef.SrvErr) {
+ count, err := db.Count(ctx, filter)
+ if err != nil {
+ log.Warn("User:CountUsers db.Count failed",
+ log.E(err), log.Any("filter", filter))
+ return 0, srvdef.New(srvdef.ErrDbQueryError, "", "")
+ }
+ return count, nil
+}
+
+// UpdateManyUser 更新满足条件的用户
+func UpdateManyUser(ctx context.Context, filter bson.M, update bson.M) *srvdef.SrvErr {
+ _, err := db.UpdateMany(ctx, filter, update)
+ if err != nil {
+ log.Warn("user:UpdateManyUser db.UpdateMany failed",
+ log.E(err), log.Any("filter", filter))
+ return srvdef.New(srvdef.ErrDbUpdateError, "", "")
+ }
+ return nil
+}
+
+// FindManyUser 查找一批用户数据
+func FindManyUser(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (UserList, *srvdef.SrvErr) {
+ data := make(UserList, 0)
+ if err := db.Find(ctx, &data, filter, opts...); err != nil {
+ log.Warn("User:FindManyUser db.Find failed", log.E(err), log.Any("filter", filter))
+ return nil, srvdef.New(srvdef.ErrDbQueryError, "", "")
+ }
+ return data, nil
+}
+
+// FindManyUserWithBind 用自定义结构查找一批用户数据
+func FindManyUserWithBind(ctx context.Context, bind interface{}, filter bson.M, opts ...*options.FindOptions) *srvdef.SrvErr {
+ if err := db.Find(ctx, bind, filter, opts...); err != nil {
+ log.Warn("User:FindManyUserWithBind db.Find failed", log.E(err), log.Any("filter", filter))
+ return srvdef.New(srvdef.ErrDbQueryError, "", "")
+ }
+ return nil
+}
+
+// AggregateUsers 聚合查找一批用户信息
+func AggregateUsers(ctx context.Context, bind interface{}, pipeline []bson.M, opts ...*options.AggregateOptions) *srvdef.SrvErr {
+ if err := db.Aggregate(ctx, bind, pipeline, opts...); err != nil {
+ log.Warn("User:AggregateUsers db.Aggregate failed",
+ log.E(err), log.Any("pipeline", pipeline))
+ return srvdef.New(srvdef.ErrDbQueryError, "", "")
+ }
+ return nil
+}
+
+// ObjectAggregate 聚合查找
+func ObjectAggregate(ctx context.Context, bind interface{}, pipeline []bson.M) *srvdef.SrvErr {
+ if err := db.ObjectAggregate(ctx, bind, pipeline); err != nil {
+ log.Warn("User:ObjectAggregate db.ObjectAggregate failed",
+ log.E(err), log.Any("pipeline", pipeline))
+ return srvdef.New(srvdef.ErrDbQueryError, "", "")
+ }
+ return nil
+}
+
+// UpdateRecharge 更新用户充值金额(正数为充值,负数为消费)
+func UpdateRecharge(ctx context.Context, userId, recharge, rechargeTotal int64) (*User, *srvdef.SrvErr) {
+ userInfo := &User{}
+ incDoc := bson.M{
+ FN.User.Recharge: recharge,
+ FN.User.RechargeTotal: rechargeTotal,
+ }
+ if recharge < 0 {
+ incDoc[FN.User.ConsumptionTotal] = -recharge
+ }
+ err := db.FindOneAndUpdate(ctx, &userInfo, bson.M{
+ FN.User.Id: userId,
+ FN.User.Recharge: bson.M{"$gte": -recharge},
+ }, bson.M{
+ "$inc": incDoc,
+ "$set": bson.M{FN.User.UpdatedAt: time.Now()},
+ })
+ if err != nil {
+ log.Error("UpdateRecharge:FindOneAndUpdate failed", log.E(err))
+ return nil, srvdef.New(srvdef.DBUpdate, "数据更新出错", "")
+ }
+ if userInfo.Id <= 0 {
+ return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "")
+ }
+ return userInfo, nil
+}
+
+// UpdateScore 更新用户积分(正数加分,负数扣分)
+func UpdateScore(ctx context.Context, userId, score int64) (*User, *srvdef.SrvErr) {
+ userInfo := &User{}
+ incDoc := bson.M{FN.User.Score: score}
+ filter := bson.M{FN.User.Id: userId}
+ if score > 0 {
+ incDoc[FN.User.ScoreTotal] = score
+ } else {
+ filter[FN.User.Score] = bson.M{"$gte": -score}
+ }
+ err := db.FindOneAndUpdate(ctx, &userInfo, filter, bson.M{
+ "$inc": incDoc,
+ "$set": bson.M{FN.User.UpdatedAt: time.Now()},
+ })
+ if err != nil {
+ log.Warn("usermod:UpdateScore FindOneAndUpdate failed",
+ log.E(err), log.Any("filter", filter))
+ return nil, srvdef.New(srvdef.DBUpdate, "数据更新出错", "")
+ }
+ if userInfo.Id <= 0 {
+ return nil, srvdef.New(srvdef.DBUpdate, "为0", "")
+ }
+ return userInfo, nil
+}
+
+type OnAddScore func(userId int64, add int64)
+
+// UpdateScoreWithEvent 更新用户积分并触发回调
+func UpdateScoreWithEvent(ctx context.Context, userId, score int64, onAdd OnAddScore) (*User, *srvdef.SrvErr) {
+ userInfo := &User{}
+ incDoc := bson.M{FN.User.Score: score}
+ filter := bson.M{FN.User.Id: userId}
+ if score > 0 {
+ incDoc[FN.User.ScoreTotal] = score
+ }
+ err := db.FindOneAndUpdate(ctx, &userInfo, filter, bson.M{
+ "$inc": incDoc,
+ "$set": bson.M{FN.User.UpdatedAt: time.Now()},
+ })
+ if err != nil {
+ log.Warn("usermod:UpdateScoreWithEvent FindOneAndUpdate failed",
+ log.E(err), log.Any("filter", filter))
+ return nil, srvdef.New(srvdef.DBUpdate, "数据更新出错", "")
+ }
+ if userInfo.Id <= 0 {
+ return nil, srvdef.New(srvdef.DBUpdate, "为0", "")
+ }
+ if score > 0 && onAdd != nil {
+ onAdd(userInfo.Id, score)
+ }
+ return userInfo, nil
+}
+
+// UserPay 扣除用户充值金币
+func UserPay(ctx context.Context, userId int64, amount int64) (*User, *srvdef.SrvErr) {
+ if amount <= 0 {
+ return nil, srvdef.New(srvdef.ErrParamError, "扣除金额不能小于0", "扣除金额不能小于0")
+ }
+ userInfo := &User{}
+ err := db.FindOneAndUpdate(ctx, &userInfo, bson.M{
+ FN.User.Id: userId,
+ FN.User.Recharge: bson.M{"$gte": amount},
+ }, bson.M{
+ "$inc": bson.M{
+ FN.User.Recharge: -amount,
+ FN.User.ConsumptionTotal: amount,
+ },
+ "$set": bson.M{FN.User.UpdatedAt: time.Now()},
+ })
+ if err != nil {
+ return nil, srvdef.New(srvdef.DBUpdate, "数据更新出错", "")
+ }
+ if userInfo.Id <= 0 {
+ return nil, srvdef.New(srvdef.DBUpdate, "余额不足或用户不存在", "")
+ }
+ return userInfo, nil
+}
+
+// FindOneUpdateAndReturnNew 更新用户数据并返回新的用户数据
+func FindOneUpdateAndReturnNew(ctx context.Context, query bson.M, update bson.M, opts ...*options.FindOneAndUpdateOptions) (*User, *srvdef.SrvErr) {
+ userInfo := &User{}
+ err := db.FindOneAndUpdate(ctx, &userInfo, query, update)
+ if err != nil {
+ return nil, srvdef.New(srvdef.DBUpdate, "数据更新出错", "")
+ }
+ if userInfo.Id <= 0 {
+ return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "")
+ }
+ return userInfo, nil
+}
+
+// GetMapUsers 获取 map 结构的 users
+func GetMapUsers(ctx context.Context, ids []int64) (UserMap, *srvdef.SrvErr) {
+ mapUsers := make(UserMap)
+ if len(ids) == 0 {
+ return mapUsers, nil
+ }
+ listUsers, serr := FindManyUser(ctx, bson.M{FN.User.Id: bson.M{"$in": ids}})
+ if serr != nil {
+ return mapUsers, serr
+ }
+ for _, user := range listUsers {
+ mapUsers[user.Id] = user
+ }
+ return mapUsers, nil
+}
+
+// FindManyUserByQuery 从 user 表中查找一批数据(自定义 bind)
+func FindManyUserByQuery(ctx context.Context, bind interface{}, query bson.M, opts ...*options.FindOptions) *srvdef.SrvErr {
+ if err := db.Find(ctx, bind, query, opts...); err != nil {
+ log.Warn("user:FindManyUserByQuery db.Find failed",
+ log.E(err), log.Any("filter", query))
+ return srvdef.New(srvdef.ErrDbQueryError, "", "")
+ }
+ return nil
+}
+
+// InsertManyUser 插入一批用户
+func InsertManyUser(ctx context.Context, users *UserList) *srvdef.SrvErr {
+ if _, err := db.InsertMany(ctx, users); err != nil {
+ log.Warn("User:InsertManyUser db.InsertMany failed", log.E(err))
+ return srvdef.New(srvdef.ErrDbInsertError, "", "")
+ }
+ return nil
+}
+
+// StatcenterSyncList 按创建时间增量拉取用户列表(数据统计同步用)
+func StatcenterSyncList(ctx context.Context, createAt time.Time, size int64) (UserList, error) {
+ opt := (&options.FindOptions{})
+ opt.SetLimit(size)
+ opt.SetSort(bson.M{FN.User.Id: 1})
+ userList := UserList{}
+ err := db.Find(ctx, &userList, bson.M{FN.User.CreatedAt: bson.M{"$gt": createAt}}, opt)
+ if err != nil {
+ return nil, err
+ }
+ return userList, nil
+}
+
+// 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{"$nin": []interface{}{"", nil}},
+ 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..9fc0476
--- /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"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/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..5e9e8db
--- /dev/null
+++ b/pub/models/uservisitlog/dbhelper.go
@@ -0,0 +1,5 @@
+package uservisitlog
+
+import "hkdj-web-server/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..6f00e82
--- /dev/null
+++ b/pub/models/uservisitlog/dbop.go
@@ -0,0 +1,77 @@
+package uservisitlog
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "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"
+)
+
+// 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 100644
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..8adbe5d
--- /dev/null
+++ b/pub/models/videocategorymod/category.go
@@ -0,0 +1,137 @@
+package videocategorymod
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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 100644
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..c99117c
--- /dev/null
+++ b/pub/models/videocategorymod/type.go
@@ -0,0 +1,31 @@
+package videocategorymod
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// @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..11a5d24
--- /dev/null
+++ b/pub/models/videocollectmod/collect.go
@@ -0,0 +1,121 @@
+package videocollectmod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..c35ec39
--- /dev/null
+++ b/pub/models/videocreatermod/creater.go
@@ -0,0 +1,132 @@
+package videocreatermod
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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 100644
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..296ccbc
--- /dev/null
+++ b/pub/models/videocreatermod/type.go
@@ -0,0 +1,20 @@
+package videocreatermod
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// @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..0341607
--- /dev/null
+++ b/pub/models/videoinformationmod/information.go
@@ -0,0 +1,320 @@
+package videoinformationmod
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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 100644
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..fda6fd0
--- /dev/null
+++ b/pub/models/videolikemod/like.go
@@ -0,0 +1,94 @@
+package videolikemod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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 100644
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..c598542
--- /dev/null
+++ b/pub/models/videotagmod/taginfo.go
@@ -0,0 +1,143 @@
+package videotagmod
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..226c54e
--- /dev/null
+++ b/pub/models/videotagmod/type.go
@@ -0,0 +1,21 @@
+package videotagmod
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// @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 100644
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..1376ee4
--- /dev/null
+++ b/pub/models/videotopicmod/type.go
@@ -0,0 +1,27 @@
+package videotopicmod
+
+import (
+ "hkdj-web-server/pub/models/videoinformationmod"
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// @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..062b0ad
--- /dev/null
+++ b/pub/models/videotopicmod/videotopic.go
@@ -0,0 +1,144 @@
+package videotopicmod
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/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..1f4d180
--- /dev/null
+++ b/pub/models/videowatchhistorymod/history.go
@@ -0,0 +1,107 @@
+package videowatchhistorymod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "time"
+
+ "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..96af635
--- /dev/null
+++ b/pub/models/vipordermod/order.go
@@ -0,0 +1,171 @@
+package vipordermod
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/mongos"
+ "hkdj-web-server/pub/pubdef"
+ "time"
+
+ "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..fa2fce2
--- /dev/null
+++ b/pub/models/vipordermod/type.go
@@ -0,0 +1,46 @@
+package vipordermod
+
+import (
+ "hkdj-web-server/pub/pubdef"
+ "time"
+)
+
+// 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"` // 下单时用户渠道码
+ CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间
+ UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间
+ FinishedAt time.Time `json:"finishedAt" bson:"finishedAt"` // 完成时间
+}
+
+type VipOrderList []*VipOrder
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..33df330
--- /dev/null
+++ b/pub/mongos/base_mod.go
@@ -0,0 +1,582 @@
+package mongos
+
+import (
+ "context"
+ "errors"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/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..aa86ca1
--- /dev/null
+++ b/pub/mongos/generate_id.go
@@ -0,0 +1,140 @@
+package mongos
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/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
+ 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.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..abebfaa
--- /dev/null
+++ b/pub/mongos/lid.go
@@ -0,0 +1,102 @@
+package mongos
+
+import (
+ "context"
+ "hkdj-web-server/pub/models/utils"
+ "time"
+
+ "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..95448a3
--- /dev/null
+++ b/pub/mongos/manager.go
@@ -0,0 +1,168 @@
+package mongos
+
+import (
+ "context"
+ "errors"
+ "hkdj-web-server/pub/common/log"
+ "time"
+
+ "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/payment/trans.go b/pub/payment/trans.go
new file mode 100644
index 0000000..b39107a
--- /dev/null
+++ b/pub/payment/trans.go
@@ -0,0 +1,96 @@
+package payment
+
+import (
+ "context"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/common/utils"
+ "hkdj-web-server/pub/models/cointransmod"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/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..a55dee0
--- /dev/null
+++ b/pub/payment/vip.go
@@ -0,0 +1,73 @@
+package payment
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "hkdj-web-server/pub/models/rechargepackagemod"
+ "hkdj-web-server/pub/models/usermod"
+ "hkdj-web-server/pub/pubdef"
+ "time"
+
+ "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..891bf78
--- /dev/null
+++ b/pub/pubdef/paydef.go
@@ -0,0 +1,52 @@
+package pubdef
+
+import "hkdj-web-server/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..5a3ff09
--- /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 = 4001
+ AppName = "花魁短剧web站点"
+ AppNameZhifu = "花魁短剧web站点"
+ OrderPrefix = "CZ-HKDJWEB-"
+ 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..90dc989
--- /dev/null
+++ b/pub/redis/manager.go
@@ -0,0 +1,177 @@
+package redis
+
+import (
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/common/srvdef"
+ "regexp"
+ "sync"
+
+ "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..afa7e42
--- /dev/null
+++ b/pub/redis/redis.go
@@ -0,0 +1,279 @@
+package redis
+
+import (
+ "errors"
+ "fmt"
+ sErr "hkdj-web-server/pub/common/errors"
+ "hkdj-web-server/pub/common/log"
+ "time"
+
+ 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/runtimecfg/config.go b/pub/runtimecfg/config.go
new file mode 100644
index 0000000..214df9d
--- /dev/null
+++ b/pub/runtimecfg/config.go
@@ -0,0 +1,29 @@
+package runtimecfg
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+
+ "gopkg.in/yaml.v2"
+)
+
+// Load substitutes YAML scalars from the service's separately loaded .env.
+func Load(path string, target interface{}) error {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ var missing string
+ expanded := os.Expand(string(raw), func(key string) string {
+ value, ok := os.LookupEnv(key)
+ if !ok || value == "" {
+ missing = key
+ }
+ return strconv.Quote(value)
+ })
+ if missing != "" {
+ return fmt.Errorf("required environment variable is missing: %s", missing)
+ }
+ return yaml.Unmarshal([]byte(expanded), target)
+}
diff --git a/pub/seo/googleindex/googleindex.go b/pub/seo/googleindex/googleindex.go
new file mode 100644
index 0000000..08934a7
--- /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"
+ "hkdj-web-server/pub/common/log"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "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..cd8cebd
--- /dev/null
+++ b/pub/seo/h5path/path.go
@@ -0,0 +1,123 @@
+// Package h5path 统一拼装花魁 NET H5 的页面路径。
+// Abs 保持原有无尾斜杠规范,SitemapAbs 专用于 sitemap 页面 URL
+package h5path
+
+import (
+ "hkdj-web-server/pub/common/utils"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+// Public page prefixes shared by sitemap, API canonical metadata and SEO push.
+const (
+ DramaListPath = "/dramas"
+ DramaPath = "/drama"
+ CollectionsPath = "/collections"
+ CollectionPath = "/collection"
+ SeriesPath = "/series"
+ SeriesWatchPath = "/series/watch"
+ TagsPath = "/tags"
+ CuratedDramasPath = "/curated/dramas"
+ CuratedSeriesPath = "/curated/series"
+ RankingsPath = "/rankings"
+ LatestPath = "/latest"
+ FeaturedPath = "/featured"
+)
+
+// Segment receives a raw model slug. Escape exactly once so slashes, queries and
+// percent signs remain part of that single segment, matching the H5 route helper.
+func Segment(slug string, id int64) string {
+ return url.PathEscape(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(DramaPath, 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 CollectionsPath
+ }
+ return Entity(CollectionPath, slug, id)
+}
+
+func Tag(slug string, id int64) string {
+ if Segment(slug, id) == "" {
+ return ""
+ }
+ return Entity(TagsPath, slug, id)
+}
+
+func ListTheme(slug string, id int64) string {
+ if Segment(slug, id) == "" {
+ return DramaListPath
+ }
+ return Entity(DramaListPath, slug, id)
+}
+
+func VideoPlay(slug string, id int64) string {
+ if Segment(slug, id) == "" {
+ return ""
+ }
+ return Entity(SeriesWatchPath, slug, id)
+}
+
+func MoreList(slug string, id int64) string {
+ if Segment(slug, id) == "" {
+ return ""
+ }
+ return Entity(CuratedDramasPath, slug, id)
+}
+
+func MoreSeriesList(slug string, id int64) string {
+ if Segment(slug, id) == "" {
+ return ""
+ }
+ return Entity(CuratedSeriesPath, slug, id)
+}
+
+func SeriesTopic(topicID int64) string {
+ if topicID <= 0 {
+ return SeriesPath
+ }
+ return SeriesPath + "/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 页面绝对地址,统一保留尾斜杠
+func SitemapAbs(webAddr, path string) string {
+ path = strings.TrimSpace(path)
+ if path == "" || path == "/" {
+ return utils.JoinUrlPathKeepSlash(webAddr, "/")
+ }
+ return utils.JoinUrlPathKeepSlash(webAddr, "/"+strings.Trim(path, "/")+"/")
+}
diff --git a/pub/seo/indexnow/submitter.go b/pub/seo/indexnow/submitter.go
new file mode 100644
index 0000000..fbcd0de
--- /dev/null
+++ b/pub/seo/indexnow/submitter.go
@@ -0,0 +1,247 @@
+// Package indexnow 实现 IndexNow 协议提交,推送到官方入口后由 Bing / Yandex 等共享数据。
+// 调用方是 web 进程的 seopush,在文章发布 / 更新 / 删除时推送对应页面地址。
+package indexnow
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// 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 依据站点地址和调用方提供的验证 key 推导 IndexNow 配置。
+func BuildConfig(webAddr, key string) (*IndexNowConfig, error) {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return nil, fmt.Errorf("IndexNow key is not configured")
+ }
+ host, keyLocation, err := hostFromWebAddr(webAddr, key)
+ if err != nil {
+ return nil, err
+ }
+ return &IndexNowConfig{
+ Key: key,
+ 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..f9902f5
--- /dev/null
+++ b/pub/seo/sitemapdirty/dirty.go
@@ -0,0 +1,44 @@
+// Package sitemapdirty 在 web 与 app 之间传递「站点内容已变更」的标记。
+// web 进程负责文章的发布/更新/删除,而 sitemap 文件由 app 进程生成并对外提供,
+// 两者独立部署且不互相调用,因此借数据库中的一条记录做跨进程通知。
+package sitemapdirty
+
+import (
+ "context"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/models/syncfilemod"
+ "time"
+
+ "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..2bc06b6
--- /dev/null
+++ b/pub/storage/proxy/autopublish_image.go
@@ -0,0 +1,131 @@
+package proxy
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "hkdj-web-server/pub/common/log"
+ "hkdj-web-server/pub/pubdef"
+ "io"
+ "mime"
+ "mime/multipart"
+ "net/http"
+ "net/textproto"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+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..c4ed5ef
--- /dev/null
+++ b/pub/storage/proxy/object.go
@@ -0,0 +1,509 @@
+package proxy
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "hkdj-web-server/pub/common/http"
+ "hkdj-web-server/pub/common/log"
+ "strings"
+)
+
+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/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
曲阜师范大学学生【何鑫】的私密自拍视频流出,尺度炸裂,令人瞠目!这位清纯外表的学妹私下化身小母狗,为完成神秘“主人”的任务大秀骚浪本性!视频中,她身着宽松裸体背带裤,半遮半掩的布料勾勒出白嫩胴体,饱满小胸和粉嫩美穴若隐若现。




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



何鑫还完成足交任务,赤裸坐在床边,用白嫩玉足轻抚主人粗大肉棒,脚趾灵活挑逗,油亮光泽的脚掌摩擦到主人爽到低吼!
关键词:#曲阜师范大学何鑫 #何鑫裸体自拍 #何鑫对镜玩穴自慰 #何鑫足交伺候主人 #何鑫不雅自拍视频 #曲阜师范大学 #何鑫 #自慰 #足交 #51吃瓜 #51吃瓜网 #黑料 #吃瓜
想看更多精彩完整版,下载51吃瓜APP一爽到底~
51吃瓜APP下载地址:
最新地址 https://url46.co/1222
安卓下载备用地址一: https://url46.co/1237
安卓下载备用地址二: https://url46.co/1238
iOS下载备用地址 https://url46.co/1239