Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
@@ -0,0 +1 @@
|
||||
.DS_Store
|
||||
@@ -0,0 +1,36 @@
|
||||
include tool/build/Makefile.help
|
||||
|
||||
## 更新APP文档
|
||||
update_app_doc:
|
||||
cd app && swag init --parseDependency --parseDepth 4
|
||||
|
||||
## 更新Web文档
|
||||
update_h5_doc:
|
||||
cd h5app && swag init --parseDependency --parseDepth 4
|
||||
|
||||
## 更新Web文档
|
||||
update_web_doc:
|
||||
cd web && swag init --parseDependency --parseDepth 4
|
||||
|
||||
## 更新所有项目文档
|
||||
update_all_docs: update_app_doc update_h5_doc update_web_doc
|
||||
|
||||
## 更新测试服Web
|
||||
deploy_test_web:
|
||||
cd web && ./deploy_test.sh
|
||||
|
||||
## 更新测试服H5
|
||||
deploy_test_h5:
|
||||
cd h5app && ./deploy_test.sh
|
||||
|
||||
## 更新测试服APP
|
||||
deploy_test_app:
|
||||
cd app && ./deploy_test.sh
|
||||
|
||||
## 更新测试服APP + H5
|
||||
deploy_test_h5_and_app: deploy_test_app deploy_test_h5
|
||||
|
||||
test:
|
||||
./tool/build/check_syntax.sh
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
GOPATH:=$(shell go env GOPATH)
|
||||
|
||||
GIT_BRANCH:=$(shell git symbolic-ref --short -q HEAD)
|
||||
GIT_VERSION:=$(shell git log --pretty=oneline -p -1 | head -n 1 | awk '{print $$1}')
|
||||
BUILD_DATE:=$(shell date '+%Y-%m-%d %H:%M:%S %z')
|
||||
BUILD_IP:=$(shell ifconfig | grep inet | grep -v grep | grep -v "inet6" | grep -v "127.0.0.1" | awk '{print $$2}')
|
||||
VERSON_PATH:=pfshop/pub/srvversion
|
||||
|
||||
APP_BUILD_FLAG:=-ldflags "-X '$(VERSON_PATH).GIT_BRANCH=$(GIT_BRANCH)' -X '$(VERSON_PATH).GIT_VERSION=$(GIT_VERSION)' -X '$(VERSON_PATH).BUILD_DATE=$(BUILD_DATE)' -X '$(VERSON_PATH).BUILD_IP=$(BUILD_IP)'"
|
||||
|
||||
|
||||
.PHONY: test deploy
|
||||
deploy:
|
||||
@echo "deploy, GOPATH=$(GOPATH)"
|
||||
go clean
|
||||
rm -f web_91ys_h5
|
||||
env GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_91ys_h5 -v
|
||||
tar czf web_91ys_h5.tar.gz web_91ys_h5
|
||||
rm -f web_91ys_h5
|
||||
|
||||
test:
|
||||
@echo "test, GOPATH=$(GOPATH)"
|
||||
GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_91ys_h5
|
||||
tar czf web_91ys_h5.tar.gz web_91ys_h5
|
||||
rm -f web_91ys_h5
|
||||
|
||||
|
||||
localtest:
|
||||
@echo "test, GOPATH=$(GOPATH)"
|
||||
go build $(APP_BUILD_FLAG) -o app
|
||||
@@ -0,0 +1,8 @@
|
||||
package web
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// About renders the static About page.
|
||||
func About(c *gin.Context) {
|
||||
HTMLOK(c, "about.html", gin.H{})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxAccountPage = 10000
|
||||
|
||||
type accountPage struct {
|
||||
Current int
|
||||
Total int
|
||||
Count int64
|
||||
PrevURL string
|
||||
NextURL string
|
||||
}
|
||||
|
||||
func accountPageNum(c *gin.Context) (int, bool) {
|
||||
if c.Request.URL.Query().Has("page") {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimSpace(c.Param("page"))
|
||||
if raw == "" {
|
||||
return 1, true
|
||||
}
|
||||
page, err := strconv.Atoi(raw)
|
||||
if err != nil || page < 1 || page > maxAccountPage {
|
||||
return 0, false
|
||||
}
|
||||
return page, true
|
||||
}
|
||||
|
||||
func makeAccountPage(path string, page, size int, count int64) accountPage {
|
||||
total := int((count + int64(size) - 1) / int64(size))
|
||||
p := accountPage{Current: page, Total: total, Count: count}
|
||||
link := func(n int) string {
|
||||
path = strings.TrimRight(path, "/")
|
||||
if n <= 1 {
|
||||
return path
|
||||
}
|
||||
return path + "/page/" + strconv.Itoa(n)
|
||||
}
|
||||
if page > 1 {
|
||||
p.PrevURL = link(page - 1)
|
||||
}
|
||||
if page < total {
|
||||
p.NextURL = link(page + 1)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type registerBody struct {
|
||||
AccountNo string `json:"accountNo"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
AgentInviteCode string `json:"agentInviteCode"`
|
||||
DevID string `json:"devID"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
type loginBody struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
DevID string `json:"devID"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
// Register 代理上游注册(POST /api/app/auth/register),成功后写入登录 cookie。
|
||||
// 请求头带游客 Authorization 时升级该游客;邮箱缺省时用账号生成占位邮箱。
|
||||
func Register(c *gin.Context) {
|
||||
var body registerBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
body.AccountNo = strings.TrimSpace(body.AccountNo)
|
||||
body.Email = strings.TrimSpace(body.Email)
|
||||
body.AgentInviteCode = strings.TrimSpace(body.AgentInviteCode)
|
||||
body.DevID = strings.TrimSpace(body.DevID)
|
||||
body.Channel = authser.ResolveChannel(c, body.Channel)
|
||||
|
||||
if body.AccountNo == "" || body.Password == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "请填写账号和密码",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 上游 registerReq 要求 email;前端无邮箱时用账号生成唯一占位邮箱。
|
||||
if body.Email == "" {
|
||||
body.Email = body.AccountNo + "@hgdj.local"
|
||||
}
|
||||
|
||||
env, user, err := authser.Register(c, authser.RegisterReq{
|
||||
AccountNo: body.AccountNo,
|
||||
Email: body.Email,
|
||||
Password: body.Password,
|
||||
AgentInviteCode: body.AgentInviteCode,
|
||||
DevID: body.DevID,
|
||||
Channel: body.Channel,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("register upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "register failed",
|
||||
"tip": "注册失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if env == nil || env.Code != 200 || user == nil {
|
||||
tip := "注册失败"
|
||||
msg := ""
|
||||
code := 500
|
||||
if env != nil {
|
||||
code = env.Code
|
||||
msg = env.Msg
|
||||
if t := strings.TrimSpace(env.Tip); t != "" {
|
||||
tip = t
|
||||
} else if m := strings.TrimSpace(env.Msg); m != "" {
|
||||
tip = m
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
"tip": tip,
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authser.SetAuthCookies(c, user)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": firstNonEmptyAuth(env.Msg, "success"),
|
||||
"tip": firstNonEmptyAuth(env.Tip, "注册成功"),
|
||||
"data": user,
|
||||
})
|
||||
}
|
||||
|
||||
// Login 代理上游登录(POST /api/app/auth/login),成功后写入登录 cookie。
|
||||
func Login(c *gin.Context) {
|
||||
var body loginBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
body.Account = strings.TrimSpace(body.Account)
|
||||
body.DevID = strings.TrimSpace(body.DevID)
|
||||
body.Channel = authser.ResolveChannel(c, body.Channel)
|
||||
if body.Account == "" || body.Password == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "请填写账号和密码",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, user, err := authser.Login(c, authser.LoginReq{
|
||||
Account: body.Account,
|
||||
Password: body.Password,
|
||||
DevID: body.DevID,
|
||||
Channel: body.Channel,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("login upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "login failed",
|
||||
"tip": "登录失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if env == nil || env.Code != 200 || user == nil {
|
||||
tip := "登录失败"
|
||||
msg := ""
|
||||
code := 500
|
||||
if env != nil {
|
||||
code = env.Code
|
||||
msg = env.Msg
|
||||
if t := strings.TrimSpace(env.Tip); t != "" {
|
||||
tip = t
|
||||
} else if m := strings.TrimSpace(env.Msg); m != "" {
|
||||
tip = m
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
"tip": tip,
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authser.SetAuthCookies(c, user)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": firstNonEmptyAuth(env.Msg, "success"),
|
||||
"tip": firstNonEmptyAuth(env.Tip, "登录成功"),
|
||||
"data": user,
|
||||
})
|
||||
}
|
||||
|
||||
// Guest 代理上游游客登录,成功后写入登录 cookie。
|
||||
// 对应 POST /api/app/auth/guest;需传 devID,上游要求 X-User-Agent / X-Real-IP(由 BFF 组装)。
|
||||
func Guest(c *gin.Context) {
|
||||
var body struct {
|
||||
DevID string `json:"devID"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Mac string `json:"mac"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
body.DevID = strings.TrimSpace(body.DevID)
|
||||
body.DeviceID = strings.TrimSpace(body.DeviceID)
|
||||
body.Mac = strings.TrimSpace(body.Mac)
|
||||
body.Channel = authser.ResolveChannel(c, body.Channel)
|
||||
if body.DevID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "缺少设备标识",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.DeviceID == "" {
|
||||
body.DeviceID = body.DevID
|
||||
}
|
||||
|
||||
env, user, err := authser.GuestLogin(c, authser.GuestLoginReq{
|
||||
DevID: body.DevID,
|
||||
DeviceID: body.DeviceID,
|
||||
Mac: body.Mac,
|
||||
Channel: body.Channel,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("guest login upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "guest login failed",
|
||||
"tip": "游客登录失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if env == nil || env.Code != 200 || user == nil {
|
||||
tip := "游客登录失败"
|
||||
msg := ""
|
||||
code := 500
|
||||
if env != nil {
|
||||
code = env.Code
|
||||
msg = env.Msg
|
||||
if t := strings.TrimSpace(env.Tip); t != "" {
|
||||
tip = t
|
||||
} else if m := strings.TrimSpace(env.Msg); m != "" {
|
||||
tip = m
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
"tip": tip,
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authser.SetAuthCookies(c, user)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": firstNonEmptyAuth(env.Msg, "success"),
|
||||
"tip": firstNonEmptyAuth(env.Tip, "游客登录成功"),
|
||||
"data": user,
|
||||
})
|
||||
}
|
||||
|
||||
// Logout 清除登录 cookie,并标记跳过紧接着的一次自动游客登录。
|
||||
func Logout(c *gin.Context) {
|
||||
authser.ClearAuthCookies(c)
|
||||
authser.MarkGuestSkipAuto(c)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"tip": "已退出登录",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
|
||||
func firstNonEmptyAuth(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ChapterLikeSet BFF:代理 POST /api/app/playlet/chapter/like/set。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_like_set
|
||||
func ChapterLikeSet(c *gin.Context) {
|
||||
var body struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
IsLike *bool `json:"isLike"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.ChapterID <= 0 || body.IsLike == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "chapterId/isLike required",
|
||||
"tip": "缺少剧集 ID 或点赞状态",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := playletser.ChapterLikeSet(c, playletser.PlayletChapterLikeSetReq{
|
||||
ChapterID: body.ChapterID,
|
||||
IsLike: *body.IsLike,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": env.Tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// ChapterCollectSet BFF:代理 POST /api/app/playlet/chapter/collect/set。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_collect_set
|
||||
func ChapterCollectSet(c *gin.Context) {
|
||||
var body struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
IsCollect *bool `json:"isCollect"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.ChapterID <= 0 || body.IsCollect == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "chapterId/isCollect required",
|
||||
"tip": "缺少剧集 ID 或收藏状态",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := playletser.ChapterCollectSet(c, playletser.PlayletChapterCollectSetReq{
|
||||
ChapterID: body.ChapterID,
|
||||
IsCollect: *body.IsCollect,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": env.Tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ChapterUnlock BFF:代理 POST /api/app/playlet/chapter/unlock。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_unlock
|
||||
func ChapterUnlock(c *gin.Context) {
|
||||
var body struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.ChapterID <= 0 || body.PlayletID <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "chapterId/playletId required",
|
||||
"tip": "缺少剧集或短剧 ID",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := playletser.ChapterUnlock(c, playletser.PlayletChapterUnlockReq{
|
||||
ChapterID: body.ChapterID,
|
||||
PlayletID: body.PlayletID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "解锁失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "解锁失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": env.Tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ChapterWatchReport BFF:代理 POST /api/app/playlet/chapter/watch/report。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_watch_report
|
||||
func ChapterWatchReport(c *gin.Context) {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 401,
|
||||
"msg": "unauthorized",
|
||||
"tip": "请先登录",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.ChapterID <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "chapterId required",
|
||||
"tip": "缺少剧集 ID",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := playletser.ChapterWatchReport(c, playletser.PlayletChapterWatchReportReq{
|
||||
ChapterID: body.ChapterID,
|
||||
PlayletID: body.PlayletID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "上报失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "上报失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": env.Tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/commentser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CommentList BFF:代理 GET /api/app/comment/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/get_api_app_comment_list
|
||||
func CommentList(c *gin.Context) {
|
||||
objectID, _ := strconv.ParseInt(c.Query("objectId"), 10, 64)
|
||||
objectType, _ := strconv.Atoi(c.Query("objectType"))
|
||||
pageNum, _ := strconv.Atoi(c.Query("pageNum"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("pageSize"))
|
||||
sortOrder, _ := strconv.Atoi(c.Query("sortOrder"))
|
||||
if objectID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "objectId required",
|
||||
"tip": "缺少评论对象",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
data := commentser.FetchListOrEmpty(c, commentser.ListQuery{
|
||||
ObjectID: objectID,
|
||||
ObjectType: objectType,
|
||||
PageNum: pageNum,
|
||||
PageSize: pageSize,
|
||||
SortOrder: sortOrder,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"tip": "成功",
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
// CommentSend BFF:代理 POST /api/app/comment/send。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/post_api_app_comment_send
|
||||
func CommentSend(c *gin.Context) {
|
||||
var req commentser.CommentSendReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
env, err := commentser.Send(c, req)
|
||||
if err != nil {
|
||||
tip := "发布失败"
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "empty text"):
|
||||
tip = "请输入评论内容"
|
||||
case strings.Contains(msg, "too long"):
|
||||
tip = "评论不能超过 200 字"
|
||||
case strings.Contains(msg, "invalid objectId"):
|
||||
tip = "缺少评论对象"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": msg,
|
||||
"tip": tip,
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "发布失败",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
tip := env.Tip
|
||||
if tip == "" {
|
||||
tip = env.Msg
|
||||
}
|
||||
// 上游参数错误兜底提示(例如 objectType 不被支持)
|
||||
if env.Code == 1001 && (tip == "" || tip == "参数错误") {
|
||||
tip = "评论参数错误,请稍后重试"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// CommentLikeSet BFF:代理 POST /api/app/comment/like/set。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/post_api_app_comment_like_set
|
||||
func CommentLikeSet(c *gin.Context) {
|
||||
var body struct {
|
||||
CommentID int64 `json:"commentId"`
|
||||
IsLike *bool `json:"isLike"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.CommentID <= 0 || body.IsLike == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "commentId/isLike required",
|
||||
"tip": "缺少评论 ID 或点赞状态",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
env, err := commentser.LikeSet(c, commentser.CommentLikeSetReq{
|
||||
CommentID: body.CommentID,
|
||||
IsLike: *body.IsLike,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": env.Tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package web
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// Compliance2257 renders the static 18 U.S.C. 2257 compliance statement page.
|
||||
func Compliance2257(c *gin.Context) {
|
||||
HTMLOK(c, "2257.html", gin.H{})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package web
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// DMCA renders the static DMCA copyright notice page.
|
||||
func DMCA(c *gin.Context) {
|
||||
HTMLOK(c, "dmca.html", gin.H{})
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/videoser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type favoriteWork struct {
|
||||
ID int64
|
||||
Title string
|
||||
Genre string
|
||||
DesktopMeta string
|
||||
Episodes string
|
||||
Badge string
|
||||
Image string
|
||||
Kind string
|
||||
URL string
|
||||
DramaID int64
|
||||
Episode int
|
||||
VideoID int64
|
||||
TotalEpisode int
|
||||
}
|
||||
|
||||
// Favorites 我的收藏:短剧 / 剧集(视频) / 章节收藏。
|
||||
// 文档:
|
||||
// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_collect_list
|
||||
// http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_collect_list
|
||||
func Favorites(c *gin.Context) {
|
||||
filter, page, validRoute := favoritesRoute(c)
|
||||
if !validRoute {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{"title": "我的收藏 - 黄果短剧", "navActive": "favorites", "accountTab": "favorites"}
|
||||
responses := gin.H{}
|
||||
loadAccountSummary(c, data, responses)
|
||||
|
||||
status := 0
|
||||
switch filter {
|
||||
case "short", "chapter", "series":
|
||||
case "following":
|
||||
status = 1
|
||||
case "finished":
|
||||
status = 2
|
||||
default:
|
||||
filter = "all"
|
||||
}
|
||||
data["filter"] = filter
|
||||
|
||||
pageSize := 12
|
||||
query := playletser.CollectListQuery{PageNum: page, PageSize: pageSize, WatchStatus: status}
|
||||
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
responses["playlet/collect/list"] = gin.H{"error": "暂无登录会话"}
|
||||
responses["video/collect/list"] = gin.H{"error": "暂无登录会话"}
|
||||
data["favoritesResponse"] = responses["playlet/collect/list"]
|
||||
data["playletCollectResponse"] = responses["playlet/collect/list"]
|
||||
data["videoCollectResponse"] = responses["video/collect/list"]
|
||||
data["favoritesDebug"] = responses
|
||||
renderFavorites(c, data)
|
||||
return
|
||||
}
|
||||
|
||||
if filter == "chapter" {
|
||||
list, code, err := playletser.FetchChapterCollectList(c, query)
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
list, _, err = playletser.FetchChapterCollectList(c, query)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("fetch chapter favorites page failed", log.E(err))
|
||||
responses["playlet/chapter/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
|
||||
data["favoritesUnavailable"] = true
|
||||
} else if list != nil {
|
||||
responses["playlet/chapter/collect/list"] = gin.H{"code": 200, "data": list}
|
||||
data["paging"] = makeFavoritesPage(query.PageNum, query.PageSize, list.TotalCount, filter)
|
||||
data["favorites"] = chapterFavoriteWorks(list.List)
|
||||
} else {
|
||||
responses["playlet/chapter/collect/list"] = gin.H{"code": code, "data": list}
|
||||
}
|
||||
data["favoritesResponse"] = responses["playlet/chapter/collect/list"]
|
||||
data["playletCollectResponse"] = responses["playlet/chapter/collect/list"]
|
||||
data["favoritesDebug"] = responses
|
||||
renderFavorites(c, data)
|
||||
return
|
||||
}
|
||||
|
||||
if filter == "series" {
|
||||
loadVideoFavorites(c, data, responses, page, pageSize)
|
||||
data["favoritesResponse"] = responses["video/collect/list"]
|
||||
data["playletCollectResponse"] = gin.H{}
|
||||
data["videoCollectResponse"] = responses["video/collect/list"]
|
||||
data["favoritesDebug"] = responses
|
||||
renderFavorites(c, data)
|
||||
return
|
||||
}
|
||||
|
||||
var works []favoriteWork
|
||||
var totalCount int64
|
||||
playletFailed := false
|
||||
videoFailed := false
|
||||
|
||||
if filter == "all" || filter == "short" || filter == "following" || filter == "finished" {
|
||||
list, code, err := playletser.FetchCollectList(c, query)
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
list, code, err = playletser.FetchCollectList(c, query)
|
||||
}
|
||||
if err == nil && list != nil {
|
||||
responses["playlet/collect/list"] = gin.H{"code": 200, "data": list}
|
||||
works = append(works, playletFavoriteWorks(list.List)...)
|
||||
totalCount += list.TotalCount
|
||||
} else if err != nil {
|
||||
responses["playlet/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
|
||||
log.Error("fetch favorites page failed", log.E(err))
|
||||
playletFailed = true
|
||||
} else {
|
||||
responses["playlet/collect/list"] = gin.H{"code": code, "data": list}
|
||||
}
|
||||
}
|
||||
|
||||
if filter == "all" {
|
||||
videoQuery := videoser.CollectListQuery{PageNum: page, PageSize: pageSize}
|
||||
videoList, videoCode, videoErr := videoser.FetchCollectList(c, videoQuery)
|
||||
if retry, _ := authser.OnSessionExpired(c, videoCode); retry {
|
||||
videoList, videoCode, videoErr = videoser.FetchCollectList(c, videoQuery)
|
||||
}
|
||||
if videoErr == nil && videoList != nil {
|
||||
responses["video/collect/list"] = gin.H{"code": 200, "data": videoList}
|
||||
works = append(works, videoFavoriteWorks(videoList.List)...)
|
||||
totalCount += videoList.TotalCount
|
||||
} else if videoErr != nil {
|
||||
responses["video/collect/list"] = gin.H{"error": "加载失败", "msg": videoErr.Error()}
|
||||
log.Error("fetch video favorites page failed", log.E(videoErr))
|
||||
videoFailed = true
|
||||
} else {
|
||||
responses["video/collect/list"] = gin.H{"code": videoCode, "data": videoList}
|
||||
}
|
||||
}
|
||||
|
||||
if playletFailed && (filter != "all" || videoFailed) {
|
||||
data["favoritesUnavailable"] = true
|
||||
}
|
||||
if filter == "all" && playletFailed && videoFailed {
|
||||
data["favoritesUnavailable"] = true
|
||||
}
|
||||
|
||||
data["paging"] = makeFavoritesPage(page, pageSize, totalCount, filter)
|
||||
data["favorites"] = works
|
||||
data["favoritesResponse"] = responses["playlet/collect/list"]
|
||||
data["playletCollectResponse"] = responses["playlet/collect/list"]
|
||||
data["videoCollectResponse"] = responses["video/collect/list"]
|
||||
data["favoritesDebug"] = responses
|
||||
renderFavorites(c, data)
|
||||
}
|
||||
|
||||
// renderFavorites 收藏为空时附带「大家都在看」后渲染。
|
||||
func renderFavorites(c *gin.Context, data gin.H) {
|
||||
attachFavoritesRecommended(c, data)
|
||||
HTMLOK(c, "favorites.html", data)
|
||||
}
|
||||
|
||||
// attachFavoritesRecommended 列表为空时拉取最热短剧(与搜索无结果一致)。
|
||||
// 上游:GET /api/app/playlet/all/list sortType=2
|
||||
func attachFavoritesRecommended(c *gin.Context, data gin.H) {
|
||||
if works, ok := data["favorites"].([]favoriteWork); ok && len(works) > 0 {
|
||||
return
|
||||
}
|
||||
watchingQ := playletser.AllListQuery{
|
||||
PageNum: 1,
|
||||
PageSize: 6,
|
||||
SortType: 2, // 最热
|
||||
UpdateStatus: 0,
|
||||
}
|
||||
watching := playletser.FetchAllListOrEmpty(c, watchingQ)
|
||||
if watching == nil || len(watching.List) == 0 {
|
||||
return
|
||||
}
|
||||
list := watching.List
|
||||
if len(list) > 6 {
|
||||
list = list[:6]
|
||||
}
|
||||
data["recommended"] = list
|
||||
}
|
||||
|
||||
func loadVideoFavorites(c *gin.Context, data gin.H, responses gin.H, page, pageSize int) {
|
||||
videoQuery := videoser.CollectListQuery{PageNum: page, PageSize: pageSize}
|
||||
list, code, err := videoser.FetchCollectList(c, videoQuery)
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
list, code, err = videoser.FetchCollectList(c, videoQuery)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("fetch video favorites page failed", log.E(err))
|
||||
responses["video/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
|
||||
data["favoritesUnavailable"] = true
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
responses["video/collect/list"] = gin.H{"code": code, "data": list}
|
||||
return
|
||||
}
|
||||
responses["video/collect/list"] = gin.H{"code": 200, "data": list}
|
||||
data["paging"] = makeFavoritesPage(page, pageSize, list.TotalCount, "series")
|
||||
data["favorites"] = videoFavoriteWorks(list.List)
|
||||
}
|
||||
|
||||
func favoritesRoute(c *gin.Context) (filter string, page int, valid bool) {
|
||||
if c.Request.URL.Query().Has("filter") {
|
||||
return "", 0, false
|
||||
}
|
||||
filter = strings.TrimSpace(c.Param("filter"))
|
||||
switch filter {
|
||||
case "":
|
||||
filter = "all"
|
||||
case "short", "chapter", "series", "following", "finished":
|
||||
default:
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
page, valid = accountPageNum(c)
|
||||
if !valid {
|
||||
return "", 0, false
|
||||
}
|
||||
return filter, page, true
|
||||
}
|
||||
|
||||
func favoritesPagePath(filter string, page int) string {
|
||||
path := "/favorites"
|
||||
if filter != "" && filter != "all" {
|
||||
path += "/" + filter
|
||||
}
|
||||
if page > 1 {
|
||||
path += "/page/" + strconv.Itoa(page)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func makeFavoritesPage(page, size int, count int64, filter string) accountPage {
|
||||
total := int((count + int64(size) - 1) / int64(size))
|
||||
pagination := accountPage{Current: page, Total: total, Count: count}
|
||||
if page > 1 {
|
||||
pagination.PrevURL = favoritesPagePath(filter, page-1)
|
||||
}
|
||||
if page < total {
|
||||
pagination.NextURL = favoritesPagePath(filter, page+1)
|
||||
}
|
||||
return pagination
|
||||
}
|
||||
|
||||
func playletFavoriteWorks(items []playletser.PlayletCollectData) []favoriteWork {
|
||||
works := make([]favoriteWork, 0, len(items))
|
||||
for _, item := range items {
|
||||
title := strings.TrimSpace(item.PlayName)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
work := favoriteWork{
|
||||
ID: item.ID, Title: title, Genre: strings.Join(item.ThemeNames, " "),
|
||||
Image: profileCover(item.Cover), Kind: "short",
|
||||
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
|
||||
DramaID: item.ID, Episode: item.LastEpisode, VideoID: item.LastChapterID,
|
||||
TotalEpisode: item.TotalEpisode,
|
||||
}
|
||||
if work.Episode <= 0 {
|
||||
work.Episode = 1
|
||||
}
|
||||
if item.TotalEpisode > 0 {
|
||||
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
|
||||
work.DesktopMeta = work.Episodes
|
||||
}
|
||||
if item.IsFeatured {
|
||||
work.Badge = "爆剧"
|
||||
}
|
||||
works = append(works, work)
|
||||
}
|
||||
return works
|
||||
}
|
||||
|
||||
func chapterFavoriteWorks(items []playletser.PlayletChapterCollectData) []favoriteWork {
|
||||
works := make([]favoriteWork, 0, len(items))
|
||||
for _, item := range items {
|
||||
title := strings.TrimSpace(item.PlayletTitle)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
work := favoriteWork{
|
||||
ID: item.ChapterID, Title: title, Image: profileCover(item.PlayletCover), Kind: "chapter",
|
||||
URL: playletser.ChapterPlayPath(item.PlayletSlug, item.PlayletID, item.ChapterID), DesktopMeta: item.ChapterTitle,
|
||||
DramaID: item.PlayletID, Episode: item.CurrentEpisode, VideoID: item.ChapterID,
|
||||
TotalEpisode: item.TotalEpisode,
|
||||
}
|
||||
if work.Episode <= 0 {
|
||||
work.Episode = 1
|
||||
}
|
||||
if item.CurrentEpisode > 0 {
|
||||
work.Genre = fmt.Sprintf("第 %d 集", item.CurrentEpisode)
|
||||
}
|
||||
if item.TotalEpisode > 0 {
|
||||
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
|
||||
}
|
||||
works = append(works, work)
|
||||
}
|
||||
return works
|
||||
}
|
||||
|
||||
func videoFavoriteWorks(items []videoser.VideoCollectData) []favoriteWork {
|
||||
works := make([]favoriteWork, 0, len(items))
|
||||
for _, item := range items {
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
work := favoriteWork{
|
||||
ID: item.ID, Title: title, Genre: strings.Join(item.Keyworld, " "),
|
||||
Image: profileCover(item.CoverImg), Kind: "series",
|
||||
URL: videoser.VideoPlayURL(item.Slug, item.ID),
|
||||
}
|
||||
if item.VideoTime > 0 {
|
||||
work.Episodes = formatVideoDuration(item.VideoTime)
|
||||
work.DesktopMeta = work.Episodes
|
||||
} else {
|
||||
work.Episodes = "剧集"
|
||||
work.DesktopMeta = "剧集"
|
||||
}
|
||||
works = append(works, work)
|
||||
}
|
||||
return works
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/feedbackser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Feedback 意见反馈页:渲染 template/pages/feedback.html。
|
||||
func Feedback(c *gin.Context) {
|
||||
HTMLOK(c, "feedback.html", gin.H{
|
||||
"title": "意见反馈 - 黄果短剧",
|
||||
})
|
||||
}
|
||||
|
||||
// FeedbackAdd BFF:代理 POST /api/app/feedback/add。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/意见反馈相关接口/post_api_app_feedback_add
|
||||
func FeedbackAdd(c *gin.Context) {
|
||||
var body feedbackser.FeedbackReq
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
body.Content = strings.TrimSpace(body.Content)
|
||||
body.Contact = strings.TrimSpace(body.Contact)
|
||||
if body.Type < 1 || body.Type > 4 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid type",
|
||||
"tip": "请选择问题类型",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.Content == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "content required",
|
||||
"tip": "请填写问题描述",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if runes := []rune(body.Content); len(runes) > 500 {
|
||||
body.Content = string(runes[:500])
|
||||
}
|
||||
if len(body.Screenshots) > 3 {
|
||||
body.Screenshots = body.Screenshots[:3]
|
||||
}
|
||||
|
||||
env, err := feedbackser.Add(c, body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "提交失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "提交失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": env.Tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// UploadImage BFF:代理 POST /api/app/upload/image。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/上传/post_api_app_upload_image
|
||||
func UploadImage(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil || fileHeader == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "file required",
|
||||
"tip": "请选择图片文件",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
f, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "读取图片失败",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
name := filepath.Base(fileHeader.Filename)
|
||||
if name == "" || name == "." || name == "/" {
|
||||
name = "image.jpg"
|
||||
}
|
||||
resp, err := authser.UploadImage(c, name, f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "上传失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if resp == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "上传失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var data gin.H
|
||||
if resp.Data != nil {
|
||||
url := strings.TrimSpace(resp.Data.SourceNameFs)
|
||||
if url == "" {
|
||||
url = strings.TrimSpace(resp.Data.ID)
|
||||
}
|
||||
data = gin.H{
|
||||
"id": resp.Data.ID,
|
||||
"sourceNameFs": resp.Data.SourceNameFs,
|
||||
"url": url,
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": resp.Code,
|
||||
"msg": resp.Msg,
|
||||
"tip": resp.Tip,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GuessLikeList BFF:代理 GET /api/app/playlet/guess-like/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_guess_like_list
|
||||
func GuessLikeList(c *gin.Context) {
|
||||
playletID, _ := strconv.ParseInt(c.Query("playletId"), 10, 64)
|
||||
pageNum, _ := strconv.Atoi(c.Query("pageNum"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("pageSize"))
|
||||
if playletID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "playletId required",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
data := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
|
||||
PlayletID: playletID,
|
||||
PageNum: pageNum,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/seoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Help renders the static Help page.
|
||||
func Help(c *gin.Context) {
|
||||
HTMLOK(c, "help.html", gin.H{
|
||||
"faqItems": []seoser.FAQItem{
|
||||
{Question: "黄果短剧网页版可以看全集吗?", Answer: "可以。免费集直接观看,付费集可用金币按集解锁,解锁后永久有效。"},
|
||||
{Question: "解锁过的剧集还需要再付费吗?", Answer: "不需要。已解锁的剧集可在登录后继续观看,无需重复付费。"},
|
||||
{Question: "如何充值金币?", Answer: "登录后进入个人中心,选择金币充值,即可查看可用的充值方式。"},
|
||||
{Question: "支持哪些支付方式?", Answer: "可用的支付方式会在充值页面列出,请以页面显示为准。"},
|
||||
{Question: "忘记密码怎么办?", Answer: "请通过意见反馈联系我们,提交账号信息后由客服协助处理。"},
|
||||
{Question: "内容多久更新一次?", Answer: "黄果短剧持续更新内容,欢迎关注首页的新剧和最近上新。"},
|
||||
{Question: "如何联系客服或洽谈合作?", Answer: "请发送邮件至 support@huangguo.com,我们会尽快回复。"},
|
||||
{Question: "如何注销账号?", Answer: "请通过意见反馈或客服邮箱提交注销申请。"},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/videoser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type historyEntry struct {
|
||||
ID int64
|
||||
Title string
|
||||
Current int
|
||||
Total int
|
||||
EpisodeText string
|
||||
Genre string
|
||||
Time string
|
||||
Image string
|
||||
Progress string
|
||||
Kind string
|
||||
URL string
|
||||
sortAt time.Time
|
||||
}
|
||||
|
||||
type historyGroup struct {
|
||||
Title string
|
||||
Entries []historyEntry
|
||||
}
|
||||
|
||||
// History 观看历史页:短剧 GET /api/app/playlet/history/list + 剧集 GET /api/app/video/history/list。
|
||||
// 文档:
|
||||
// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_history_list
|
||||
// http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_history_list
|
||||
func History(c *gin.Context) {
|
||||
page, validPage := accountPageNum(c)
|
||||
if !validPage {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{"title": "观看历史 - 黄果短剧", "navActive": "history", "accountTab": "history"}
|
||||
responses := gin.H{}
|
||||
loadAccountSummary(c, data, responses)
|
||||
|
||||
pageSize := 20
|
||||
var entries []historyEntry
|
||||
var totalCount int64
|
||||
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
responses["playlet/history/list"] = gin.H{"error": "暂无登录会话"}
|
||||
responses["video/history/list"] = gin.H{"error": "暂无登录会话"}
|
||||
} else {
|
||||
playletQuery := playletser.HistoryListQuery{PageNum: page, PageSize: pageSize}
|
||||
playletList, playletCode, playletErr := playletser.FetchHistoryList(c, playletQuery)
|
||||
if retry, _ := authser.OnSessionExpired(c, playletCode); retry {
|
||||
playletList, playletCode, playletErr = playletser.FetchHistoryList(c, playletQuery)
|
||||
}
|
||||
if playletErr == nil && playletList != nil {
|
||||
responses["playlet/history/list"] = gin.H{"code": 200, "data": playletList}
|
||||
entries = append(entries, playletHistoryEntries(playletList.List)...)
|
||||
totalCount += playletList.TotalCount
|
||||
} else if playletErr != nil {
|
||||
responses["playlet/history/list"] = gin.H{"error": "加载失败", "msg": playletErr.Error()}
|
||||
log.Error("fetch playlet history page failed", log.E(playletErr))
|
||||
} else {
|
||||
responses["playlet/history/list"] = gin.H{"code": playletCode, "data": playletList}
|
||||
}
|
||||
|
||||
videoQuery := videoser.HistoryListQuery{PageNum: page, PageSize: pageSize}
|
||||
videoList, videoCode, videoErr := videoser.FetchHistoryList(c, videoQuery)
|
||||
if retry, _ := authser.OnSessionExpired(c, videoCode); retry {
|
||||
videoList, videoCode, videoErr = videoser.FetchHistoryList(c, videoQuery)
|
||||
}
|
||||
if videoErr == nil && videoList != nil {
|
||||
responses["video/history/list"] = gin.H{"code": 200, "data": videoList}
|
||||
entries = append(entries, videoHistoryEntries(videoList.List)...)
|
||||
totalCount += videoList.TotalCount
|
||||
} else if videoErr != nil {
|
||||
responses["video/history/list"] = gin.H{"error": "加载失败", "msg": videoErr.Error()}
|
||||
log.Error("fetch video history page failed", log.E(videoErr))
|
||||
} else {
|
||||
responses["video/history/list"] = gin.H{"code": videoCode, "data": videoList}
|
||||
}
|
||||
|
||||
if playletErr != nil && videoErr != nil {
|
||||
data["historyUnavailable"] = true
|
||||
}
|
||||
}
|
||||
|
||||
data["paging"] = makeAccountPage("/history", page, pageSize, totalCount)
|
||||
data["groups"] = groupHistoryEntries(entries)
|
||||
data["historyResponse"] = responses["playlet/history/list"]
|
||||
data["playletHistoryResponse"] = responses["playlet/history/list"]
|
||||
data["videoHistoryResponse"] = responses["video/history/list"]
|
||||
data["historyDebug"] = responses
|
||||
HTMLOK(c, "history.html", data)
|
||||
}
|
||||
|
||||
// HistoryClear BFF:代理 POST /api/app/playlet/history/clear。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_clear
|
||||
func HistoryClear(c *gin.Context) {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
|
||||
return
|
||||
}
|
||||
env, err := playletser.HistoryClear(c)
|
||||
if err != nil {
|
||||
log.Error("clear playlet history failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "清空失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "清空失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
if env.Code != 200 {
|
||||
tip := strings.TrimSpace(env.Tip)
|
||||
if tip == "" {
|
||||
tip = strings.TrimSpace(env.Msg)
|
||||
}
|
||||
if tip == "" {
|
||||
tip = "清空失败,请稍后重试"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
|
||||
}
|
||||
|
||||
// VideoHistoryClear BFF:代理 POST /api/app/video/history/clear。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_clear
|
||||
func VideoHistoryClear(c *gin.Context) {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
|
||||
return
|
||||
}
|
||||
env, err := videoser.HistoryClear(c)
|
||||
if err != nil {
|
||||
log.Error("clear video history failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "清空失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "清空失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
if env.Code != 200 {
|
||||
tip := strings.TrimSpace(env.Tip)
|
||||
if tip == "" {
|
||||
tip = strings.TrimSpace(env.Msg)
|
||||
}
|
||||
if tip == "" {
|
||||
tip = "清空失败,请稍后重试"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
|
||||
}
|
||||
|
||||
// HistoryDelete BFF:代理 POST /api/app/playlet/history/delete。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_delete
|
||||
func HistoryDelete(c *gin.Context) {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
|
||||
return
|
||||
}
|
||||
var body playletser.PlayletHistoryDeleteReq
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.PlayletID <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "playletId required", "tip": "参数错误", "data": nil})
|
||||
return
|
||||
}
|
||||
env, err := playletser.HistoryDelete(c, body)
|
||||
if err != nil {
|
||||
log.Error("delete playlet history failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "删除失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "删除失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
if env.Code != 200 {
|
||||
tip := strings.TrimSpace(env.Tip)
|
||||
if tip == "" {
|
||||
tip = strings.TrimSpace(env.Msg)
|
||||
}
|
||||
if tip == "" {
|
||||
tip = "删除失败,请稍后重试"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
|
||||
}
|
||||
|
||||
// VideoHistoryDelete BFF:代理 POST /api/app/video/history/delete。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_delete
|
||||
func VideoHistoryDelete(c *gin.Context) {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
|
||||
return
|
||||
}
|
||||
var body videoser.VideoHistoryDeleteReq
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.VideoID <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "videoId required", "tip": "参数错误", "data": nil})
|
||||
return
|
||||
}
|
||||
env, err := videoser.HistoryDelete(c, body)
|
||||
if err != nil {
|
||||
log.Error("delete video history failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "删除失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "删除失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
if env.Code != 200 {
|
||||
tip := strings.TrimSpace(env.Tip)
|
||||
if tip == "" {
|
||||
tip = strings.TrimSpace(env.Msg)
|
||||
}
|
||||
if tip == "" {
|
||||
tip = "删除失败,请稍后重试"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
|
||||
}
|
||||
|
||||
func playletHistoryEntries(items []playletser.PlayletHistoryData) []historyEntry {
|
||||
out := make([]historyEntry, 0, len(items))
|
||||
for _, item := range items {
|
||||
title := strings.TrimSpace(item.PlayName)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
progress := 0
|
||||
if item.TotalEpisode > 0 {
|
||||
progress = item.WatchedCount * 100 / item.TotalEpisode
|
||||
}
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
if progress > 100 {
|
||||
progress = 100
|
||||
}
|
||||
out = append(out, historyEntry{
|
||||
ID: item.ID,
|
||||
Title: title,
|
||||
Current: item.LastEpisode,
|
||||
Total: item.TotalEpisode,
|
||||
Genre: strings.Join(item.ThemeNames, " "),
|
||||
Time: playletser.FormatHistoryListTime(item.HistoryAt),
|
||||
Image: profileCover(item.Cover),
|
||||
Progress: fmt.Sprintf("%d%%", progress),
|
||||
Kind: "short",
|
||||
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
|
||||
sortAt: historySortAt(item.HistoryAt),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func videoHistoryEntries(items []videoser.VideoHistoryData) []historyEntry {
|
||||
out := make([]historyEntry, 0, len(items))
|
||||
for _, item := range items {
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, historyEntry{
|
||||
ID: item.ID,
|
||||
Title: title,
|
||||
EpisodeText: videoHistoryEpisodeText(item),
|
||||
Genre: strings.Join(item.Keyworld, " "),
|
||||
Time: playletser.FormatHistoryListTime(item.HistoryAt),
|
||||
Image: profileCover(item.CoverImg),
|
||||
Progress: "",
|
||||
Kind: "series",
|
||||
URL: videoser.VideoPlayURL(item.Slug, item.ID),
|
||||
sortAt: historySortAt(item.HistoryAt),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func videoHistoryEpisodeText(item videoser.VideoHistoryData) string {
|
||||
if item.VideoTime > 0 {
|
||||
return formatVideoDuration(item.VideoTime)
|
||||
}
|
||||
return "剧集"
|
||||
}
|
||||
|
||||
func formatVideoDuration(sec int) string {
|
||||
if sec <= 0 {
|
||||
return ""
|
||||
}
|
||||
h := sec / 3600
|
||||
m := (sec % 3600) / 60
|
||||
s := sec % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", m, s)
|
||||
}
|
||||
|
||||
func historySortAt(raw string) time.Time {
|
||||
t, ok := playletser.ParseHistoryTime(raw)
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func groupHistoryEntries(entries []historyEntry) []historyGroup {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].sortAt.Equal(entries[j].sortAt) {
|
||||
return entries[i].ID > entries[j].ID
|
||||
}
|
||||
return entries[i].sortAt.After(entries[j].sortAt)
|
||||
})
|
||||
order := []string{"今天", "昨天", "更早"}
|
||||
buckets := map[string][]historyEntry{
|
||||
"今天": {},
|
||||
"昨天": {},
|
||||
"更早": {},
|
||||
}
|
||||
for _, entry := range entries {
|
||||
bucket := historyBucketFromTime(entry.sortAt)
|
||||
buckets[bucket] = append(buckets[bucket], entry)
|
||||
}
|
||||
groups := make([]historyGroup, 0, 3)
|
||||
for _, title := range order {
|
||||
if len(buckets[title]) == 0 {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, historyGroup{Title: title, Entries: buckets[title]})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func historyBucketFromTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "更早"
|
||||
}
|
||||
now := time.Now()
|
||||
startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
dayDiff := int(startToday.Sub(startThat).Hours() / 24)
|
||||
switch {
|
||||
case dayDiff <= 0:
|
||||
return "今天"
|
||||
case dayDiff == 1:
|
||||
return "昨天"
|
||||
default:
|
||||
return "更早"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/videoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Index 首页 SSR:拉取 GET /api/app/playlet/home,按接口字段注入模板。
|
||||
// 接口文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home
|
||||
// 继续观看:GET /api/app/playlet/history/list?pageNum=1&pageSize=6
|
||||
// 热播榜:短剧 hot/rank/list;剧集 video/all/list sortType=最热
|
||||
func Index(c *gin.Context) {
|
||||
raw := playletser.FetchHomeOrEmpty(c)
|
||||
title := "黄果短剧"
|
||||
if raw != nil && raw.SeoInfo.Title != "" {
|
||||
title = raw.SeoInfo.Title
|
||||
}
|
||||
history := playletser.FetchHistoryListOrEmpty(c, playletser.HistoryListQuery{PageNum: 1, PageSize: 6})
|
||||
continueWatch := history.List
|
||||
if len(continueWatch) > 6 {
|
||||
continueWatch = continueWatch[:6]
|
||||
}
|
||||
|
||||
hotPlaylets := playletser.FetchHotRankListOrEmpty(c, playletser.HotRankQuery{
|
||||
PageNum: 1,
|
||||
PageSize: 10,
|
||||
RankType: 1,
|
||||
}).List
|
||||
if len(hotPlaylets) > 10 {
|
||||
hotPlaylets = hotPlaylets[:10]
|
||||
}
|
||||
|
||||
hotVideos := videoser.FetchVideoAllListOrEmpty(c, videoser.SeriesListQuery{
|
||||
PageNum: 1,
|
||||
PageSize: 10,
|
||||
SortType: videoser.SortHeat,
|
||||
MediaType: videoser.MediaAll,
|
||||
}).List
|
||||
if len(hotVideos) > 10 {
|
||||
hotVideos = hotVideos[:10]
|
||||
}
|
||||
|
||||
HTMLOK(c, "index.html", gin.H{
|
||||
"title": title,
|
||||
"navActive": "home",
|
||||
"seoInfo": raw.SeoInfo,
|
||||
"announcements": raw.Announcements,
|
||||
"advitise": raw.Advitise,
|
||||
"homeBannerData": playletser.ActiveHomeBanners(raw.HomeBannerData),
|
||||
"homeConfigData": playletser.ActiveHomeConfigs(raw.HomeConfigData),
|
||||
// GET /api/app/playlet/tag/options → list(已上架标签)
|
||||
"tagOptions": playletser.FetchTagOptionsOrEmpty(c),
|
||||
"continueWatch": continueWatch,
|
||||
"hotRankPlaylets": hotPlaylets,
|
||||
"hotRankVideos": hotVideos,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// List 短剧列表页:题材 + GET /api/app/playlet/all/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_all_list
|
||||
func List(c *gin.Context) {
|
||||
q := playletser.ParseAllListQuery(c)
|
||||
themes := playletser.FetchThemesOrEmpty(c, 0)
|
||||
playletser.ResolveAllListTheme(&q, themes)
|
||||
themeResolved := listThemeResolved(q, themes)
|
||||
raw := playletser.FetchAllListOrEmpty(c, q)
|
||||
pageHeading, seoSelections := listSEOContext(q, themes)
|
||||
title := pageHeading + " - 黄果短剧"
|
||||
if raw != nil && raw.SeoInfo.Title != "" {
|
||||
title = raw.SeoInfo.Title
|
||||
}
|
||||
list := []playletser.CommonData(nil)
|
||||
total := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var seo playletser.SeoInfo
|
||||
var advitise any
|
||||
if raw != nil {
|
||||
list = raw.List
|
||||
total = raw.TotalCount
|
||||
seo = raw.SeoInfo
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
advitise = raw.Advitise
|
||||
}
|
||||
HTMLOK(c, "list.html", gin.H{
|
||||
"title": title,
|
||||
"navActive": "list",
|
||||
"seoInfo": seo,
|
||||
"seoSelections": seoSelections,
|
||||
"pageHeading": pageHeading,
|
||||
"list": list,
|
||||
"totalCount": total,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"listCount": fmt.Sprintf("共 %d 部", total),
|
||||
"advitise": advitise,
|
||||
"themeList": themes,
|
||||
"themeSlug": q.ThemeSlug,
|
||||
"themeId": q.ThemeID,
|
||||
"updateStatus": q.UpdateStatus,
|
||||
"sortType": q.SortType,
|
||||
"seoEntityValid": themeResolved || total > 0,
|
||||
"pagination": playletser.BuildAllListPagination(q, total),
|
||||
"canonicalPath": q.URL(),
|
||||
})
|
||||
}
|
||||
|
||||
func listThemeResolved(q playletser.AllListQuery, themes []playletser.PlayletThemeItem) bool {
|
||||
if q.ThemeID <= 0 && q.ThemeSlug == "" {
|
||||
return true
|
||||
}
|
||||
for _, theme := range themes {
|
||||
if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func listSEOContext(q playletser.AllListQuery, themes []playletser.PlayletThemeItem) (string, []playletser.SEOSelection) {
|
||||
heading := "全部短剧"
|
||||
selections := make([]playletser.SEOSelection, 0, 3)
|
||||
themeName := q.ThemeSlug
|
||||
for _, theme := range themes {
|
||||
if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) {
|
||||
themeName = theme.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if themeName != "" {
|
||||
heading = themeName + "短剧"
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: themeName})
|
||||
}
|
||||
if q.UpdateStatus == 1 {
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "状态", Value: "连载中"})
|
||||
} else if q.UpdateStatus == 2 {
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "状态", Value: "已完结"})
|
||||
}
|
||||
if q.SortType == 2 {
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "最热"})
|
||||
} else if q.SortType == 3 {
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"})
|
||||
}
|
||||
return heading, selections
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package web
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// LoginPage H5 登录页:渲染 template/pages/login.html。
|
||||
// PC 端由页面内脚本回首页并打开 loginPop;H5 直接展示本页。
|
||||
func LoginPage(c *gin.Context) {
|
||||
HTMLOK(c, "login.html", gin.H{
|
||||
"title": "登录 - 黄果短剧",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// MoreList 首页配置更多页:GET /api/app/playlet/home/config/detail/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home_config_detail_list
|
||||
func MoreList(c *gin.Context) {
|
||||
q := playletser.ParseMoreListQuery(c)
|
||||
themes := playletser.FetchThemesOrEmpty(c, 0)
|
||||
playletser.ResolveMoreListTheme(&q, themes)
|
||||
raw := playletser.FetchHomeConfigDetailListOrEmpty(c, q)
|
||||
if raw != nil {
|
||||
playletser.ApplyHomeConfigDetailIdentity(&q, raw)
|
||||
}
|
||||
home := playletser.FetchHomeOrEmpty(c)
|
||||
homeConfigData := playletser.ActiveHomeConfigs(home.HomeConfigData)
|
||||
|
||||
name := "更多短剧"
|
||||
desc := ""
|
||||
entityName := ""
|
||||
var seo playletser.SeoInfo
|
||||
if raw != nil {
|
||||
if raw.Name != "" {
|
||||
name = raw.Name
|
||||
entityName = raw.Name
|
||||
}
|
||||
desc = raw.Desc
|
||||
seo = raw.SeoInfo
|
||||
}
|
||||
if config, ok := playletser.FindHomeConfigByIdentity(homeConfigData, q.HomeConfigID, q.HomeConfigSlug); ok {
|
||||
seo = playletser.MergeSeoInfo(config.SeoInfo, seo)
|
||||
if raw == nil || raw.Name == "" {
|
||||
if config.Name != "" {
|
||||
name = config.Name
|
||||
entityName = config.Name
|
||||
}
|
||||
}
|
||||
if desc == "" {
|
||||
desc = config.Desc
|
||||
}
|
||||
}
|
||||
title := name + " - 黄果短剧"
|
||||
if seo.Title != "" {
|
||||
title = seo.Title
|
||||
}
|
||||
|
||||
list := []playletser.CommonData(nil)
|
||||
total := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var advitise any
|
||||
entityValid := false
|
||||
if raw != nil {
|
||||
list = raw.List
|
||||
total = raw.TotalCount
|
||||
advitise = raw.Advitise
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
entityValid = playletser.MoreListEntityValid(q, entityName, total)
|
||||
}
|
||||
|
||||
HTMLOK(c, "moreList.html", gin.H{
|
||||
"title": title,
|
||||
"seoInfo": seo,
|
||||
"routeConfigSEO": true,
|
||||
"homeConfigData": homeConfigData,
|
||||
"seoSelections": moreListSEOSelections(q, themes),
|
||||
"navActive": "home",
|
||||
"list": list,
|
||||
"totalCount": total,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"listCount": fmt.Sprintf("共 %d 部", total),
|
||||
"configId": q.HomeConfigID,
|
||||
"configSlug": q.HomeConfigSlug,
|
||||
"configName": name,
|
||||
"configDesc": desc,
|
||||
"timeRange": q.TimeRange,
|
||||
"themeSlug": q.ThemeSlug,
|
||||
"themeId": q.ThemeID,
|
||||
"seoEntityValid": entityValid,
|
||||
"themeList": themes,
|
||||
"timeTabs": playletser.BuildMoreTimeTabs(q),
|
||||
"advitise": advitise,
|
||||
"pagination": playletser.BuildMoreListPagination(q, total),
|
||||
"canonicalPath": playletser.MoreListCanonicalPath(q),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// MoreSeriesList 首页影集配置更多页(type=2):GET /api/app/playlet/home/config/detail/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home_config_detail_list
|
||||
func MoreSeriesList(c *gin.Context) {
|
||||
q := playletser.ParseMoreListQuery(c)
|
||||
themes := playletser.FetchThemesOrEmpty(c, 0)
|
||||
playletser.ResolveMoreListTheme(&q, themes)
|
||||
raw := playletser.FetchHomeConfigDetailListOrEmpty(c, q)
|
||||
|
||||
name := "更多视频"
|
||||
desc := ""
|
||||
if raw != nil {
|
||||
if raw.Name != "" {
|
||||
name = raw.Name
|
||||
}
|
||||
desc = raw.Desc
|
||||
}
|
||||
title := name + " - 黄果短剧"
|
||||
|
||||
list := []playletser.VideoItemResp(nil)
|
||||
total := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var advitise any
|
||||
entityValid := false
|
||||
if raw != nil {
|
||||
list = raw.VideoList
|
||||
total = raw.TotalCount
|
||||
advitise = raw.Advitise
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
playletser.ApplyHomeConfigDetailIdentity(&q, raw)
|
||||
entityValid = playletser.MoreListEntityValid(q, raw.Name, total)
|
||||
}
|
||||
|
||||
HTMLOK(c, "moreSeriesList.html", gin.H{
|
||||
"title": title,
|
||||
"seoSelections": moreListSEOSelections(q, themes),
|
||||
"navActive": "home",
|
||||
"list": list,
|
||||
"totalCount": total,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"listCount": fmt.Sprintf("共 %d 部", total),
|
||||
"configId": q.HomeConfigID,
|
||||
"configSlug": q.HomeConfigSlug,
|
||||
"configName": name,
|
||||
"configDesc": desc,
|
||||
"timeRange": q.TimeRange,
|
||||
"themeSlug": q.ThemeSlug,
|
||||
"themeId": q.ThemeID,
|
||||
"seoEntityValid": entityValid,
|
||||
"themeList": themes,
|
||||
"timeTabs": playletser.BuildMoreSeriesTimeTabs(q),
|
||||
"advitise": advitise,
|
||||
"pagination": playletser.BuildMoreSeriesListPagination(q, total),
|
||||
"canonicalPath": playletser.MoreSeriesListCanonicalPath(q),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Nav 官方导航页 SSR:直接使用 config/info 注入的 appConfig。
|
||||
func Nav(c *gin.Context) {
|
||||
HTMLOK(c, "nav.html", gin.H{
|
||||
"title": "黄果短剧 · 官方导航",
|
||||
"isNavPage": true,
|
||||
"pageKey": "nav",
|
||||
"pageName": "官方导航",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NotFound 404 页面:渲染 template/pages/404.html。
|
||||
func NotFound(c *gin.Context) {
|
||||
HTML(c, http.StatusNotFound, "404.html", gin.H{
|
||||
"title": "页面不存在 - 黄果短剧",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package web
|
||||
|
||||
import "videoweb/app/service/playletser"
|
||||
|
||||
// mockPagination 为静态稿页生成分页数据;pageURL 暂统一为 #。
|
||||
func mockPagination(page, pageSize int, total int64) playletser.PaginationVM {
|
||||
return playletser.BuildPagination(func(int) string { return "#" }, page, pageSize, total)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/commentser"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/videoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Play 播放页:GET /api/app/playlet/detail + GET /api/app/playlet/chapter/detail。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_detail
|
||||
func Play(c *gin.Context) {
|
||||
if c.Request.URL.Query().Has("guessPage") {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
q := playletser.ParsePlayQuery(c.Param("slug"), c.Param("ep"))
|
||||
if strings.TrimSpace(c.Param("ep")) != "" && q.ChapterID <= 0 {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
playletKey := c.Param("slug")
|
||||
playlet := playletser.FetchDetailOrNilByKey(c, playletKey)
|
||||
if playlet == nil {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
chapterID := playletser.ResolvePlayChapterID(playlet, q.ChapterID)
|
||||
listedChapter := playletser.FindChapter(playlet, chapterID)
|
||||
if chapterID <= 0 || (q.ChapterID > 0 && listedChapter == nil) {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
chapter := playletser.FetchChapterDetailOrNil(c, chapterID, playlet.ID, playlet.Slug)
|
||||
if chapter == nil {
|
||||
if listedChapter != nil {
|
||||
chapter = playletser.ChapterFromList(*listedChapter)
|
||||
}
|
||||
}
|
||||
if chapter == nil {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
seo := playletser.ResolvePlayPageSEO(chapter, playlet)
|
||||
title := playletser.ResolvePlayPageTitle(chapter, playlet)
|
||||
if title == "" {
|
||||
title = "在线播放 - 黄果短剧"
|
||||
}
|
||||
|
||||
hot := playletser.FetchAllListOrEmpty(c, playletser.AllListQuery{
|
||||
PageNum: 1,
|
||||
PageSize: 8,
|
||||
SortType: 2, // 最热
|
||||
UpdateStatus: 0, // 全部
|
||||
})
|
||||
hotList := []playletser.CommonData(nil)
|
||||
if hot != nil {
|
||||
hotList = hot.List
|
||||
}
|
||||
|
||||
guessPage := playletser.ParseGuessLikePage(c)
|
||||
guessLike := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
|
||||
PlayletID: playlet.ID,
|
||||
PageNum: guessPage,
|
||||
PageSize: 6,
|
||||
})
|
||||
if len(guessLike.List) == 0 && guessPage > 1 {
|
||||
guessLike = playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
|
||||
PlayletID: playlet.ID,
|
||||
PageNum: 1,
|
||||
PageSize: 6,
|
||||
})
|
||||
}
|
||||
|
||||
commentObjectID := playlet.ID
|
||||
commentObjectType := commentser.ObjectTypePlaylet
|
||||
if chapterID > 0 {
|
||||
commentObjectID = chapterID
|
||||
commentObjectType = commentser.ObjectTypeChapter
|
||||
}
|
||||
comments := commentser.FetchListOrEmpty(c, commentser.ListQuery{
|
||||
ObjectID: commentObjectID,
|
||||
ObjectType: commentObjectType,
|
||||
PageNum: 1,
|
||||
PageSize: 4,
|
||||
SortOrder: commentser.SortHot,
|
||||
})
|
||||
|
||||
unlockCost := int64(0)
|
||||
balance := int64(0)
|
||||
canWatch := false
|
||||
episode := 0
|
||||
if chapter != nil {
|
||||
unlockCost = chapter.UnlockCost()
|
||||
balance = chapter.Balance
|
||||
canWatch = chapter.AllowWatch()
|
||||
episode = chapter.CurrentEpisode
|
||||
}
|
||||
if episode <= 0 {
|
||||
for i, item := range playlet.PlayletChapterInfo {
|
||||
if item.ID != chapterID {
|
||||
continue
|
||||
}
|
||||
episode = item.CurrentEpisode
|
||||
if episode <= 0 {
|
||||
episode = i + 1
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if episode <= 0 {
|
||||
episode = 1
|
||||
}
|
||||
if unlockCost <= 0 {
|
||||
unlockCost = int64(playletser.FirstUnlockCost(playlet.PlayletChapterInfo))
|
||||
}
|
||||
|
||||
previewURL := strings.TrimSpace(playlet.Preview)
|
||||
mainURL := ""
|
||||
if canWatch && chapter != nil {
|
||||
mainURL = strings.TrimSpace(chapter.VideoURL)
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"title": title,
|
||||
"navActive": "list",
|
||||
"seoInfo": seo,
|
||||
"playlet": playlet,
|
||||
"chapter": chapter,
|
||||
"chapterID": chapterID,
|
||||
"episode": episode,
|
||||
"canWatch": canWatch,
|
||||
"mainURL": mainURL,
|
||||
"previewURL": previewURL,
|
||||
"hasPreview": previewURL != "",
|
||||
"unlockCost": unlockCost,
|
||||
"balance": balance,
|
||||
"balanceText": authser.FormatRecharge(balance),
|
||||
"nextChapterID": playletser.NextChapterID(playlet, chapterID),
|
||||
"advitise": playlet.Advitise,
|
||||
"related": playlet.RelatedRecommendData,
|
||||
"guessLike": guessLike.List,
|
||||
"guessPage": guessLike.PageNum,
|
||||
"guessPageSize": guessLike.PageSize,
|
||||
"guessTotal": guessLike.TotalCount,
|
||||
"hotList": hotList,
|
||||
"hotMoreURL": playletser.AllListURL("all", 0, 0, 2, 1),
|
||||
"comments": comments.List,
|
||||
"commentTotal": comments.TotalCount,
|
||||
"commentPage": comments.PageNum,
|
||||
"commentSize": comments.PageSize,
|
||||
"commentObjectID": commentObjectID,
|
||||
"commentObjectType": commentObjectType,
|
||||
"trackerDrama": dramaTrackingData(playlet, chapter, chapterID, episode),
|
||||
"playResponse": gin.H{
|
||||
"playlet": playlet,
|
||||
"chapter": chapter,
|
||||
"chapterID": chapterID,
|
||||
"canWatch": canWatch,
|
||||
"mainURL": mainURL,
|
||||
"previewURL": previewURL,
|
||||
"hasPreview": previewURL != "",
|
||||
"guessLike": guessLike,
|
||||
"hotList": hotList,
|
||||
"comments": comments,
|
||||
"commentObjectID": commentObjectID,
|
||||
"commentObjectType": commentObjectType,
|
||||
},
|
||||
}
|
||||
data["canonicalPath"] = playletser.PlayPath(playlet.Slug, playlet.ID)
|
||||
attachRechargeModal(c, data)
|
||||
HTMLOK(c, "play.html", data)
|
||||
}
|
||||
|
||||
func dramaTrackingData(playlet *playletser.PlayletInfoResp, chapter *playletser.PlayletChapterInfoResp, chapterID int64, episode int) gin.H {
|
||||
name := strings.TrimSpace(playlet.PlayName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(playlet.Title)
|
||||
}
|
||||
totalEpisode := playlet.TotalEpisode
|
||||
if totalEpisode <= 0 {
|
||||
totalEpisode = len(playlet.PlayletChapterInfo)
|
||||
}
|
||||
if totalEpisode <= 0 {
|
||||
totalEpisode = 1
|
||||
}
|
||||
data := gin.H{
|
||||
"MediaID": "",
|
||||
"DramaID": playlet.ID,
|
||||
"DramaTitle": name,
|
||||
"Episode": max(1, episode),
|
||||
"TotalEpisode": totalEpisode,
|
||||
"VideoID": chapterID,
|
||||
"DramaTypeID": "default",
|
||||
"DramaTypeName": "默认分类",
|
||||
"DramaTagKey": "default",
|
||||
"DramaTagName": "默认标签",
|
||||
"DramaDuration": 0,
|
||||
"RecommendTraceID": "",
|
||||
}
|
||||
if chapter != nil {
|
||||
if chapter.ID > 0 {
|
||||
data["VideoID"] = chapter.ID
|
||||
}
|
||||
if chapter.MediaTime > 0 {
|
||||
data["DramaDuration"] = chapter.MediaTime
|
||||
}
|
||||
}
|
||||
for i, id := range playlet.ThemeIDs {
|
||||
if id <= 0 || i >= len(playlet.ThemeNames) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(playlet.ThemeNames[i])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
data["DramaTypeID"] = strconv.FormatInt(id, 10)
|
||||
data["DramaTypeName"] = name
|
||||
break
|
||||
}
|
||||
tagIDs := make([]string, 0, len(playlet.TagIDs))
|
||||
tagNames := make([]string, 0, len(playlet.TagIDs))
|
||||
for i, id := range playlet.TagIDs {
|
||||
if id <= 0 || i >= len(playlet.TagNames) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(playlet.TagNames[i])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
tagIDs = append(tagIDs, strconv.FormatInt(id, 10))
|
||||
tagNames = append(tagNames, name)
|
||||
}
|
||||
if len(tagIDs) > 0 {
|
||||
data["DramaTagKey"] = strings.Join(tagIDs, ",")
|
||||
data["DramaTagName"] = strings.Join(tagNames, ",")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// SeriesPlay 视频播放页:GET /api/app/video/detail。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_detail
|
||||
func SeriesPlay(c *gin.Context) {
|
||||
if c.Request.URL.Query().Has("guessPage") {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
key := c.Param("slug")
|
||||
video := videoser.FetchVideoDetailOrNilByKey(c, key)
|
||||
if video == nil {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
title := videoser.ResolveVideoPageTitle(video)
|
||||
if title == "" {
|
||||
title = "在线播放 - 黄果短剧"
|
||||
} else {
|
||||
title = title + " - 黄果短剧"
|
||||
}
|
||||
|
||||
cover := video.CoverImg
|
||||
if cover == "" {
|
||||
cover = video.Video.Cover
|
||||
}
|
||||
videoURL := strings.TrimSpace(video.Video.URL)
|
||||
canWatch := videoURL != ""
|
||||
|
||||
commentObjectID := video.ID
|
||||
// 视频评论:objectType=20(swagger:10-短剧 11-短剧剧集 20-视频)
|
||||
commentObjectType := commentser.ObjectTypeVideo
|
||||
comments := commentser.FetchListOrEmpty(c, commentser.ListQuery{
|
||||
ObjectID: commentObjectID,
|
||||
ObjectType: commentObjectType,
|
||||
PageNum: 1,
|
||||
PageSize: 4,
|
||||
SortOrder: commentser.SortHot,
|
||||
})
|
||||
if comments.PageNum <= 0 {
|
||||
comments.PageNum = 1
|
||||
}
|
||||
if comments.PageSize <= 0 {
|
||||
comments.PageSize = 4
|
||||
}
|
||||
commentTotal := comments.TotalCount
|
||||
if commentTotal <= 0 && video.CountComment > 0 {
|
||||
commentTotal = video.CountComment
|
||||
}
|
||||
|
||||
hot := playletser.FetchAllListOrEmpty(c, playletser.AllListQuery{
|
||||
PageNum: 1,
|
||||
PageSize: 8,
|
||||
SortType: 2, // 最热
|
||||
UpdateStatus: 0, // 全部
|
||||
})
|
||||
hotList := []playletser.CommonData(nil)
|
||||
if hot != nil {
|
||||
hotList = hot.List
|
||||
}
|
||||
|
||||
// 视频详情无 recommendVideos:猜你喜欢与 play.html 一致,走短剧同题材推荐。
|
||||
// 以热榜首部短剧为种子 playletId(排除该剧本身)。
|
||||
guessPlayletID := int64(0)
|
||||
if len(hotList) > 0 {
|
||||
guessPlayletID = hotList[0].ID
|
||||
}
|
||||
guessPage := playletser.ParseGuessLikePage(c)
|
||||
guessLike := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
|
||||
PlayletID: guessPlayletID,
|
||||
PageNum: guessPage,
|
||||
PageSize: 6,
|
||||
})
|
||||
if len(guessLike.List) == 0 && guessPage > 1 {
|
||||
guessLike = playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
|
||||
PlayletID: guessPlayletID,
|
||||
PageNum: 1,
|
||||
PageSize: 6,
|
||||
})
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"title": title,
|
||||
"navActive": "aiSeries",
|
||||
"seoInfo": video.SeoInfo,
|
||||
"video": video,
|
||||
"videoID": video.ID,
|
||||
"videoSlug": video.Slug,
|
||||
"cover": cover,
|
||||
"videoURL": videoURL,
|
||||
"canWatch": canWatch,
|
||||
"guessLike": guessLike.List,
|
||||
"guessPage": guessLike.PageNum,
|
||||
"guessPageSize": guessLike.PageSize,
|
||||
"guessTotal": guessLike.TotalCount,
|
||||
"guessPlayletID": guessPlayletID,
|
||||
"advitise": video.Advitise,
|
||||
"comments": comments.List,
|
||||
"commentTotal": commentTotal,
|
||||
"commentPage": comments.PageNum,
|
||||
"commentSize": comments.PageSize,
|
||||
"commentObjectID": commentObjectID,
|
||||
"commentObjectType": commentObjectType,
|
||||
"hotList": hotList,
|
||||
"hotMoreURL": playletser.AllListURL("all", 0, 0, 2, 1),
|
||||
"videoResponse": gin.H{
|
||||
"video": video,
|
||||
"videoURL": videoURL,
|
||||
"canWatch": canWatch,
|
||||
"comments": comments,
|
||||
"hotList": hotList,
|
||||
"guessLike": guessLike,
|
||||
"guessPlayletID": guessPlayletID,
|
||||
"commentObjectID": commentObjectID,
|
||||
"commentObjectType": commentObjectType,
|
||||
},
|
||||
"commentDebug": gin.H{
|
||||
"path": "/api/app/comment/list",
|
||||
"objectId": commentObjectID,
|
||||
"objectType": commentObjectType, // 20-视频
|
||||
"pageNum": comments.PageNum,
|
||||
"pageSize": comments.PageSize,
|
||||
"totalCount": commentTotal,
|
||||
"list": comments.List,
|
||||
"apis": []string{
|
||||
"GET /api/app/comment/list",
|
||||
"POST /api/app/comment/send",
|
||||
"POST /api/app/comment/like/set",
|
||||
},
|
||||
},
|
||||
"playDebug": gin.H{
|
||||
"canWatch": canWatch,
|
||||
"videoURL": videoURL,
|
||||
"cover": cover,
|
||||
"videoTime": video.Video.VideoTime,
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(c.Param("guessPage")) != "" {
|
||||
data["canonicalPath"] = videoser.VideoPlayURL(video.Slug, video.ID)
|
||||
}
|
||||
attachRechargeModal(c, data)
|
||||
HTMLOK(c, "seriesPlay.html", data)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PlayletLikeSet BFF:代理 POST /api/app/playlet/like/set。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_like_set
|
||||
func PlayletLikeSet(c *gin.Context) {
|
||||
var body struct {
|
||||
ID int64 `json:"id"`
|
||||
IsLike *bool `json:"isLike"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.ID <= 0 || body.IsLike == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "id/isLike required",
|
||||
"tip": "缺少短剧 ID 或点赞状态",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := playletser.LikeSet(c, playletser.PlayletLikeSetReq{
|
||||
ID: body.ID,
|
||||
IsLike: *body.IsLike,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": env.Tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PlayletCollectSet BFF:代理 POST /api/app/playlet/collect/set。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_collect_set
|
||||
func PlayletCollectSet(c *gin.Context) {
|
||||
var body struct {
|
||||
ID int64 `json:"id"`
|
||||
IsCollect *bool `json:"isCollect"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.ID <= 0 || body.IsCollect == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "id/isCollect required",
|
||||
"tip": "缺少短剧 ID 或收藏状态",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := playletser.CollectSet(c, playletser.PlayletCollectSetReq{
|
||||
ID: body.ID,
|
||||
IsCollect: *body.IsCollect,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": env.Tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PopularRecommend 热播榜页:GET /api/app/playlet/hot/rank/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_hot_rank_list
|
||||
func PopularRecommend(c *gin.Context) {
|
||||
q := playletser.ParseHotRankQuery(c)
|
||||
raw := playletser.FetchHotRankListOrEmpty(c, q)
|
||||
tabTitle := playletser.HotRankTabTitle(q.RankType)
|
||||
title := tabTitle + " - 黄果短剧"
|
||||
if raw != nil && raw.SeoInfo.Title != "" {
|
||||
title = raw.SeoInfo.Title
|
||||
}
|
||||
|
||||
list := []playletser.CommonData(nil)
|
||||
total := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var seo playletser.SeoInfo
|
||||
if raw != nil {
|
||||
list = raw.List
|
||||
total = raw.TotalCount
|
||||
seo = raw.SeoInfo
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
}
|
||||
|
||||
HTMLOK(c, "popularRecommend.html", gin.H{
|
||||
"title": title,
|
||||
"navActive": "popular",
|
||||
"seoInfo": seo,
|
||||
"seoSelections": rankingSEOSelections(tabTitle),
|
||||
"list": list,
|
||||
"totalCount": total,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"rankType": q.RankType,
|
||||
"tabTitle": tabTitle,
|
||||
"rankTabs": playletser.BuildHotRankTabs(q),
|
||||
"listCount": fmt.Sprintf("共 %d 部", total),
|
||||
"pagination": playletser.BuildHotRankPagination(q, total),
|
||||
"canonicalPath": playletser.HotRankCanonicalPath(q),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package web
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// Privacy renders the static Privacy Policy page.
|
||||
func Privacy(c *gin.Context) {
|
||||
HTMLOK(c, "privacy.html", gin.H{})
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/usercenterser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type profileWork struct {
|
||||
Title, Meta, Episodes, Badge, Image, Progress, Remaining, URL string
|
||||
}
|
||||
|
||||
// Profile 从当前会话拉取用户信息、观看历史和收藏,不展示演示账号数据。
|
||||
func Profile(c *gin.Context) {
|
||||
data := gin.H{
|
||||
"title": "个人中心 - 黄果短剧", "navActive": "profile", "accountTab": "profile",
|
||||
}
|
||||
responses := gin.H{}
|
||||
loadAccountSummary(c, data, responses)
|
||||
if useragent.TokenFromRequest(c.Request) != "" {
|
||||
fetchProfileLists(c, data, responses)
|
||||
}
|
||||
if pretty, err := json.MarshalIndent(responses, "", " "); err == nil {
|
||||
data["responseJSON"] = string(pretty)
|
||||
}
|
||||
HTMLOK(c, "profile.html", data)
|
||||
}
|
||||
|
||||
func loadAccountSummary(c *gin.Context, data, responses gin.H) {
|
||||
data["userName"] = "未登录"
|
||||
data["balance"] = "—"
|
||||
data["historyCount"] = "—"
|
||||
data["favoriteCount"] = "—"
|
||||
data["chasingCount"] = "—"
|
||||
data["boughtChapterCount"] = "—"
|
||||
user := authser.SyncUserOnEnter(c)
|
||||
if user == nil {
|
||||
if useragent.TokenFromRequest(c.Request) != "" {
|
||||
responses["auth/getInfo"] = gin.H{"error": "加载失败"}
|
||||
fetchProfileStats(c, data, responses)
|
||||
return
|
||||
}
|
||||
responses["auth/getInfo"] = gin.H{"error": "暂无登录会话"}
|
||||
return
|
||||
}
|
||||
applyProfileInfo(data, responses, user)
|
||||
if useragent.TokenFromRequest(c.Request) != "" {
|
||||
fetchProfileStats(c, data, responses)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchProfileStats(c *gin.Context, data, responses gin.H) {
|
||||
stats, code, err := usercenterser.Fetch(c)
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
stats, code, err = usercenterser.Fetch(c)
|
||||
}
|
||||
if err != nil || stats == nil {
|
||||
responses["user/center/data"] = gin.H{"error": "加载失败"}
|
||||
if err != nil {
|
||||
log.Error("fetch user center data failed", log.E(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
responses["user/center/data"] = gin.H{"code": code, "data": stats}
|
||||
data["historyCount"] = fmt.Sprint(stats.WatchCount)
|
||||
data["favoriteCount"] = fmt.Sprint(stats.CollectCount)
|
||||
data["chasingCount"] = fmt.Sprint(stats.ChasingCount)
|
||||
data["boughtChapterCount"] = fmt.Sprint(stats.BoughtChapterCount)
|
||||
}
|
||||
|
||||
func fetchProfileLists(c *gin.Context, data, responses gin.H) {
|
||||
query := playletser.HistoryListQuery{PageNum: 1, PageSize: 6}
|
||||
history, code, err := playletser.FetchHistoryList(c, query)
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
history, _, err = playletser.FetchHistoryList(c, query)
|
||||
}
|
||||
if err == nil && history != nil {
|
||||
responses["playlet/history/list"] = gin.H{"code": 200, "data": history}
|
||||
data["history"] = profileHistoryWorks(history.List)
|
||||
} else if err != nil {
|
||||
responses["playlet/history/list"] = gin.H{"error": "加载失败"}
|
||||
log.Error("fetch profile history failed", log.E(err))
|
||||
data["historyUnavailable"] = true
|
||||
}
|
||||
|
||||
collectQuery := playletser.CollectListQuery{PageNum: 1, PageSize: 6, WatchStatus: 0}
|
||||
favorites, code, err := playletser.FetchCollectList(c, collectQuery)
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
favorites, _, err = playletser.FetchCollectList(c, collectQuery)
|
||||
}
|
||||
if err == nil && favorites != nil {
|
||||
responses["playlet/collect/list"] = gin.H{"code": 200, "data": favorites}
|
||||
data["favorites"] = profileFavoriteWorks(favorites.List)
|
||||
} else if err != nil {
|
||||
responses["playlet/collect/list"] = gin.H{"error": "加载失败"}
|
||||
log.Error("fetch profile favorites failed", log.E(err))
|
||||
data["favoritesUnavailable"] = true
|
||||
}
|
||||
}
|
||||
|
||||
func applyProfileInfo(data, responses gin.H, user *authser.LoginResp) {
|
||||
if user == nil {
|
||||
return
|
||||
}
|
||||
// 登录响应可能包含 token;调试区域只展示非凭证字段。
|
||||
rawUser, _ := json.Marshal(user)
|
||||
var safeUser map[string]any
|
||||
_ = json.Unmarshal(rawUser, &safeUser)
|
||||
delete(safeUser, "token")
|
||||
responses["auth/getInfo"] = gin.H{"code": 200, "data": safeUser}
|
||||
name := strings.TrimSpace(user.NickName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(user.AccountNo)
|
||||
}
|
||||
if name == "" {
|
||||
name = "用户"
|
||||
}
|
||||
data["userName"] = name
|
||||
data["setting"] = authser.BuildSettingVM(user)
|
||||
data["userID"] = user.UserID
|
||||
data["userId"] = fmt.Sprint(user.UserID)
|
||||
data["balance"] = fmt.Sprint(user.Recharge)
|
||||
data["recharge"] = user.Recharge
|
||||
data["rechargeText"] = authser.FormatRecharge(user.Recharge)
|
||||
if avatar := safeProfileImage(user.Avatar); avatar != "" {
|
||||
data["avatar"] = avatar
|
||||
}
|
||||
}
|
||||
|
||||
func safeProfileImage(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(raw, "/static/") {
|
||||
return raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func profileHistoryWorks(items []playletser.PlayletHistoryData) []profileWork {
|
||||
works := make([]profileWork, 0, len(items))
|
||||
for _, item := range items {
|
||||
if len(works) == 6 {
|
||||
break
|
||||
}
|
||||
name := strings.TrimSpace(item.Title)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(item.PlayName)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
progress := 0
|
||||
if item.TotalEpisode > 0 {
|
||||
progress = item.WatchedCount * 100 / item.TotalEpisode
|
||||
}
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
if progress > 100 {
|
||||
progress = 100
|
||||
}
|
||||
meta := "观看记录"
|
||||
if item.LastEpisode > 0 {
|
||||
meta = fmt.Sprintf("看至第 %d 集", item.LastEpisode)
|
||||
}
|
||||
remaining := ""
|
||||
if item.UnwatchedCount > 0 {
|
||||
remaining = fmt.Sprintf("剩 %d 集", item.UnwatchedCount)
|
||||
}
|
||||
works = append(works, profileWork{Title: name, Meta: meta, Image: profileCover(item.Cover), Progress: fmt.Sprintf("%d%%", progress), Remaining: remaining, URL: profilePlayURL(item.Slug, item.ID, item.LastEpisode)})
|
||||
}
|
||||
return works
|
||||
}
|
||||
|
||||
func profileFavoriteWorks(items []playletser.PlayletCollectData) []profileWork {
|
||||
works := make([]profileWork, 0, len(items))
|
||||
for _, item := range items {
|
||||
if len(works) == 6 {
|
||||
break
|
||||
}
|
||||
name := strings.TrimSpace(item.Title)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(item.PlayName)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
work := profileWork{Title: name, Meta: strings.Join(item.ThemeNames, " "), Image: profileCover(item.Cover), URL: profilePlayURL(item.Slug, item.ID, 1)}
|
||||
if item.TotalEpisode > 0 {
|
||||
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
|
||||
}
|
||||
if item.IsFeatured {
|
||||
work.Badge = "爆剧"
|
||||
}
|
||||
works = append(works, work)
|
||||
}
|
||||
return works
|
||||
}
|
||||
|
||||
func profileCover(raw string) string {
|
||||
if safe := safeProfileImage(raw); safe != "" {
|
||||
return safe
|
||||
}
|
||||
return "/static/images/placeholder.png?v=2"
|
||||
}
|
||||
|
||||
func profilePlayURL(slug string, id int64, episode int) string {
|
||||
if slug == "" {
|
||||
if id <= 0 {
|
||||
return ""
|
||||
}
|
||||
slug = fmt.Sprint(id)
|
||||
}
|
||||
if episode < 1 {
|
||||
episode = 1
|
||||
}
|
||||
return "/play/" + url.PathEscape(slug) + "/" + fmt.Sprint(episode)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type purchasedWork struct {
|
||||
Title, Image, Info, Date, Progress, URL string
|
||||
}
|
||||
|
||||
// PurchasedSeries 已购剧集:GET /api/app/playlet/bought/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_bought_list
|
||||
func PurchasedSeries(c *gin.Context) {
|
||||
page, validPage := accountPageNum(c)
|
||||
if !validPage {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{"title": "已购剧集 - 黄果短剧", "navActive": "purchased", "accountTab": "purchased"}
|
||||
responses := gin.H{}
|
||||
loadAccountSummary(c, data, responses)
|
||||
|
||||
size := 20
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
responses["playlet/bought/list"] = gin.H{"error": "暂无登录会话"}
|
||||
data["purchasedResponse"] = responses["playlet/bought/list"]
|
||||
HTMLOK(c, "purchased.html", data)
|
||||
return
|
||||
}
|
||||
|
||||
list, code, err := playletser.FetchBoughtList(c, page, size)
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
list, _, err = playletser.FetchBoughtList(c, page, size)
|
||||
}
|
||||
if err == nil && list != nil {
|
||||
responses["playlet/bought/list"] = gin.H{"code": 200, "data": list}
|
||||
data["paging"] = makeAccountPage("/purchased", page, size, list.TotalCount)
|
||||
works := make([]purchasedWork, 0, len(list.List))
|
||||
for _, item := range list.List {
|
||||
title := strings.TrimSpace(item.PlayName)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
info := fmt.Sprintf("已购 %d 集", item.BoughtChapterCount)
|
||||
if item.TotalEpisode > 0 {
|
||||
info += fmt.Sprintf(" / 共 %d 集", item.TotalEpisode)
|
||||
}
|
||||
info += fmt.Sprintf(" · %d 金币", item.CostCoins)
|
||||
progress := 0
|
||||
if item.TotalEpisode > 0 {
|
||||
progress = item.WatchedCount * 100 / item.TotalEpisode
|
||||
}
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
if progress > 100 {
|
||||
progress = 100
|
||||
}
|
||||
works = append(works, purchasedWork{
|
||||
Title: title, Image: profileCover(item.Cover), Info: info,
|
||||
Date: playletser.FormatHistoryAt(item.BoughtAt), Progress: fmt.Sprintf("%d%%", progress),
|
||||
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
|
||||
})
|
||||
}
|
||||
data["purchased"] = works
|
||||
} else if err != nil {
|
||||
responses["playlet/bought/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
|
||||
log.Error("fetch bought playlets failed", log.E(err))
|
||||
data["purchasedUnavailable"] = true
|
||||
} else {
|
||||
responses["playlet/bought/list"] = gin.H{"code": code, "data": list}
|
||||
}
|
||||
|
||||
data["purchasedResponse"] = responses["playlet/bought/list"]
|
||||
HTMLOK(c, "purchased.html", data)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Rank 排行榜页:GET /api/app/playlet/rank。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_rank
|
||||
func Rank(c *gin.Context) {
|
||||
q := playletser.ParseRankQuery(c)
|
||||
raw := playletser.FetchRankOrEmpty(c, q)
|
||||
tabTitle := playletser.RankTabTitle(q.SortOrder)
|
||||
title := tabTitle + " - 黄果短剧"
|
||||
if raw != nil && raw.SeoInfo.Title != "" {
|
||||
title = raw.SeoInfo.Title
|
||||
}
|
||||
|
||||
list := []playletser.CommonData(nil)
|
||||
total := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var seo playletser.SeoInfo
|
||||
if raw != nil {
|
||||
list = raw.List
|
||||
total = raw.TotalCount
|
||||
seo = raw.SeoInfo
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
}
|
||||
|
||||
HTMLOK(c, "rank.html", gin.H{
|
||||
"title": title,
|
||||
"navActive": "rank",
|
||||
"seoInfo": seo,
|
||||
"seoSelections": rankingSEOSelections(tabTitle),
|
||||
"list": list,
|
||||
"totalCount": total,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"sortOrder": q.SortOrder,
|
||||
"tabTitle": tabTitle,
|
||||
"rankTabs": playletser.BuildRankTabs(q),
|
||||
"rankOffset": playletser.RankOffset(q),
|
||||
"isBinge": playletser.IsRankBinge(q.SortOrder),
|
||||
"pagination": playletser.BuildRankPagination(q, total),
|
||||
"canonicalPath": playletser.RankCanonicalPath(q),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RecentlyAdded 最近上新页:GET /api/app/playlet/today-update。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_today_update
|
||||
func RecentlyAdded(c *gin.Context) {
|
||||
q := playletser.ParseTodayUpdateQuery(c.Param("page"))
|
||||
raw := playletser.FetchTodayUpdateOrEmpty(c, q)
|
||||
title := "最近上新 - 黄果短剧"
|
||||
if raw != nil && raw.SeoInfo.Title != "" {
|
||||
title = raw.SeoInfo.Title
|
||||
}
|
||||
|
||||
list := []playletser.CommonData(nil)
|
||||
total := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var seo playletser.SeoInfo
|
||||
if raw != nil {
|
||||
list = raw.List
|
||||
total = raw.TotalCount
|
||||
seo = raw.SeoInfo
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
}
|
||||
|
||||
HTMLOK(c, "recentlyAdded.html", gin.H{
|
||||
"title": title,
|
||||
"navActive": "todayUpdate",
|
||||
"seoInfo": seo,
|
||||
"list": list,
|
||||
"totalCount": total,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"listCount": fmt.Sprintf("共 %d 部", total),
|
||||
"pagination": playletser.BuildTodayUpdatePagination(q, total),
|
||||
"canonicalPath": playletser.TodayUpdateCanonicalPath(q),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/rechargeser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const rechargePageSize = 5
|
||||
|
||||
// Recharge 金币充值页:套餐 GET /api/app/recharge/package/list,购买 POST /api/app/coin/buy,流水 GET /api/app/coin/trans/list。
|
||||
func Recharge(c *gin.Context) {
|
||||
data := gin.H{
|
||||
"title": "金币充值 - 黄果短剧", "pageHeading": "金币充值",
|
||||
"backHref": "/profile", "helpHref": "/help",
|
||||
}
|
||||
responses := gin.H{}
|
||||
loadAccountSummary(c, data, responses)
|
||||
|
||||
packages, err := rechargeser.FetchPackageList(c)
|
||||
if err != nil {
|
||||
log.Error("fetch recharge packages failed", log.E(err))
|
||||
data["packageUnavailable"] = true
|
||||
packages = &rechargeser.RechargePackageListResp{}
|
||||
responses["recharge/package/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
|
||||
} else {
|
||||
responses["recharge/package/list"] = gin.H{"code": 200, "data": packages}
|
||||
}
|
||||
vm := rechargeser.BuildDepositPageVM(packages, "coin")
|
||||
data["packages"] = vm.CoinList
|
||||
data["paymentMethods"] = vm.PayMethods
|
||||
data["rechargePackageResponse"] = packages
|
||||
|
||||
balance := authser.RechargeFromCookies(c)
|
||||
if _, ok := data["recharge"]; ok {
|
||||
balance = toInt64(data["recharge"])
|
||||
}
|
||||
data["balance"] = balance
|
||||
data["balanceText"] = authser.FormatRecharge(balance)
|
||||
|
||||
page, _, err := fetchRechargeTransactions(c, 1, rechargePageSize, 0)
|
||||
if err != nil {
|
||||
log.Error("fetch recharge transactions failed", log.E(err))
|
||||
data["transactionsUnavailable"] = true
|
||||
page = &rechargeser.CoinTransPage{PageNum: 1, PageSize: rechargePageSize}
|
||||
responses["coin/trans/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
|
||||
} else {
|
||||
responses["coin/trans/list"] = gin.H{"code": 200, "data": page}
|
||||
}
|
||||
data["transactions"] = rechargeser.BuildTxListFromCoinTrans(page.List, 0)
|
||||
data["transactionPage"] = page
|
||||
data["transactionResponse"] = page
|
||||
data["rechargeDebug"] = responses
|
||||
HTMLOK(c, "recharge.html", data)
|
||||
}
|
||||
|
||||
// RechargeCoinBuy BFF:代理 POST /api/app/coin/buy。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/post_api_app_coin_buy
|
||||
func RechargeCoinBuy(c *gin.Context) {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
|
||||
return
|
||||
}
|
||||
var body rechargeser.BuyPayReq
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.PackageID <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "packageId required", "tip": "请选择充值套餐", "data": nil})
|
||||
return
|
||||
}
|
||||
body.PayMode = rechargeser.NormalizePayMode(body.PayMode)
|
||||
if body.PayMode == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "payMode required", "tip": "请选择支付方式", "data": nil})
|
||||
return
|
||||
}
|
||||
if !validCoinPackagePayment(c, body.PackageID, body.PayMode) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "invalid package/payMode", "tip": "套餐或支付方式不可用,请刷新后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := rechargeser.BuyCoin(c, body)
|
||||
if err != nil {
|
||||
log.Error("buy coin failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "创建支付订单失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
if env != nil {
|
||||
if retry, needLogin := authser.OnSessionExpired(c, env.Code); needLogin {
|
||||
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": "登录已过期,请重新登录", "data": nil})
|
||||
return
|
||||
} else if retry {
|
||||
env, err = rechargeser.BuyCoin(c, body)
|
||||
if err != nil {
|
||||
log.Error("buy coin retry failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "创建支付订单失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if env == nil || env.Code != 200 {
|
||||
code, tip, msg := 500, "创建支付订单失败", "buy failed"
|
||||
if env != nil {
|
||||
code = env.Code
|
||||
msg = env.Msg
|
||||
tip = firstNonEmptyAuth(env.Tip, env.Msg, tip)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": code, "msg": msg, "tip": tip, "data": nil})
|
||||
return
|
||||
}
|
||||
safeURL := safePaymentURL(env.Data.PayURL)
|
||||
if safeURL == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "invalid payUrl", "tip": "支付地址无效,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
env.Data.PayURL = safeURL
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": env.Msg,
|
||||
"tip": firstNonEmptyAuth(env.Tip, "订单创建成功"),
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// RechargeTransactions BFF:代理 GET /api/app/coin/trans/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/get_api_app_coin_trans_list
|
||||
// query:page / pageSize / type(0全部 1充值 2消费 3奖励)
|
||||
func RechargeTransactions(c *gin.Context) {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
|
||||
return
|
||||
}
|
||||
page := queryInt(c, "page", 1)
|
||||
pageSize := queryInt(c, "pageSize", rechargePageSize)
|
||||
typ := queryIntAllowZero(c, "type", 0)
|
||||
if typ < 0 || typ > 3 {
|
||||
typ = 0
|
||||
}
|
||||
result, code, err := fetchRechargeTransactions(c, page, pageSize, typ)
|
||||
if err != nil {
|
||||
log.Error("fetch recharge transactions failed", log.E(err))
|
||||
tip := "交易明细加载失败"
|
||||
if code > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"code": code, "msg": err.Error(), "tip": tip, "data": nil})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": tip, "data": nil})
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
result = &rechargeser.CoinTransPage{PageNum: page, PageSize: pageSize}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"tip": "",
|
||||
"data": gin.H{
|
||||
"list": rechargeser.BuildTxListFromCoinTrans(result.List, 0),
|
||||
"rawList": result.List,
|
||||
"pageNum": result.PageNum,
|
||||
"pageSize": result.PageSize,
|
||||
"totalCount": result.TotalCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RechargeCoinTransExport BFF:代理 GET /api/app/coin/trans/export。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/get_api_app_coin_trans_export
|
||||
// query:type(0全部 1充值 2消费 3奖励),返回 CSV。
|
||||
func RechargeCoinTransExport(c *gin.Context) {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
|
||||
return
|
||||
}
|
||||
typ := queryIntAllowZero(c, "type", 0)
|
||||
if typ < 0 || typ > 3 {
|
||||
typ = 0
|
||||
}
|
||||
result, err := rechargeser.FetchCoinTransExport(c, typ)
|
||||
if err != nil {
|
||||
log.Error("export coin trans failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "导出失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
ct := strings.TrimSpace(result.ContentType)
|
||||
if ct == "" || strings.Contains(strings.ToLower(ct), "json") {
|
||||
ct = "text/csv; charset=utf-8"
|
||||
}
|
||||
disposition := strings.TrimSpace(result.ContentDisposition)
|
||||
if disposition == "" {
|
||||
disposition = `attachment; filename="coin-trans.csv"`
|
||||
}
|
||||
c.Header("Content-Type", ct)
|
||||
c.Header("Content-Disposition", disposition)
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Data(http.StatusOK, ct, result.Body)
|
||||
}
|
||||
|
||||
func fetchRechargeTransactions(c *gin.Context, page, pageSize, typ int) (*rechargeser.CoinTransPage, int, error) {
|
||||
result, code, err := rechargeser.FetchCoinTransPage(c, page, pageSize, typ)
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
result, code, err = rechargeser.FetchCoinTransPage(c, page, pageSize, typ)
|
||||
}
|
||||
return result, code, err
|
||||
}
|
||||
|
||||
// attachRechargeModal 注入充值弹窗数据:GET /api/app/recharge/package/list → coinList。
|
||||
func attachRechargeModal(c *gin.Context, data gin.H) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
packages := rechargeser.FetchPackageListOrEmpty(c)
|
||||
vm := rechargeser.BuildDepositPageVM(packages, "coin")
|
||||
data["rechargePackages"] = vm.CoinList
|
||||
data["rechargePayMethods"] = vm.PayMethods
|
||||
data["rechargePackageResponse"] = packages
|
||||
if _, ok := data["unlockCost"]; !ok {
|
||||
data["unlockCost"] = int64(0)
|
||||
}
|
||||
if _, ok := data["balance"]; !ok {
|
||||
data["balance"] = authser.RechargeFromCookies(c)
|
||||
}
|
||||
if _, ok := data["balanceText"]; !ok {
|
||||
data["balanceText"] = authser.FormatRecharge(toInt64(data["balance"]))
|
||||
}
|
||||
}
|
||||
|
||||
func validCoinPackagePayment(c *gin.Context, packageID int64, payMode string) bool {
|
||||
data, err := rechargeser.FetchPackageList(c)
|
||||
if err != nil || data == nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range data.CoinList {
|
||||
if item.ID != packageID {
|
||||
continue
|
||||
}
|
||||
for _, method := range item.PayMethods {
|
||||
if rechargeser.NormalizePayMode(method.Type) == payMode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func safePaymentURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func queryInt(c *gin.Context, name string, fallback int) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(c.Query(name)))
|
||||
if err != nil || v <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// queryIntAllowZero 允许 0(如 type=0 全部)。
|
||||
func queryIntAllowZero(c *gin.Context, name string, fallback int) int {
|
||||
raw := strings.TrimSpace(c.Query(name))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package web
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// RegisterPage H5 注册页:渲染 template/pages/register.html。
|
||||
// PC 端由页面内脚本回首页并打开 registerPop;H5 直接展示本页。
|
||||
func RegisterPage(c *gin.Context) {
|
||||
HTMLOK(c, "register.html", gin.H{
|
||||
"title": "注册账号 - 黄果短剧",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/seoser"
|
||||
"videoweb/app/service/translationser"
|
||||
"videoweb/app/service/videoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HTML 渲染页面并注入登录态(以 cookie 为准)。
|
||||
// 无 token 时自动游客登录(POST /api/app/auth/guest),保证默认进入即有会话。
|
||||
func HTML(c *gin.Context, code int, name string, data gin.H) {
|
||||
if data == nil {
|
||||
data = gin.H{}
|
||||
}
|
||||
|
||||
// 所有服务端页面先完成渠道首次归因;已有会话也不会漏记落地页渠道。
|
||||
if channel := authser.ResolveChannel(c, ""); channel != "" {
|
||||
data["channel"] = channel
|
||||
}
|
||||
authser.SyncUserOnEnter(c)
|
||||
|
||||
loggedIn, userName, avatar, email := authser.AuthFromCookies(c)
|
||||
registered := authser.IsRegisteredFromCookies(c)
|
||||
data["isLoggedIn"] = loggedIn
|
||||
data["isRegistered"] = registered
|
||||
// 已登录(含游客)展示用户菜单;未登录才展示「登录」按钮。
|
||||
if _, ok := data["guestHeader"]; !ok {
|
||||
data["guestHeader"] = !loggedIn
|
||||
}
|
||||
if _, ok := data["isVip"]; !ok {
|
||||
data["isVip"] = loggedIn && authser.IsActiveVipFromCookies(c)
|
||||
}
|
||||
isVip, _ := data["isVip"].(bool)
|
||||
data["isVip"] = isVip
|
||||
|
||||
if loggedIn {
|
||||
if _, provided := data["userName"]; !provided && userName != "" {
|
||||
data["userName"] = userName
|
||||
}
|
||||
if _, provided := data["avatar"]; !provided && avatar != "" {
|
||||
data["avatar"] = avatar
|
||||
}
|
||||
if email != "" {
|
||||
data["email"] = email
|
||||
}
|
||||
if _, ok := data["userId"]; !ok {
|
||||
if uid := authser.UserIDFromCookies(c); uid > 0 {
|
||||
data["userId"] = authser.FormatUserID(uid)
|
||||
}
|
||||
}
|
||||
if _, ok := data["recharge"]; !ok {
|
||||
recharge := authser.RechargeFromCookies(c)
|
||||
data["recharge"] = recharge
|
||||
data["rechargeText"] = authser.FormatRecharge(recharge)
|
||||
} else if _, ok := data["rechargeText"]; !ok {
|
||||
data["rechargeText"] = authser.FormatRecharge(toInt64(data["recharge"]))
|
||||
}
|
||||
if _, ok := data["vipType"]; !ok {
|
||||
data["vipType"] = authser.VipTypeFromCookies(c)
|
||||
}
|
||||
} else {
|
||||
delete(data, "userName")
|
||||
delete(data, "avatar")
|
||||
delete(data, "email")
|
||||
delete(data, "userId")
|
||||
delete(data, "recharge")
|
||||
delete(data, "rechargeText")
|
||||
delete(data, "vipType")
|
||||
}
|
||||
|
||||
// 导航分类:GET /api/app/translation/category/list → categoryList(包含分类 seoInfo)
|
||||
if _, ok := data["categoryList"]; !ok {
|
||||
data["categoryList"] = translationser.FetchHeaderCategoryListOrDefault(c)
|
||||
}
|
||||
if _, ok := data["requestPath"]; !ok {
|
||||
data["requestPath"] = c.Request.URL.Path
|
||||
}
|
||||
// 抽屉题材:GET /api/app/theme/list
|
||||
if _, ok := data["themeList"]; !ok {
|
||||
data["themeList"] = playletser.FetchThemesOrEmpty(c, 0)
|
||||
}
|
||||
// 顶栏短剧下拉快捷入口:homeConfigData type=1
|
||||
if _, ok := data["homeConfigPlaylets"]; !ok {
|
||||
if configs, ok := data["homeConfigData"].([]playletser.HomeConfigData); ok {
|
||||
data["homeConfigPlaylets"] = playletser.HomeConfigsByType(configs, 1)
|
||||
} else {
|
||||
home := playletser.FetchHomeOrEmpty(c)
|
||||
data["homeConfigPlaylets"] = playletser.HomeConfigsByType(home.HomeConfigData, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// PC 搜索下拉热搜:GET /api/app/playlet/hot-like
|
||||
if _, ok := data["searchTrends"]; !ok {
|
||||
trends := hotSearchTrends(playletser.FetchHotLikeOrEmpty(c).HotSearch)
|
||||
if len(trends) > 8 {
|
||||
trends = trends[:8]
|
||||
}
|
||||
data["searchTrends"] = trends
|
||||
}
|
||||
|
||||
// 公共配置:GET /api/app/config/info → config / appConfig
|
||||
if _, ok := data["appConfig"]; !ok {
|
||||
vm := configser.FetchAppConfigVMOrEmpty(c)
|
||||
data["appConfig"] = vm
|
||||
if _, ok := data["config"]; !ok {
|
||||
data["config"] = vm.Config
|
||||
}
|
||||
} else if _, ok := data["config"]; !ok {
|
||||
if vm, ok := data["appConfig"].(configser.AppConfigVM); ok {
|
||||
data["config"] = vm.Config
|
||||
}
|
||||
}
|
||||
|
||||
data["trackerAppId"] = configser.ResolveTrackerAppID()
|
||||
attachTrackerPage(c.Request.URL.Path, code, data)
|
||||
|
||||
attachPageSEO(c, code, data)
|
||||
c.HTML(code, name, data)
|
||||
}
|
||||
|
||||
// HTMLOK 以 200 渲染页面。
|
||||
func HTMLOK(c *gin.Context, name string, data gin.H) {
|
||||
if pageRepresentsMissingResource(c, data) {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
HTML(c, http.StatusOK, name, data)
|
||||
}
|
||||
|
||||
func toInt64(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case int32:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
case string:
|
||||
i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
|
||||
return i
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// attachPageSEO 在一个入口统一生成页面级 SEO 信号,避免各模板各自维护后产生冲突。
|
||||
func attachPageSEO(c *gin.Context, code int, data gin.H) {
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cfg, ok := data["appConfig"].(configser.AppConfigVM)
|
||||
if !ok {
|
||||
cfg = configser.FetchAppConfigVMOrEmpty(c)
|
||||
data["appConfig"] = cfg
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
pageTitle, _ := data["title"].(string)
|
||||
pageTitle = strings.TrimSpace(pageTitle)
|
||||
|
||||
seoInfo := pageSEOFromData(data["seoInfo"])
|
||||
siteName := firstNonEmptySEO(seoInfo.WebSiteName, cfg.SeoInfo.WebSiteName, "黄果短剧")
|
||||
routeConfigSEO, _ := data["routeConfigSEO"].(bool)
|
||||
if !routeConfigSEO {
|
||||
seoInfo = replaceSitewideSEOWithRouteSEO(path, data, seoInfo, pageSEOFromData(cfg.SeoInfo), siteName)
|
||||
}
|
||||
navigationSEOApplied := false
|
||||
if categories, ok := data["categoryList"].([]translationser.TranslationCategory); ok {
|
||||
headerNav := translationser.BuildHeaderNav(&translationser.AppTranslationInfo{CategoryList: categories})
|
||||
activeKey, _ := data["navActive"].(string)
|
||||
if navSEO, found := translationser.FindHeaderNavSEOByPath(headerNav, path); found {
|
||||
// 与 jha-1040-cgdj-web 一致:导航分类页面以分类接口 TDK 为主,页面接口只补缺。
|
||||
seoInfo = playletser.MergeSeoInfo(playletser.SeoInfo{
|
||||
Title: navSEO.Title,
|
||||
Description: navSEO.Description,
|
||||
Keywords: navSEO.Keyworld,
|
||||
}, seoInfo)
|
||||
navigationSEOApplied = true
|
||||
} else if !routeConfigSEO {
|
||||
// 当前项目的详情/播放页会复用 navActive;这类页面保持内容 TDK 为主,导航只补缺。
|
||||
if navSEO, found := translationser.FindHeaderNavSEO(headerNav, activeKey, path); found {
|
||||
seoInfo = playletser.MergeSeoInfo(seoInfo, playletser.SeoInfo{
|
||||
Title: navSEO.Title,
|
||||
Description: navSEO.Description,
|
||||
Keywords: navSEO.Keyworld,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if selections, ok := data["seoSelections"].([]playletser.SEOSelection); ok && len(selections) > 0 {
|
||||
seoInfo = playletser.DifferentiateSeoInfo(seoInfo, selections)
|
||||
}
|
||||
if seoInfo.Title == "" {
|
||||
seoInfo.Title = firstNonEmptySEO(pageTitle, defaultPageTitle(path, siteName))
|
||||
}
|
||||
if seoInfo.Description == "" {
|
||||
seoInfo.Description = defaultMetaDescription(path, seoInfo.Title, data, siteName)
|
||||
}
|
||||
if len(seoInfo.Keywords) == 0 {
|
||||
seoInfo.Keywords = defaultMetaKeywords(path, siteName)
|
||||
}
|
||||
if pageNum := paginationPage(data["pagination"]); pageNum > 1 {
|
||||
seoInfo = playletser.PaginateSeoInfo(seoInfo, seoInfo.Description, pageNum)
|
||||
}
|
||||
metaTitle := seoInfo.Title
|
||||
if !navigationSEOApplied {
|
||||
metaTitle = ensureBrandTitle(metaTitle, siteName)
|
||||
}
|
||||
data["title"] = metaTitle
|
||||
data["metaTitle"] = metaTitle
|
||||
data["metaDescription"] = seoInfo.Description
|
||||
data["metaKeywords"] = strings.Join(seoInfo.Keywords, ",")
|
||||
data["siteName"] = siteName
|
||||
data["seoInfo"] = seoInfo
|
||||
|
||||
siteURL := seoser.ResolveSiteURL(c, cfg)
|
||||
data["siteURL"] = siteURL
|
||||
data["foreverUrlAddr"] = siteURL
|
||||
data["seoVerify"] = seoser.VerifyFromConfig()
|
||||
data["ogType"] = pageOGType(path)
|
||||
data["ogImage"] = pageOGImage(siteURL, path, data)
|
||||
attachPaginationLinks(data, siteURL)
|
||||
|
||||
robots, _ := data["robots"].(string)
|
||||
robots = strings.TrimSpace(robots)
|
||||
if robots == "" {
|
||||
robots = defaultRobotsForPath(path, code)
|
||||
}
|
||||
if !robotsHasNoIndex(robots) && pageShouldNotIndex(c, data) {
|
||||
robots = "noindex,follow"
|
||||
}
|
||||
data["robots"] = robots
|
||||
noindex := robotsHasNoIndex(robots)
|
||||
if noindex {
|
||||
data["noindex"] = true
|
||||
c.Header("X-Robots-Tag", strings.ReplaceAll(robots, ",", ", "))
|
||||
} else {
|
||||
delete(data, "noindex")
|
||||
}
|
||||
|
||||
canonicalEligible := code >= http.StatusOK && code < http.StatusMultipleChoices && !noindex && seoser.IsIndexablePath(path)
|
||||
if canonicalEligible {
|
||||
canonicalURL := seoser.CanonicalWithoutQuery(seoser.PageCanonicalURL(siteURL, cfg.ForeverURLAddr, c))
|
||||
if canonicalPath, _ := data["canonicalPath"].(string); strings.HasPrefix(strings.TrimSpace(canonicalPath), "/") {
|
||||
canonicalURL = seoser.CanonicalWithoutQuery(seoser.AbsURL(siteURL, strings.TrimSpace(canonicalPath)))
|
||||
}
|
||||
canonicalURL = seoser.CanonicalWithoutQuery(seoser.ContentCanonicalURL(siteURL, canonicalURL, map[string]any(data)))
|
||||
data["canonicalURL"] = canonicalURL
|
||||
data["jsonLD"] = seoser.PageJSONLD(siteURL, canonicalURL, c.Request.URL.RequestURI(), map[string]any(data), cfg)
|
||||
} else {
|
||||
delete(data, "canonicalURL")
|
||||
delete(data, "jsonLD")
|
||||
}
|
||||
}
|
||||
|
||||
func pageShouldNotIndex(c *gin.Context, data gin.H) bool {
|
||||
if isNavPage, _ := data["isNavPage"].(bool); isNavPage {
|
||||
return true
|
||||
}
|
||||
if c != nil && c.Request != nil {
|
||||
path := c.Request.URL.Path
|
||||
if isEpisodePlayPath(path, c.Param("ep")) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/series-play/") {
|
||||
if canWatch, ok := data["canWatch"].(bool); ok && !canWatch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return pageRepresentsMissingResource(c, data)
|
||||
}
|
||||
|
||||
// pageRepresentsMissingResource 识别不存在的实体、非法分页与越界分页。
|
||||
// 这些请求必须返回真实 404,不能仅依赖 noindex 掩盖 200 软 404。
|
||||
func pageRepresentsMissingResource(c *gin.Context, data gin.H) bool {
|
||||
if valid, exists := data["seoEntityValid"].(bool); exists && !valid {
|
||||
return true
|
||||
}
|
||||
pageNum := int(toInt64(data["pageNum"]))
|
||||
if c != nil {
|
||||
if rawPage := strings.TrimSpace(c.Param("page")); rawPage != "" {
|
||||
requestedPage, err := strconv.Atoi(rawPage)
|
||||
// 分页二级路由只允许第 2 页及以后;第 1 页使用更短的规范地址。
|
||||
if err != nil || requestedPage <= 1 {
|
||||
return true
|
||||
}
|
||||
pageNum = requestedPage
|
||||
}
|
||||
}
|
||||
pageSize := int(toInt64(data["pageSize"]))
|
||||
totalCount, hasTotal := data["totalCount"]
|
||||
return hasTotal && playletser.PageOutOfRange(pageNum, pageSize, toInt64(totalCount))
|
||||
}
|
||||
|
||||
func pageSEOFromData(value any) playletser.SeoInfo {
|
||||
var seo playletser.SeoInfo
|
||||
switch value := value.(type) {
|
||||
case playletser.SeoInfo:
|
||||
seo = value
|
||||
case *playletser.SeoInfo:
|
||||
if value != nil {
|
||||
seo = *value
|
||||
}
|
||||
case videoser.SeoInfo:
|
||||
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
|
||||
case *videoser.SeoInfo:
|
||||
if value != nil {
|
||||
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
|
||||
}
|
||||
case configser.SeoInfo:
|
||||
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
|
||||
case *configser.SeoInfo:
|
||||
if value != nil {
|
||||
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
|
||||
}
|
||||
}
|
||||
seo.Normalize()
|
||||
return seo
|
||||
}
|
||||
|
||||
// replaceSitewideSEOWithRouteSEO 只替换接口误复用的全站首页 TDK。
|
||||
// 页面接口真正下发的独立 SEO 仍保持最高优先级。
|
||||
func replaceSitewideSEOWithRouteSEO(path string, data gin.H, seo, siteDefault playletser.SeoInfo, siteName string) playletser.SeoInfo {
|
||||
routeSEO, ok := routeSpecificSEO(path, data, siteName)
|
||||
if !ok {
|
||||
return seo
|
||||
}
|
||||
if sameNonEmptySEOValue(seo.Title, siteDefault.Title) {
|
||||
seo.Title = routeSEO.Title
|
||||
}
|
||||
if sameNonEmptySEOValue(seo.Description, siteDefault.Description) {
|
||||
seo.Description = routeSEO.Description
|
||||
}
|
||||
if sameSEOKeywords(seo.Keywords, siteDefault.Keywords) {
|
||||
seo.Keywords = append([]string(nil), routeSEO.Keywords...)
|
||||
}
|
||||
seo.Normalize()
|
||||
return seo
|
||||
}
|
||||
|
||||
func isEpisodePlayPath(path, epParam string) bool {
|
||||
if strings.TrimSpace(epParam) != "" {
|
||||
return true
|
||||
}
|
||||
segments := strings.Split(strings.Trim(path, "/"), "/")
|
||||
return len(segments) >= 3 && segments[0] == "play" && segments[1] != "" && segments[2] != ""
|
||||
}
|
||||
|
||||
func routeSpecificSEO(path string, data gin.H, siteName string) (playletser.SeoInfo, bool) {
|
||||
path = strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
title := ""
|
||||
switch {
|
||||
case path == "/list" || strings.HasPrefix(path, "/list/"):
|
||||
title = firstNonEmptySEO(dataString(data, "pageHeading"), "全部短剧")
|
||||
case path == "/series" || strings.HasPrefix(path, "/series/"):
|
||||
title = firstNonEmptySEO(dataString(data, "pageHeading"), "剧集与动画")
|
||||
case path == "/topic" || strings.HasPrefix(path, "/topic/"):
|
||||
title = "专题剧单"
|
||||
case strings.HasPrefix(path, "/topic-detail/"):
|
||||
if name := dataString(data, "name"); name != "" {
|
||||
title = name + "专题短剧"
|
||||
} else {
|
||||
title = "短剧专题"
|
||||
}
|
||||
case strings.HasPrefix(path, "/tags/"):
|
||||
if name := dataString(data, "tagName"); name != "" {
|
||||
title = name + "标签短剧"
|
||||
} else {
|
||||
title = "标签短剧"
|
||||
}
|
||||
case strings.HasPrefix(path, "/more-list/"):
|
||||
title = firstNonEmptySEO(dataString(data, "configName"), "精选短剧")
|
||||
case strings.HasPrefix(path, "/more-series-list/"):
|
||||
title = firstNonEmptySEO(dataString(data, "configName"), "精选剧集")
|
||||
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
|
||||
title = "短剧排行榜"
|
||||
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
|
||||
title = "热门短剧推荐"
|
||||
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
|
||||
title = "最近上新短剧"
|
||||
default:
|
||||
return playletser.SeoInfo{}, false
|
||||
}
|
||||
return playletser.SeoInfo{
|
||||
Title: title,
|
||||
Description: defaultMetaDescription(path, title, data, siteName),
|
||||
Keywords: defaultMetaKeywords(path, siteName),
|
||||
}, true
|
||||
}
|
||||
|
||||
func dataString(data gin.H, key string) string {
|
||||
value, _ := data[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func sameNonEmptySEOValue(value, generic string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
generic = strings.TrimSpace(generic)
|
||||
return generic != "" && strings.EqualFold(value, generic)
|
||||
}
|
||||
|
||||
func sameSEOKeywords(value, generic []string) bool {
|
||||
if len(generic) == 0 || len(value) != len(generic) {
|
||||
return false
|
||||
}
|
||||
for i := range value {
|
||||
if !strings.EqualFold(strings.TrimSpace(value[i]), strings.TrimSpace(generic[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func paginationPage(value any) int {
|
||||
switch value := value.(type) {
|
||||
case playletser.PaginationVM:
|
||||
return value.Page
|
||||
case *playletser.PaginationVM:
|
||||
if value != nil {
|
||||
return value.Page
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func attachPaginationLinks(data gin.H, siteURL string) {
|
||||
var pagination playletser.PaginationVM
|
||||
switch value := data["pagination"].(type) {
|
||||
case playletser.PaginationVM:
|
||||
pagination = value
|
||||
case *playletser.PaginationVM:
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
pagination = *value
|
||||
default:
|
||||
return
|
||||
}
|
||||
if !pagination.Visible {
|
||||
return
|
||||
}
|
||||
if pagination.HasPrev && strings.TrimSpace(pagination.PrevURL) != "" {
|
||||
data["paginationPrevURL"] = seoser.AbsURL(siteURL, pagination.PrevURL)
|
||||
}
|
||||
if pagination.HasNext && strings.TrimSpace(pagination.NextURL) != "" {
|
||||
data["paginationNextURL"] = seoser.AbsURL(siteURL, pagination.NextURL)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRobotsForPath(path string, code int) string {
|
||||
if code >= http.StatusBadRequest {
|
||||
return "noindex,follow"
|
||||
}
|
||||
if seoser.IsIndexablePath(path) {
|
||||
return "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1"
|
||||
}
|
||||
if seoser.IsPrivatePath(path) {
|
||||
return "noindex,nofollow"
|
||||
}
|
||||
return "noindex,follow"
|
||||
}
|
||||
|
||||
func robotsHasNoIndex(robots string) bool {
|
||||
for _, directive := range strings.Split(strings.ToLower(robots), ",") {
|
||||
if strings.TrimSpace(directive) == "noindex" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultPageTitle(path, siteName string) string {
|
||||
path = strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
if siteName == "" {
|
||||
siteName = "黄果短剧"
|
||||
}
|
||||
switch {
|
||||
case path == "":
|
||||
return siteName + " - 热门短剧在线看"
|
||||
case path == "/about":
|
||||
return "关于我们"
|
||||
case path == "/nav" || path == "/dh":
|
||||
return siteName + " · 官方导航"
|
||||
case path == "/help":
|
||||
return "常见问题"
|
||||
case path == "/terms":
|
||||
return "用户条款"
|
||||
case path == "/privacy":
|
||||
return "隐私政策"
|
||||
case path == "/2257":
|
||||
return "2257 合规声明"
|
||||
case path == "/dmca":
|
||||
return "DMCA 版权声明"
|
||||
case path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile":
|
||||
return "搜索"
|
||||
case path == "/list" || strings.HasPrefix(path, "/list/"):
|
||||
return "全部短剧"
|
||||
case path == "/series" || strings.HasPrefix(path, "/series/"):
|
||||
return "剧集与动画"
|
||||
case path == "/topic" || strings.HasPrefix(path, "/topic/"):
|
||||
return "专题剧单"
|
||||
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
|
||||
return "短剧排行榜"
|
||||
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
|
||||
return "最近上新"
|
||||
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
|
||||
return "热门推荐"
|
||||
case strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/"):
|
||||
return "在线播放"
|
||||
default:
|
||||
return siteName
|
||||
}
|
||||
}
|
||||
|
||||
func defaultMetaDescription(path, title string, data gin.H, siteName string) string {
|
||||
for _, key := range []string{"description", "desc", "heroDesc", "categoryDescription", "tagDescription", "topicDescription"} {
|
||||
if value, _ := data[key].(string); strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
if siteName == "" {
|
||||
siteName = "黄果短剧"
|
||||
}
|
||||
cleanTitle := strings.TrimSpace(strings.TrimSuffix(title, " - "+siteName))
|
||||
path = strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
if isNavPage, _ := data["isNavPage"].(bool); isNavPage {
|
||||
return siteName + "官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。"
|
||||
}
|
||||
switch {
|
||||
case path == "":
|
||||
return siteName + "汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。"
|
||||
case path == "/about":
|
||||
return "了解" + siteName + "的平台理念、内容特色与联系方式。"
|
||||
case path == "/nav" || path == "/dh":
|
||||
return siteName + "官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。"
|
||||
case path == "/help":
|
||||
return "查看" + siteName + "常见问题、播放帮助、账号使用与客服支持信息。"
|
||||
case path == "/terms":
|
||||
return "查看" + siteName + "用户服务条款及平台使用规则。"
|
||||
case path == "/privacy":
|
||||
return "查看" + siteName + "隐私政策与个人信息保护说明。"
|
||||
case path == "/2257":
|
||||
return "查看" + siteName + " 18 U.S.C. 第 2257 章合规声明、年龄限制与内容反馈处理原则。"
|
||||
case path == "/dmca":
|
||||
return "查看" + siteName + " DMCA 版权声明、投诉材料要求与处理流程说明。"
|
||||
case path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile":
|
||||
return "在" + siteName + "搜索剧名、演员、标签与相关作品。"
|
||||
case strings.HasPrefix(path, "/play/"):
|
||||
return "在线观看《" + cleanTitle + "》,查看选集、剧情信息与相关推荐。"
|
||||
case strings.HasPrefix(path, "/series-play/"):
|
||||
return "在线观看《" + cleanTitle + "》,浏览视频信息与相关推荐。"
|
||||
case path == "/list" || strings.HasPrefix(path, "/list/"):
|
||||
return "浏览" + siteName + "全部短剧,按题材、更新状态和热度筛选精彩作品。"
|
||||
case path == "/series" || strings.HasPrefix(path, "/series/"):
|
||||
return "浏览" + siteName + "精选剧集与动画,按类型、专题和热度发现更多内容。"
|
||||
case path == "/topic" || strings.HasPrefix(path, "/topic/") || strings.HasPrefix(path, "/topic-detail/"):
|
||||
return "浏览" + siteName + "精选专题剧单,发现同题材热门作品与持续更新内容。"
|
||||
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
|
||||
return "查看" + siteName + "实时更新的短剧排行榜,浏览热播榜、新剧榜与追剧榜。"
|
||||
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
|
||||
return "浏览" + siteName + "热门推荐,发现近期高热度、广受欢迎的精选短剧。"
|
||||
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
|
||||
return "查看" + siteName + "最近上新的短剧与剧集,及时追更最新内容。"
|
||||
default:
|
||||
return cleanTitle + ",浏览" + siteName + "相关内容与推荐。"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultMetaKeywords(path, siteName string) []string {
|
||||
path = strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
if siteName == "" {
|
||||
siteName = "黄果短剧"
|
||||
}
|
||||
switch {
|
||||
case path == "":
|
||||
return []string{siteName, "短剧", "在线短剧", "热门短剧"}
|
||||
case path == "/list" || strings.HasPrefix(path, "/list/"):
|
||||
return []string{"短剧列表", "短剧分类", "在线短剧", siteName}
|
||||
case path == "/series" || strings.HasPrefix(path, "/series/"):
|
||||
return []string{"剧集", "动画", "在线观看", siteName}
|
||||
case strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/"):
|
||||
return []string{"在线播放", "短剧播放", siteName}
|
||||
case path == "/topic" || strings.HasPrefix(path, "/topic/") || strings.HasPrefix(path, "/topic-detail/"):
|
||||
return []string{"短剧专题", "专题剧单", siteName}
|
||||
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
|
||||
return []string{"短剧排行榜", "热门短剧", siteName}
|
||||
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
|
||||
return []string{"热门短剧", "短剧推荐", siteName}
|
||||
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
|
||||
return []string{"最新短剧", "最近上新", siteName}
|
||||
case strings.HasPrefix(path, "/tags/"):
|
||||
return []string{"标签短剧", "短剧分类", siteName}
|
||||
case strings.HasPrefix(path, "/more-list/"):
|
||||
return []string{"精选短剧", "热门短剧", siteName}
|
||||
case strings.HasPrefix(path, "/more-series-list/"):
|
||||
return []string{"精选剧集", "剧集推荐", siteName}
|
||||
default:
|
||||
return []string{siteName, "在线短剧"}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureBrandTitle(title, siteName string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
siteName = strings.TrimSpace(siteName)
|
||||
if title == "" {
|
||||
return siteName
|
||||
}
|
||||
if siteName == "" || strings.Contains(title, siteName) || strings.Contains(title, "黄果短剧") {
|
||||
return title
|
||||
}
|
||||
return title + " - " + siteName
|
||||
}
|
||||
|
||||
func pageOGType(path string) string {
|
||||
path = strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
if strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/") {
|
||||
return "video.episode"
|
||||
}
|
||||
if path == "/terms" || path == "/privacy" || path == "/2257" || path == "/dmca" {
|
||||
return "article"
|
||||
}
|
||||
return "website"
|
||||
}
|
||||
|
||||
func pageOGImage(siteURL string, path string, data gin.H) string {
|
||||
path = strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
// 播放页分享小卡片统一用品牌 OG 图,避免微信等抓取剧集封面。
|
||||
if strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/") {
|
||||
return seoser.AbsURL(siteURL, "/static/og-default.png")
|
||||
}
|
||||
if playlet, ok := data["playlet"].(*playletser.PlayletInfoResp); ok && playlet != nil {
|
||||
if cover := firstNonEmptySEO(playlet.Cover, playlet.Banner); cover != "" {
|
||||
return seoser.AbsURL(siteURL, cover)
|
||||
}
|
||||
}
|
||||
if cover, _ := data["cover"].(string); strings.TrimSpace(cover) != "" {
|
||||
return seoser.AbsURL(siteURL, cover)
|
||||
}
|
||||
return seoser.AbsURL(siteURL, "/static/og-default.png")
|
||||
}
|
||||
|
||||
func firstNonEmptySEO(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAttachPageSEOUsesPageTypeInsteadOfRequestDomain(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
requestURL string
|
||||
foreverURL string
|
||||
status int
|
||||
data gin.H
|
||||
wantRobots string
|
||||
wantRobotsHeader string
|
||||
wantCanonical string
|
||||
wantJSONLD bool
|
||||
}{
|
||||
{
|
||||
name: "public page on alternate domain remains indexable",
|
||||
requestURL: "https://alternate.example/",
|
||||
foreverURL: "https://primary.example",
|
||||
status: http.StatusOK,
|
||||
data: gin.H{},
|
||||
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
|
||||
wantCanonical: "https://primary.example/",
|
||||
wantJSONLD: true,
|
||||
},
|
||||
{
|
||||
name: "public page without permanent address remains indexable",
|
||||
requestURL: "https://alternate.example/about",
|
||||
status: http.StatusOK,
|
||||
data: gin.H{},
|
||||
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
|
||||
wantCanonical: "https://alternate.example/about",
|
||||
wantJSONLD: true,
|
||||
},
|
||||
{
|
||||
name: "search page is not indexable",
|
||||
requestURL: "https://alternate.example/search/short-drama",
|
||||
foreverURL: "https://primary.example",
|
||||
status: http.StatusOK,
|
||||
data: gin.H{},
|
||||
wantRobots: "noindex,follow",
|
||||
wantRobotsHeader: "noindex, follow",
|
||||
},
|
||||
{
|
||||
name: "private page is neither indexed nor followed",
|
||||
requestURL: "https://alternate.example/profile",
|
||||
foreverURL: "https://primary.example",
|
||||
status: http.StatusOK,
|
||||
data: gin.H{},
|
||||
wantRobots: "noindex,nofollow",
|
||||
wantRobotsHeader: "noindex, nofollow",
|
||||
},
|
||||
{
|
||||
name: "navigation page is not indexable",
|
||||
requestURL: "https://alternate.example/",
|
||||
foreverURL: "https://primary.example",
|
||||
status: http.StatusOK,
|
||||
data: gin.H{"isNavPage": true},
|
||||
wantRobots: "noindex,follow",
|
||||
wantRobotsHeader: "noindex, follow",
|
||||
},
|
||||
{
|
||||
name: "missing public entity is not indexable",
|
||||
requestURL: "https://alternate.example/tags/missing",
|
||||
foreverURL: "https://primary.example",
|
||||
status: http.StatusOK,
|
||||
data: gin.H{"seoEntityValid": false},
|
||||
wantRobots: "noindex,follow",
|
||||
wantRobotsHeader: "noindex, follow",
|
||||
},
|
||||
{
|
||||
name: "error page is not indexable",
|
||||
requestURL: "https://alternate.example/about",
|
||||
foreverURL: "https://primary.example",
|
||||
status: http.StatusNotFound,
|
||||
data: gin.H{},
|
||||
wantRobots: "noindex,follow",
|
||||
wantRobotsHeader: "noindex, follow",
|
||||
},
|
||||
{
|
||||
name: "canonical play page stays indexable",
|
||||
requestURL: "https://alternate.example/play/zhuazou",
|
||||
foreverURL: "https://primary.example",
|
||||
status: http.StatusOK,
|
||||
data: gin.H{},
|
||||
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
|
||||
wantCanonical: "https://primary.example/play/zhuazou",
|
||||
wantJSONLD: true,
|
||||
},
|
||||
{
|
||||
name: "play page with chapter still canonicalizes to two-segment path",
|
||||
requestURL: "https://alternate.example/play/zhuazou",
|
||||
foreverURL: "https://primary.example",
|
||||
status: http.StatusOK,
|
||||
data: gin.H{
|
||||
"playlet": playletser.PlayletInfoResp{ID: 12, Slug: "zhuazou"},
|
||||
"chapter": playletser.PlayletChapterInfoResp{ID: 901, PlayletID: 12},
|
||||
},
|
||||
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
|
||||
wantCanonical: "https://primary.example/play/zhuazou",
|
||||
wantJSONLD: true,
|
||||
},
|
||||
{
|
||||
name: "episode play page is not indexable",
|
||||
requestURL: "https://alternate.example/play/zhuazou/12",
|
||||
foreverURL: "https://primary.example",
|
||||
status: http.StatusOK,
|
||||
data: gin.H{},
|
||||
wantRobots: "noindex,follow",
|
||||
wantRobotsHeader: "noindex, follow",
|
||||
},
|
||||
{
|
||||
name: "unwatchable series play page is not indexable",
|
||||
requestURL: "https://alternate.example/series-play/film-1",
|
||||
foreverURL: "https://primary.example",
|
||||
status: http.StatusOK,
|
||||
data: gin.H{"canWatch": false},
|
||||
wantRobots: "noindex,follow",
|
||||
wantRobotsHeader: "noindex, follow",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(response)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, tt.requestURL, nil)
|
||||
|
||||
data := gin.H{
|
||||
"appConfig": configser.AppConfigVM{
|
||||
ForeverURLAddr: tt.foreverURL,
|
||||
},
|
||||
}
|
||||
for key, value := range tt.data {
|
||||
data[key] = value
|
||||
}
|
||||
|
||||
attachPageSEO(ctx, tt.status, data)
|
||||
|
||||
if got := data["robots"]; got != tt.wantRobots {
|
||||
t.Fatalf("robots = %q, want %q", got, tt.wantRobots)
|
||||
}
|
||||
if got := response.Header().Get("X-Robots-Tag"); got != tt.wantRobotsHeader {
|
||||
t.Fatalf("X-Robots-Tag = %q, want %q", got, tt.wantRobotsHeader)
|
||||
}
|
||||
|
||||
canonicalValue, hasCanonical := data["canonicalURL"]
|
||||
if tt.wantCanonical == "" {
|
||||
if hasCanonical {
|
||||
t.Fatalf("canonicalURL must be absent, got %#v", canonicalValue)
|
||||
}
|
||||
} else {
|
||||
canonical, ok := canonicalValue.(string)
|
||||
if !hasCanonical || !ok || canonical != tt.wantCanonical {
|
||||
t.Fatalf("canonicalURL = %#v, want %q", canonicalValue, tt.wantCanonical)
|
||||
}
|
||||
}
|
||||
|
||||
jsonLD, hasJSONLD := data["jsonLD"]
|
||||
if hasJSONLD != tt.wantJSONLD {
|
||||
t.Fatalf("jsonLD presence = %v, want %v (value %#v)", hasJSONLD, tt.wantJSONLD, jsonLD)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type searchTrend struct {
|
||||
Rank int
|
||||
Title string
|
||||
Heat string
|
||||
Badge string
|
||||
}
|
||||
|
||||
// SearchMobile 移动端搜索入口。
|
||||
// 热搜 + 猜你喜欢:GET /api/app/playlet/hot-like
|
||||
// 联想:前端调用 GET /api/web/search/suggest → 上游 /api/app/playlet/search/suggest
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_hot_like
|
||||
func SearchMobile(c *gin.Context) {
|
||||
if hasLegacySearchQuery(c) {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
raw := playletser.FetchHotLikeOrEmpty(c)
|
||||
trends := hotSearchTrends(raw.HotSearch)
|
||||
recommended := raw.GuessLike
|
||||
if len(recommended) > 15 {
|
||||
recommended = recommended[:15]
|
||||
}
|
||||
|
||||
HTMLOK(c, "searchMobile.html", gin.H{
|
||||
"title": "搜索 - 黄果短剧",
|
||||
"query": searchQueryFromRequest(c),
|
||||
"trends": trends,
|
||||
"recommended": recommended,
|
||||
"hotLikeDebug": gin.H{
|
||||
"path": "/api/app/playlet/hot-like",
|
||||
"response": raw,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func hotSearchTrends(items []playletser.PlayletHotSearchItem) []searchTrend {
|
||||
out := make([]searchTrend, 0, len(items))
|
||||
for i, item := range items {
|
||||
keyword := strings.TrimSpace(item.Keyword)
|
||||
if keyword == "" {
|
||||
continue
|
||||
}
|
||||
rank := i + 1
|
||||
badge := ""
|
||||
switch rank {
|
||||
case 1:
|
||||
badge = "热"
|
||||
case 2:
|
||||
badge = "新"
|
||||
}
|
||||
out = append(out, searchTrend{
|
||||
Rank: rank,
|
||||
Title: keyword,
|
||||
Heat: formatSearchHeat(item.Count),
|
||||
Badge: badge,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatSearchHeat(n int64) string {
|
||||
if n <= 0 {
|
||||
return "0"
|
||||
}
|
||||
if n < 10000 {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
v := float64(n) / 10000
|
||||
if v >= 100 {
|
||||
return fmt.Sprintf("%.0f 万", v)
|
||||
}
|
||||
return fmt.Sprintf("%.1f 万", v)
|
||||
}
|
||||
|
||||
// Search 搜索结果页:GET /api/app/playlet/search。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_search
|
||||
// 无结果时「大家都在看」:GET /api/app/playlet/all/list(取 6 条最热)。
|
||||
func Search(c *gin.Context) {
|
||||
if hasLegacySearchQuery(c) {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
keyword := searchQueryFromRequest(c)
|
||||
pageStr := strings.TrimSpace(c.Param("page"))
|
||||
q := playletser.ParseSearchQuery(keyword, pageStr)
|
||||
|
||||
list := []playletser.CommonData(nil)
|
||||
recommended := []playletser.CommonData(nil)
|
||||
total := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var searchDebug any
|
||||
var watchingDebug any
|
||||
|
||||
if q.Keyword != "" {
|
||||
raw := playletser.FetchSearchOrEmpty(c, q)
|
||||
searchDebug = gin.H{
|
||||
"path": "/api/app/playlet/search",
|
||||
"keyword": q.Keyword,
|
||||
"pageNum": q.PageNum,
|
||||
"pageSize": q.PageSize,
|
||||
"response": raw,
|
||||
}
|
||||
if raw != nil {
|
||||
list = raw.List
|
||||
total = raw.TotalCount
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
if strings.TrimSpace(raw.Keyword) != "" {
|
||||
keyword = raw.Keyword
|
||||
}
|
||||
}
|
||||
} else {
|
||||
searchDebug = gin.H{
|
||||
"path": "/api/app/playlet/search",
|
||||
"keyword": "",
|
||||
"note": "empty keyword, skip upstream",
|
||||
}
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
watchingQ := playletser.AllListQuery{
|
||||
PageNum: 1,
|
||||
PageSize: 6,
|
||||
SortType: 2, // 最热
|
||||
UpdateStatus: 0, // 全部
|
||||
}
|
||||
watching := playletser.FetchAllListOrEmpty(c, watchingQ)
|
||||
watchingDebug = gin.H{
|
||||
"path": "/api/app/playlet/all/list",
|
||||
"pageNum": watchingQ.PageNum,
|
||||
"pageSize": watchingQ.PageSize,
|
||||
"sortType": watchingQ.SortType,
|
||||
"response": watching,
|
||||
}
|
||||
if watching != nil {
|
||||
recommended = watching.List
|
||||
if len(recommended) > 6 {
|
||||
recommended = recommended[:6]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HTMLOK(c, "search.html", gin.H{
|
||||
"title": "搜索 - 黄果短剧",
|
||||
"query": keyword,
|
||||
"hasResults": len(list) > 0,
|
||||
"resultCount": total,
|
||||
"works": list,
|
||||
"recommended": recommended,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"pagination": playletser.BuildSearchPagination(q, total),
|
||||
"searchDebug": searchDebug,
|
||||
"watchingDebug": watchingDebug,
|
||||
})
|
||||
}
|
||||
|
||||
type searchSuggestFeature struct {
|
||||
Title string `json:"title"`
|
||||
Cover string `json:"cover"`
|
||||
URL string `json:"url"`
|
||||
Meta string `json:"meta"`
|
||||
}
|
||||
|
||||
type searchSuggestItem struct {
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Query string `json:"query"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// SearchSuggest PC 顶栏输入联想:GET /api/web/search/suggest?keyword=
|
||||
// 上游:GET /api/app/playlet/search/suggest → proto.PlayletSearchSuggestResp
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_search_suggest
|
||||
func SearchSuggest(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
if keyword == "" {
|
||||
keyword = strings.TrimSpace(c.Query("q"))
|
||||
}
|
||||
if keyword == "" {
|
||||
c.JSON(200, gin.H{"code": 200, "keyword": "", "feature": nil, "items": []searchSuggestItem{}})
|
||||
return
|
||||
}
|
||||
|
||||
raw := playletser.FetchSearchSuggestOrEmpty(c, keyword)
|
||||
if kw := strings.TrimSpace(raw.Keyword); kw != "" {
|
||||
keyword = kw
|
||||
}
|
||||
|
||||
var feature *searchSuggestFeature
|
||||
items := make([]searchSuggestItem, 0, len(raw.List)+1)
|
||||
for _, item := range raw.List {
|
||||
title := playletser.PlayletDisplayName(item.PlayName, item.Title)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
href := playletser.PlayPath(item.Slug, item.ID)
|
||||
if feature == nil {
|
||||
feature = &searchSuggestFeature{
|
||||
Title: title,
|
||||
Cover: strings.TrimSpace(item.Cover),
|
||||
URL: href,
|
||||
Meta: buildSearchSuggestMeta(item),
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, searchSuggestItem{
|
||||
Title: title,
|
||||
Kind: "短剧 剧集",
|
||||
Query: title,
|
||||
URL: href,
|
||||
})
|
||||
}
|
||||
|
||||
items = append(items, searchSuggestItem{
|
||||
Title: keyword,
|
||||
Kind: "搜索",
|
||||
Query: keyword,
|
||||
URL: playletser.SearchResultsURL(keyword),
|
||||
})
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"code": 200,
|
||||
"keyword": keyword,
|
||||
"feature": feature,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func buildSearchSuggestMeta(item playletser.CommonData) string {
|
||||
parts := make([]string, 0, 4)
|
||||
for _, name := range item.ThemeNames {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
parts = append(parts, name)
|
||||
}
|
||||
}
|
||||
for _, name := range item.TagNames {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" && len(parts) < 2 {
|
||||
parts = append(parts, name)
|
||||
}
|
||||
}
|
||||
if item.TotalEpisode > 0 {
|
||||
parts = append(parts, fmt.Sprintf("全 %d 集", item.TotalEpisode))
|
||||
} else if item.UpdateStatus == 1 {
|
||||
parts = append(parts, "连载中")
|
||||
}
|
||||
if anchor := strings.TrimSpace(item.Anchor); anchor != "" && anchor != "暂无" && anchor != "原创" {
|
||||
parts = append(parts, "主演 "+anchor)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func hasLegacySearchQuery(c *gin.Context) bool {
|
||||
query := c.Request.URL.Query()
|
||||
return query.Has("q") || query.Has("page")
|
||||
}
|
||||
|
||||
func searchQueryFromRequest(c *gin.Context) string {
|
||||
q := strings.TrimSpace(c.Param("q"))
|
||||
if q == "" {
|
||||
return ""
|
||||
}
|
||||
if decoded, err := url.PathUnescape(q); err == nil {
|
||||
return strings.TrimSpace(decoded)
|
||||
}
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/seoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RobotsTXT 输出可抓取规则及绝对 sitemap 地址。
|
||||
func RobotsTXT(c *gin.Context) {
|
||||
cfg := configser.FetchAppConfigVMOrEmpty(c)
|
||||
siteURL := seoser.ResolveSiteURL(c, cfg)
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.String(http.StatusOK, seoser.BuildRobotsTXT(siteURL))
|
||||
}
|
||||
|
||||
// LLMSTXT 输出简洁站点说明及核心页面的规范地址,供 AI 抓取工具发现内容。
|
||||
func LLMSTXT(c *gin.Context) {
|
||||
cfg := configser.FetchAppConfigVMOrEmpty(c)
|
||||
writeLLMSTXT(c, cfg)
|
||||
}
|
||||
|
||||
func writeLLMSTXT(c *gin.Context, cfg configser.AppConfigVM) {
|
||||
siteURL := seoser.ResolveSiteURL(c, cfg)
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.String(http.StatusOK, seoser.BuildLLMSTXT(siteURL, cfg))
|
||||
}
|
||||
|
||||
// SEOVerifyFile 输出 IndexNow 所有权验证文本。
|
||||
func SEOVerifyFile(c *gin.Context) {
|
||||
key := strings.TrimSuffix(strings.TrimPrefix(c.Request.URL.Path, "/"), ".txt")
|
||||
configured := seoser.VerifyFromConfig().IndexNowKey
|
||||
if key == "" || key != configured {
|
||||
NotFound(c)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.String(http.StatusOK, configured)
|
||||
}
|
||||
|
||||
// DownloadApp 将站内统一下载入口重定向到配置的官方地址,避免模板回退链接产生 404。
|
||||
func DownloadApp(c *gin.Context) {
|
||||
cfg := configser.FetchAppConfigVMOrEmpty(c)
|
||||
target := strings.TrimSpace(cfg.AppAddr)
|
||||
if target == "" {
|
||||
target = strings.TrimSpace(cfg.OfficialAppAddr)
|
||||
}
|
||||
if target == "" {
|
||||
target = "/"
|
||||
}
|
||||
c.Header("X-Robots-Tag", "noindex, nofollow")
|
||||
c.Redirect(http.StatusFound, target)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package web
|
||||
|
||||
import "videoweb/app/service/playletser"
|
||||
|
||||
func rankingSEOSelections(value string) []playletser.SEOSelection {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return []playletser.SEOSelection{{Dimension: "榜单", Value: value}}
|
||||
}
|
||||
|
||||
func moreListSEOSelections(q playletser.MoreListQuery, themes []playletser.PlayletThemeItem) []playletser.SEOSelection {
|
||||
selections := make([]playletser.SEOSelection, 0, 2)
|
||||
if q.TimeRange == 1 {
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "时间", Value: "本周"})
|
||||
} else if q.TimeRange == 2 {
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "时间", Value: "本月"})
|
||||
}
|
||||
themeName := q.ThemeSlug
|
||||
for _, theme := range themes {
|
||||
if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) {
|
||||
themeName = theme.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if themeName != "" {
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: themeName})
|
||||
}
|
||||
return selections
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/videoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Series 剧集列表页:GET /api/app/video/all/list + topic/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_all_list
|
||||
func Series(c *gin.Context) {
|
||||
q := videoser.ParseSeriesListQuery(c)
|
||||
topics := videoser.FetchVideoTopicListOrEmpty(c, q.MediaType)
|
||||
topicResolved := seriesTopicResolved(q, topics)
|
||||
raw := videoser.FetchVideoAllListOrEmpty(c, q)
|
||||
pageHeading, seoSelections := seriesSEOContext(q, topics)
|
||||
|
||||
title := pageHeading + " - 黄果短剧"
|
||||
if raw != nil && raw.SeoInfo.Title != "" {
|
||||
title = raw.SeoInfo.Title
|
||||
}
|
||||
|
||||
list := []videoser.VideoAllListItemResp(nil)
|
||||
total := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var seo videoser.SeoInfo
|
||||
var advitise any
|
||||
if raw != nil {
|
||||
list = raw.List
|
||||
total = raw.TotalCount
|
||||
seo = raw.SeoInfo
|
||||
advitise = raw.Advitise
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
}
|
||||
|
||||
HTMLOK(c, "series.html", gin.H{
|
||||
"title": title,
|
||||
"navActive": "series",
|
||||
"seoInfo": seo,
|
||||
"seoSelections": seoSelections,
|
||||
"pageHeading": pageHeading,
|
||||
"mediaType": q.MediaType,
|
||||
"topicId": q.TopicID,
|
||||
"sortType": q.SortType,
|
||||
"seoEntityValid": topicResolved || total > 0,
|
||||
"topicList": topics,
|
||||
"list": list,
|
||||
"totalCount": total,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"listCount": fmt.Sprintf("共 %d 部", total),
|
||||
"advitise": advitise,
|
||||
"pagination": videoser.BuildSeriesListPagination(q, total),
|
||||
"canonicalPath": q.URL(),
|
||||
})
|
||||
}
|
||||
|
||||
func seriesTopicResolved(q videoser.SeriesListQuery, topics []videoser.VideoTopicInfo) bool {
|
||||
if q.TopicID <= 0 {
|
||||
return true
|
||||
}
|
||||
for _, topic := range topics {
|
||||
if topic.ID == q.TopicID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func seriesSEOContext(q videoser.SeriesListQuery, topics []videoser.VideoTopicInfo) (string, []playletser.SEOSelection) {
|
||||
heading := "剧集与动画"
|
||||
selections := make([]playletser.SEOSelection, 0, 3)
|
||||
if q.MediaType == videoser.MediaFilm || q.MediaType == videoser.MediaAnime {
|
||||
mediaName := videoser.MediaTypeLabel(q.MediaType)
|
||||
heading = mediaName
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "类型", Value: mediaName})
|
||||
}
|
||||
if q.TopicID > 0 {
|
||||
for _, topic := range topics {
|
||||
if topic.ID == q.TopicID {
|
||||
name := topic.DisplayName()
|
||||
if name != "" {
|
||||
heading = name + heading
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: name})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if q.SortType == videoser.SortHeat {
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "最热"})
|
||||
} else if q.SortType == videoser.SortCollect {
|
||||
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"})
|
||||
}
|
||||
return heading, selections
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Setting 展示账号设置,字段来自最新的用户信息接口。
|
||||
func Setting(c *gin.Context) {
|
||||
data := gin.H{"title": "账号设置 - 黄果短剧", "navActive": "setting", "accountTab": "setting"}
|
||||
loadAccountSummary(c, data, gin.H{})
|
||||
HTMLOK(c, "setting.html", data)
|
||||
}
|
||||
|
||||
// UpdateProfile 代理账号资料修改。可选字段必须显式传入,避免覆盖未修改内容。
|
||||
func UpdateProfile(c *gin.Context) {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "tip": "请先登录"})
|
||||
return
|
||||
}
|
||||
var body authser.UpdateProfileReq
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "参数错误"})
|
||||
return
|
||||
}
|
||||
if body.NickName != nil {
|
||||
v := strings.TrimSpace(*body.NickName)
|
||||
if v == "" || len([]rune(v)) > 40 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "昵称须为 1 至 40 个字符"})
|
||||
return
|
||||
}
|
||||
body.NickName = &v
|
||||
}
|
||||
if body.Introduction != nil {
|
||||
v := strings.TrimSpace(*body.Introduction)
|
||||
if len([]rune(v)) > 300 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "简介不能超过 300 个字符"})
|
||||
return
|
||||
}
|
||||
body.Introduction = &v
|
||||
}
|
||||
if body.Avatar != nil {
|
||||
v := strings.TrimSpace(*body.Avatar)
|
||||
if safeProfileImage(v) == "" || strings.HasPrefix(v, "/static/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "头像地址无效"})
|
||||
return
|
||||
}
|
||||
body.Avatar = &v
|
||||
}
|
||||
if (body.OldPassword == nil) != (body.NewPassword == nil) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "请输入旧密码和新密码"})
|
||||
return
|
||||
}
|
||||
if body.OldPassword != nil && (*body.OldPassword == "" || *body.NewPassword == "") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "密码不能为空"})
|
||||
return
|
||||
}
|
||||
if body.NickName == nil && body.Introduction == nil && body.Avatar == nil && body.OldPassword == nil && body.PrivacyMode == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "没有需要保存的修改"})
|
||||
return
|
||||
}
|
||||
env, err := authser.UpdateProfile(c, body)
|
||||
if err != nil {
|
||||
log.Error("update profile failed", log.E(err))
|
||||
c.JSON(http.StatusBadGateway, gin.H{"code": 500, "tip": "保存失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
if env == nil || env.Code != 200 {
|
||||
if env == nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"code": 500, "tip": "保存失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": env.Code, "tip": firstNonEmptyAuth(env.Tip, env.Msg, "保存失败")})
|
||||
return
|
||||
}
|
||||
if infoEnv, user, err := authser.GetInfo(c); err == nil && infoEnv != nil && infoEnv.Code == 200 && user != nil {
|
||||
authser.SetAuthCookies(c, user)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "tip": firstNonEmptyAuth(env.Tip, "保存成功")})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Tags 标签作品列表页:GET /api/app/playlet/tag/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_tag_list
|
||||
func Tags(c *gin.Context) {
|
||||
q := playletser.ParseTagListQuery(c)
|
||||
tagOptions := playletser.FetchTagOptionsOrEmpty(c)
|
||||
tagName := playletser.ResolveTagListIdentity(&q, tagOptions)
|
||||
tagResolved := tagName != ""
|
||||
raw := playletser.FetchTagListOrEmpty(c, q)
|
||||
|
||||
if tagName == "" && raw != nil {
|
||||
if raw.SeoInfo.Title != "" {
|
||||
tagName = raw.SeoInfo.Title
|
||||
} else {
|
||||
tagName = playletser.FindTagNameBySlug(raw.List, q.TagSlug)
|
||||
if tagName == "" {
|
||||
tagName = playletser.FindTagName(raw.List, q.TagID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
title := "标签 - 黄果短剧"
|
||||
if raw != nil {
|
||||
title = playletser.TagListPageTitle(tagName, q.PageNum, raw.SeoInfo)
|
||||
} else if tagName != "" {
|
||||
title = playletser.TagListPageTitle(tagName, q.PageNum, playletser.SeoInfo{})
|
||||
}
|
||||
|
||||
list := []playletser.CommonData(nil)
|
||||
total := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var seo playletser.SeoInfo
|
||||
if raw != nil {
|
||||
list = raw.List
|
||||
total = raw.TotalCount
|
||||
seo = raw.SeoInfo
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
}
|
||||
|
||||
desc := ""
|
||||
if seo.Description != "" {
|
||||
desc = seo.Description
|
||||
} else if total > 0 {
|
||||
desc = fmt.Sprintf("%d 部作品带有此标签", total)
|
||||
}
|
||||
seoSelections := make([]playletser.SEOSelection, 0, 2)
|
||||
if tagName != "" {
|
||||
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "标签", Value: tagName})
|
||||
}
|
||||
if q.SortType == 2 {
|
||||
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "排序", Value: "最热"})
|
||||
} else if q.SortType == 3 {
|
||||
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"})
|
||||
}
|
||||
|
||||
HTMLOK(c, "tags.html", gin.H{
|
||||
"title": title,
|
||||
"navActive": "list",
|
||||
"seoInfo": seo,
|
||||
"seoSelections": seoSelections,
|
||||
"tagName": tagName,
|
||||
"tagSlug": q.TagSlug,
|
||||
"tagId": q.TagID,
|
||||
"sortType": q.SortType,
|
||||
"seoEntityValid": tagResolved || total > 0,
|
||||
"relatedTags": playletser.RelatedTagOptions(tagOptions, q.TagID, q.TagSlug),
|
||||
"list": list,
|
||||
"totalCount": total,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"listCount": fmt.Sprintf("共 %d 部", total),
|
||||
"heroDesc": desc,
|
||||
"pagination": playletser.BuildTagListPagination(q, total),
|
||||
"canonicalPath": playletser.TagListCanonicalPath(q),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package web
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// Terms renders the static Terms of Service page.
|
||||
func Terms(c *gin.Context) {
|
||||
HTMLOK(c, "terms.html", gin.H{})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Topic 专题剧单列表页:GET /api/app/playlet/topic/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_topic_list
|
||||
func Topic(c *gin.Context) {
|
||||
q := playletser.ParseTopicListQuery(c)
|
||||
raw := playletser.FetchTopicListOrEmpty(c, q)
|
||||
home := playletser.FetchHomeOrEmpty(c)
|
||||
homeConfigData := playletser.ActiveHomeConfigs(home.HomeConfigData)
|
||||
list := []playletser.PlayletTopicPageItem(nil)
|
||||
total := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var seo playletser.SeoInfo
|
||||
var seoSelections []playletser.SEOSelection
|
||||
if q.SortType == 2 {
|
||||
seoSelections = []playletser.SEOSelection{{Dimension: "排序", Value: "最热"}}
|
||||
}
|
||||
if raw != nil {
|
||||
list = raw.List
|
||||
total = raw.TotalCount
|
||||
seo = raw.SeoInfo
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
}
|
||||
// 首页“专题”区块的配置 TDK 优先;专题列表接口 SEO 仅补齐配置未填写的字段。
|
||||
if configSEO, ok := playletser.FirstHomeConfigSEOByType(homeConfigData, 3); ok {
|
||||
seo = playletser.MergeSeoInfo(configSEO, seo)
|
||||
}
|
||||
title := "专题剧单 - 黄果短剧"
|
||||
if seo.Title != "" {
|
||||
title = seo.Title
|
||||
}
|
||||
HTMLOK(c, "topic.html", gin.H{
|
||||
"title": title,
|
||||
"navActive": "topic",
|
||||
"seoInfo": seo,
|
||||
"routeConfigSEO": true,
|
||||
"homeConfigData": homeConfigData,
|
||||
"seoSelections": seoSelections,
|
||||
"list": list,
|
||||
"totalCount": total,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"sortType": q.SortType,
|
||||
"pagination": playletser.BuildTopicListPagination(q, total),
|
||||
"canonicalPath": q.URL(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TopicDetail 专题详情页:GET /api/app/playlet/topic/detail。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_topic_detail
|
||||
func TopicDetail(c *gin.Context) {
|
||||
q := playletser.ParseTopicDetailQuery(c)
|
||||
playletser.ResolveTopicDetailID(c, &q)
|
||||
raw := playletser.FetchTopicDetailOrEmpty(c, q)
|
||||
if raw != nil {
|
||||
if raw.Slug != "" {
|
||||
q.Slug = raw.Slug
|
||||
}
|
||||
if raw.ID > 0 {
|
||||
q.ID = raw.ID
|
||||
}
|
||||
}
|
||||
|
||||
name := ""
|
||||
if raw != nil {
|
||||
name = raw.Name
|
||||
}
|
||||
title := "专题 - 黄果短剧"
|
||||
if raw != nil && raw.SeoInfo.Title != "" {
|
||||
title = raw.SeoInfo.Title
|
||||
} else if name != "" {
|
||||
title = name + " - 黄果短剧"
|
||||
}
|
||||
|
||||
list := []playletser.CommonData(nil)
|
||||
more := []playletser.PlayletTopicPageItem(nil)
|
||||
total := int64(0)
|
||||
playletCount := int64(0)
|
||||
pageNum, pageSize := q.PageNum, q.PageSize
|
||||
var seo playletser.SeoInfo
|
||||
desc := ""
|
||||
heroCover := ""
|
||||
covers := []string(nil)
|
||||
if raw != nil {
|
||||
list = raw.List
|
||||
more = raw.MoreTopics
|
||||
total = raw.TotalCount
|
||||
playletCount = raw.PlayletCount
|
||||
seo = raw.SeoInfo
|
||||
desc = raw.Desc
|
||||
heroCover = playletser.TopicDetailHeroCover(*raw)
|
||||
covers = playletser.TopicDetailCoverURLs(*raw)
|
||||
if raw.PageNum > 0 {
|
||||
pageNum = raw.PageNum
|
||||
}
|
||||
if raw.PageSize > 0 {
|
||||
pageSize = raw.PageSize
|
||||
}
|
||||
}
|
||||
countText := "持续更新"
|
||||
if playletCount > 0 {
|
||||
countText = fmt.Sprintf("%d 部 持续更新", playletCount)
|
||||
} else if total > 0 {
|
||||
countText = fmt.Sprintf("%d 部 持续更新", total)
|
||||
}
|
||||
var seoSelections []playletser.SEOSelection
|
||||
if q.SortType != 1 {
|
||||
seoSelections = []playletser.SEOSelection{{Dimension: "排序", Value: playletser.TopicDetailSortLabel(q.SortType)}}
|
||||
}
|
||||
|
||||
HTMLOK(c, "topicDetail.html", gin.H{
|
||||
"title": title,
|
||||
"navActive": "topic",
|
||||
"seoInfo": seo,
|
||||
"seoSelections": seoSelections,
|
||||
"topic": raw,
|
||||
"name": name,
|
||||
"desc": desc,
|
||||
"slug": q.Slug,
|
||||
"topicId": q.ID,
|
||||
"list": list,
|
||||
"moreTopics": more,
|
||||
"totalCount": total,
|
||||
"playletCount": playletCount,
|
||||
"countText": countText,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
"sortType": q.SortType,
|
||||
"sortLabel": playletser.TopicDetailSortLabel(q.SortType),
|
||||
"seoEntityValid": q.ID > 0 && (name != "" || total > 0),
|
||||
"heroCover": heroCover,
|
||||
"covers": covers,
|
||||
"pagination": playletser.BuildTopicDetailPagination(q, total),
|
||||
"canonicalPath": q.URL(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// attachTrackerPage 为所有 HTML 页面补齐稳定的 pageKey/pageName,避免事件落入 unknown。
|
||||
func attachTrackerPage(path string, code int, data map[string]any) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
key, name := trackerPageFromRequest(path, code)
|
||||
if current, _ := data["pageKey"].(string); strings.TrimSpace(current) == "" {
|
||||
data["pageKey"] = key
|
||||
}
|
||||
if current, _ := data["pageName"].(string); strings.TrimSpace(current) == "" {
|
||||
data["pageName"] = name
|
||||
}
|
||||
}
|
||||
|
||||
func trackerPageFromRequest(path string, code int) (string, string) {
|
||||
if code >= http.StatusInternalServerError {
|
||||
return "server_error", "服务异常页面"
|
||||
}
|
||||
if code >= http.StatusBadRequest {
|
||||
return "not_found", "404页面"
|
||||
}
|
||||
return trackerPageFromPath(path)
|
||||
}
|
||||
|
||||
func trackerPageFromPath(path string) (string, string) {
|
||||
p := strings.TrimSuffix(strings.TrimSpace(path), "/")
|
||||
if p == "" {
|
||||
return "home", "首页"
|
||||
}
|
||||
rules := []struct {
|
||||
prefix string
|
||||
key string
|
||||
name string
|
||||
}{
|
||||
{"/series-play", "series_play", "AI成人剧集播放页"},
|
||||
{"/series", "series", "AI成人剧集"},
|
||||
{"/play", "play", "短剧播放页"},
|
||||
{"/detail", "detail", "短剧详情"},
|
||||
{"/list", "list", "短剧列表"},
|
||||
{"/recently-added", "recently_added", "最新短剧"},
|
||||
{"/popular-recommend", "popular_recommend", "热门推荐"},
|
||||
{"/rank", "rank", "排行榜"},
|
||||
{"/topic-detail", "topic_detail", "专题详情"},
|
||||
{"/topic", "topic", "专题列表"},
|
||||
{"/tags", "tags", "标签详情"},
|
||||
{"/more-series-list", "more_series_list", "成人剧集列表"},
|
||||
{"/more-list", "more_list", "短剧扩展列表"},
|
||||
{"/search-mobile", "search", "搜索结果页"},
|
||||
{"/search", "search", "搜索结果页"},
|
||||
{"/history", "history", "观看历史"},
|
||||
{"/favorites", "favorites", "我的收藏"},
|
||||
{"/purchased", "purchased", "已购剧集"},
|
||||
{"/profile", "profile", "个人中心"},
|
||||
{"/setting", "setting", "设置"},
|
||||
{"/recharge", "recharge", "充值中心"},
|
||||
{"/download-app", "download_app", "下载APP"},
|
||||
{"/feedback", "feedback", "问题反馈"},
|
||||
{"/help", "help", "帮助中心"},
|
||||
{"/about", "about", "关于我们"},
|
||||
{"/terms", "terms", "用户协议"},
|
||||
{"/privacy", "privacy", "隐私政策"},
|
||||
{"/2257", "compliance_2257", "2257合规声明"},
|
||||
{"/dmca", "dmca", "DMCA版权声明"},
|
||||
{"/login", "login", "登录"},
|
||||
{"/register", "register", "注册"},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if p == rule.prefix || strings.HasPrefix(p, rule.prefix+"/") {
|
||||
return rule.key, rule.name
|
||||
}
|
||||
}
|
||||
return "internal_other", "站内其他页面"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"videoweb/app/service/videoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VideoCollectSet BFF:代理 POST /api/app/video/collect/set。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_collect_set
|
||||
func VideoCollectSet(c *gin.Context) {
|
||||
var body struct {
|
||||
ID int64 `json:"id"`
|
||||
IsCollect *bool `json:"isCollect"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.ID <= 0 || body.IsCollect == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "id/isCollect required",
|
||||
"tip": "缺少视频 ID 或收藏状态",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := videoser.CollectSet(c, videoser.VideoCollectSetReq{
|
||||
ID: body.ID,
|
||||
IsCollect: *body.IsCollect,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": env.Tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"videoweb/app/service/videoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// VideoLikeSet BFF:代理 POST /api/app/video/like/set。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_like_set
|
||||
func VideoLikeSet(c *gin.Context) {
|
||||
var body struct {
|
||||
ID int64 `json:"id"`
|
||||
IsLike *bool `json:"isLike"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "invalid body",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.ID <= 0 || body.IsLike == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "id/isLike required",
|
||||
"tip": "缺少视频 ID 或点赞状态",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := videoser.LikeSet(c, videoser.VideoLikeSetReq{
|
||||
ID: body.ID,
|
||||
IsLike: *body.IsLike,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": err.Error(),
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "empty response",
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": env.Tip,
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package appg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
const EsIndex = "sz"
|
||||
|
||||
var (
|
||||
Conf GlobalCfgEx
|
||||
Cache *cache.Cache
|
||||
)
|
||||
|
||||
type GlobalCfgEx struct {
|
||||
ServerAddr string `yaml:"serverAddr"`
|
||||
ListenAddr string `yaml:"listenAddr"`
|
||||
DHListenAddr string `yaml:"dhListenAddr"`
|
||||
RunMod string `yaml:"runMod"`
|
||||
TsAuthKey string `yaml:"tsAuthKey"`
|
||||
SEO SEOConf `yaml:"seo"`
|
||||
Tracker TrackerConf `yaml:"tracker"`
|
||||
}
|
||||
|
||||
// SEOConf 站长平台验证与 SEO 相关配置。
|
||||
type SEOConf struct {
|
||||
BaiduSiteVerification string `yaml:"baiduSiteVerification"`
|
||||
BaiduHTMLFile string `yaml:"baiduHtmlFile"` // 如 baidu_verify_codeva-xxx.html
|
||||
IndexNowKey string `yaml:"indexNowKey"` // Bing IndexNow 密钥,提供 /{key}.txt
|
||||
}
|
||||
|
||||
// TrackerConf 数据上报 SDK 本地兜底(上游 reportSdkConfig 优先)。
|
||||
type TrackerConf struct {
|
||||
AppID string `yaml:"appId"`
|
||||
EncryptedConfig string `yaml:"encryptedConfig"`
|
||||
}
|
||||
|
||||
// TrackerAppID 返回页面 SDK 与 BFF 透传校验共用的可信应用 ID。
|
||||
func TrackerAppID() string {
|
||||
if id := strings.TrimSpace(Conf.Tracker.AppID); id != "" {
|
||||
return id
|
||||
}
|
||||
return pubdef.TrackerAppId()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
serverAddr: "http://172.104.181.99:25319"
|
||||
listenAddr: "25121"
|
||||
dhListenAddr: "26813"
|
||||
runMod: "dev"
|
||||
tsAuthKey: "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN"
|
||||
|
||||
# 数据上报 SDK(web-sdk)。encryptedConfig 优先用上游 config/info.reportSdkConfig;
|
||||
# 若上游暂未下发,可在此填写密文兜底。
|
||||
tracker:
|
||||
appId: "JHA-1045"
|
||||
encryptedConfig: ""
|
||||
@@ -0,0 +1 @@
|
||||
—Џ€И’љБCЏ„
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
FILE_NAME="web_cgdj_h5"
|
||||
REMOTE_FILE_PATH="/home/server2/web_cgdj/h5/"
|
||||
TAG_NAME="web_cgdj_h5.tar.gz"
|
||||
|
||||
set -e
|
||||
set -o
|
||||
function send_msg() {
|
||||
msg="@jscdsndj @xiuyuan_Lee @steven0yu %0A服务:SERVER2 站点-cgdj-H5 %0A更新内容:$content%0A创建时间: $(date +%F_%X)%0A操作人:$(git config user.name)"
|
||||
curl -X POST "https://api.telegram.org/bot6357085965:AAEmXfjZnZwnDTicL7Wjbd7cXvYv_G98w8A/sendMessage" -d "chat_id=$1&text=$msg" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
read -p "输入yes, 开始上传: " word
|
||||
if [[ ! "yes" =~ $word ]]; then
|
||||
echo "终止"
|
||||
else
|
||||
read -e -p "本次更新内容(输入q退出编辑): " -r content
|
||||
echo "开始"
|
||||
rm -rf ./$FILE_NAME ./$TAG_NAME ./yinsemd5.txt
|
||||
GOOS=linux GOARCH=amd64 go build -o $FILE_NAME
|
||||
md5sum $FILE_NAME | cut -d ' ' -f1 >yinsemd5.txt
|
||||
tar zvcf $TAG_NAME ./$FILE_NAME yinsemd5.txt
|
||||
md5sum $TAG_NAME | cut -d ' ' -f1 >yinsemd5.txt
|
||||
scp -i ~/.ssh/id_rsa -P 22889 ./yinsemd5.txt server2@116.212.120.2:$REMOTE_FILE_PATH
|
||||
scp -i ~/.ssh/id_rsa -P 22889 ./$TAG_NAME server2@116.212.120.2:$REMOTE_FILE_PATH
|
||||
send_msg -4791758991 #服务器小组
|
||||
fi
|
||||
rm -rf ./$FILE_NAME ./$TAG_NAME ./yinsemd5.txt
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"videoweb/app/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
server.Run(os.Args)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// applyClientForwardHeaders 向上游透传客户端 IP / UA(与 API 上游一致):
|
||||
// X-Real-IP、User-Agent、X-User-Agent。
|
||||
func applyClientForwardHeaders(header map[string]string, c *gin.Context) {
|
||||
if header == nil {
|
||||
return
|
||||
}
|
||||
useragent.ApplyToHeader(header, c, useragent.DefaultVer)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"videoweb/app/api/web"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/app/service/commentser"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/seoser"
|
||||
"videoweb/app/service/translationser"
|
||||
"videoweb/app/service/videoser"
|
||||
staticassets "videoweb/app/static"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Init 初始化 Gin,注册静态资源与业务路由。
|
||||
func Init() *gin.Engine {
|
||||
setGinMode()
|
||||
engine := gin.Default()
|
||||
engine.HandleMethodNotAllowed = true
|
||||
setupEngine(engine)
|
||||
registerM3U8Routes(engine)
|
||||
registerSitemapRoutes(engine)
|
||||
registerRoutes(engine)
|
||||
engine.NoMethod(web.NotFound)
|
||||
return engine
|
||||
}
|
||||
|
||||
// DHInit 导航站独立端口:仅暴露官方导航页及静态资源。
|
||||
func DHInit() *gin.Engine {
|
||||
setGinMode()
|
||||
engine := gin.Default()
|
||||
engine.HandleMethodNotAllowed = true
|
||||
setupEngine(engine)
|
||||
engine.GET("/", web.Nav)
|
||||
engine.GET("/nav/", web.Nav)
|
||||
engine.GET("/dh/", web.Nav)
|
||||
engine.NoRoute(web.NotFound)
|
||||
engine.NoMethod(web.NotFound)
|
||||
return engine
|
||||
}
|
||||
|
||||
func setGinMode() {
|
||||
mode := strings.ToLower(strings.TrimSpace(appg.Conf.RunMod))
|
||||
if envMode := strings.ToLower(strings.TrimSpace(os.Getenv(gin.EnvGinMode))); envMode != "" {
|
||||
mode = envMode
|
||||
}
|
||||
switch mode {
|
||||
case "test":
|
||||
gin.SetMode(gin.TestMode)
|
||||
case "prod", "production", "release":
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
default:
|
||||
gin.SetMode(gin.DebugMode)
|
||||
}
|
||||
}
|
||||
|
||||
func setupEngine(engine *gin.Engine) {
|
||||
tplFuncs := playletser.TemplateFuncs()
|
||||
for name, fn := range commentser.TemplateFuncs() {
|
||||
tplFuncs[name] = fn
|
||||
}
|
||||
tplFuncs["InJSONScript"] = inJSONScript
|
||||
tplFuncs["navHref"] = translationser.NavHref
|
||||
tplFuncs["navIsDownload"] = translationser.NavIsDownload
|
||||
tplFuncs["navIsActive"] = translationser.NavIsActive
|
||||
tplFuncs["navDrawerIcon"] = translationser.NavDrawerIcon
|
||||
tplFuncs["navHasMegaMenu"] = translationser.NavHasMegaMenu
|
||||
tplFuncs["listThemeSegment"] = translationser.ListThemeSegmentFromPath
|
||||
tplFuncs["listUpdateStatus"] = translationser.ListUpdateStatusFromPath
|
||||
tplFuncs["seriesListURL"] = videoser.SeriesListURL
|
||||
tplFuncs["videoDetailURL"] = videoser.VideoDetailURL
|
||||
tplFuncs["videoPlayURL"] = videoser.VideoPlayURL
|
||||
tplFuncs["mediaTypeLabel"] = videoser.MediaTypeLabel
|
||||
tplFuncs["episodeLabel"] = videoser.EpisodeLabel
|
||||
engine.SetFuncMap(tplFuncs)
|
||||
engine.SetHTMLTemplate(mustHTMLTemplate(tplFuncs))
|
||||
engine.Use(strictTransportSecurity(), staticCacheHeaders(), crawlerHeaders())
|
||||
engine.StaticFS("/static", http.FS(staticassets.FS))
|
||||
}
|
||||
|
||||
// strictTransportSecurity 仅在请求实际通过 HTTPS 到达,或可信代理标记首个协议为 HTTPS 时启用 HSTS。
|
||||
// 不声明 includeSubDomains/preload,避免把尚未确认支持 HTTPS 的子域一并纳入。
|
||||
func strictTransportSecurity() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if isHTTPSRequest(c.Request) {
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func isHTTPSRequest(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if i := strings.IndexByte(proto, ','); i >= 0 {
|
||||
proto = proto[:i]
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(proto), "https")
|
||||
}
|
||||
|
||||
// crawlerHeaders 防止接口和媒体工具响应进入搜索索引。
|
||||
func crawlerHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/h5/") ||
|
||||
path == "/enkey" || path == "/newenkey" || path == "/healthz" {
|
||||
c.Header("X-Robots-Tag", "noindex, nofollow")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// inJSONScript 将任意数据序列化为可嵌入 <script> 的 JSON。
|
||||
func inJSONScript(v any) template.JS {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return template.JS("null")
|
||||
}
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
// staticCacheHeaders 为静态资源设置浏览器缓存。
|
||||
func staticCacheHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/static/") {
|
||||
c.Header("Cache-Control", "public, max-age=604800, stale-while-revalidate=86400")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func registerRoutes(r *gin.Engine) {
|
||||
// 页面路由与 template/pages 文件名对应:camelCase → kebab-case(如 popularRecommend → /popular-recommend)
|
||||
r.GET("/", web.Index)
|
||||
r.GET("/search-mobile", web.SearchMobile)
|
||||
r.GET("/search", web.Search)
|
||||
r.GET("/search/:q", web.Search)
|
||||
r.GET("/search/:q/page/:page", web.Search)
|
||||
r.GET("/profile", web.Profile)
|
||||
r.GET("/history", web.History)
|
||||
r.GET("/history/page/:page", web.History)
|
||||
r.POST("/api/playlet/history/clear", web.HistoryClear)
|
||||
r.POST("/api/playlet/history/delete", web.HistoryDelete)
|
||||
r.POST("/api/video/history/clear", web.VideoHistoryClear)
|
||||
r.POST("/api/video/history/delete", web.VideoHistoryDelete)
|
||||
r.GET("/favorites", web.Favorites)
|
||||
r.GET("/favorites/page/:page", web.Favorites)
|
||||
r.GET("/favorites/:filter", web.Favorites)
|
||||
r.GET("/favorites/:filter/page/:page", web.Favorites)
|
||||
r.GET("/purchased", web.PurchasedSeries)
|
||||
r.GET("/purchased/page/:page", web.PurchasedSeries)
|
||||
r.GET("/setting", web.Setting)
|
||||
r.GET("/settings", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusMovedPermanently, "/setting")
|
||||
})
|
||||
r.GET("/recharge", web.Recharge)
|
||||
r.GET("/about", web.About)
|
||||
r.GET("/help", web.Help)
|
||||
r.GET("/terms", web.Terms)
|
||||
r.GET("/privacy", web.Privacy)
|
||||
r.GET("/2257", web.Compliance2257)
|
||||
r.GET("/dmca", web.DMCA)
|
||||
r.GET("/download-app", web.DownloadApp)
|
||||
r.GET("/nav", web.Nav)
|
||||
r.GET("/nav/", web.Nav)
|
||||
r.GET("/dh", web.Nav)
|
||||
r.GET("/dh/", web.Nav)
|
||||
r.GET("/list", web.List)
|
||||
r.GET("/list/:theme", web.List)
|
||||
r.GET("/list/:theme/:updateStatus/:sortType", web.List)
|
||||
r.GET("/list/:theme/:updateStatus/:sortType/page/:page", web.List)
|
||||
r.GET("/more-list/:configId/:timeRange/:theme/page/:page", web.MoreList)
|
||||
r.GET("/more-list/:configId/:timeRange/:theme", web.MoreList)
|
||||
r.GET("/more-list/:configId/page/:page", web.MoreList)
|
||||
r.GET("/more-list/:configId", web.MoreList)
|
||||
r.GET("/more-series-list/:configId/:timeRange/:theme/page/:page", web.MoreSeriesList)
|
||||
r.GET("/more-series-list/:configId/:timeRange/:theme", web.MoreSeriesList)
|
||||
r.GET("/more-series-list/:configId/page/:page", web.MoreSeriesList)
|
||||
r.GET("/more-series-list/:configId", web.MoreSeriesList)
|
||||
r.GET("/series", web.Series)
|
||||
r.GET("/series/:media/:topic/:sortType", web.Series)
|
||||
r.GET("/series/:media/:topic/:sortType/page/:page", web.Series)
|
||||
r.GET("/topic", web.Topic)
|
||||
r.GET("/topic/page/:page", web.Topic)
|
||||
r.GET("/topic/sort/:sortType", web.Topic)
|
||||
r.GET("/topic/sort/:sortType/page/:page", web.Topic)
|
||||
r.GET("/topic-detail/:slug/sort/:sortType/page/:page", web.TopicDetail)
|
||||
r.GET("/topic-detail/:slug/sort/:sortType", web.TopicDetail)
|
||||
r.GET("/topic-detail/:slug/page/:page", web.TopicDetail)
|
||||
r.GET("/topic-detail/:slug", web.TopicDetail)
|
||||
r.GET("/rank", web.Rank)
|
||||
r.GET("/rank/page/:page", web.Rank)
|
||||
r.GET("/rank/:sortOrder", web.Rank)
|
||||
r.GET("/rank/:sortOrder/page/:page", web.Rank)
|
||||
r.GET("/tags/:slug/sort/:sortType/page/:page", web.Tags)
|
||||
r.GET("/tags/:slug/sort/:sortType", web.Tags)
|
||||
r.GET("/tags/:slug/page/:page", web.Tags)
|
||||
r.GET("/tags/:slug", web.Tags)
|
||||
r.GET("/recently-added", web.RecentlyAdded)
|
||||
r.GET("/recently-added/page/:page", web.RecentlyAdded)
|
||||
r.GET("/popular-recommend", web.PopularRecommend)
|
||||
r.GET("/popular-recommend/page/:page", web.PopularRecommend)
|
||||
r.GET("/popular-recommend/:rankType", web.PopularRecommend)
|
||||
r.GET("/popular-recommend/:rankType/page/:page", web.PopularRecommend)
|
||||
r.GET("/play/:slug", web.Play)
|
||||
r.GET("/play/:slug/:ep", web.Play)
|
||||
r.GET("/play/:slug/recommendations/page/:guessPage", web.Play)
|
||||
r.GET("/play/:slug/:ep/recommendations/page/:guessPage", web.Play)
|
||||
r.GET("/series-play/:slug", web.SeriesPlay)
|
||||
r.GET("/series-play/:slug/recommendations/page/:guessPage", web.SeriesPlay)
|
||||
r.GET("/login", web.LoginPage)
|
||||
r.GET("/register", web.RegisterPage)
|
||||
r.GET("/feedback", web.Feedback)
|
||||
|
||||
// 认证 BFF:SSR 进页已自动游客;前端登录/注册/补游客会话走以下接口。
|
||||
r.POST("/api/auth/guest", web.Guest)
|
||||
r.POST("/api/auth/login", web.Login)
|
||||
r.POST("/api/auth/register", web.Register)
|
||||
r.POST("/api/auth/logout", web.Logout)
|
||||
r.POST("/api/auth/profile", web.UpdateProfile)
|
||||
r.POST("/api/recharge/coin/buy", web.RechargeCoinBuy)
|
||||
r.POST("/api/coin/buy", web.RechargeCoinBuy)
|
||||
r.GET("/api/recharge/transactions", web.RechargeTransactions)
|
||||
r.GET("/api/coin/trans/list", web.RechargeTransactions)
|
||||
r.GET("/api/coin/trans/export", web.RechargeCoinTransExport)
|
||||
r.GET("/api/recharge/transactions/export", web.RechargeCoinTransExport)
|
||||
r.POST("/api/upload/image", web.UploadImage)
|
||||
r.POST("/api/feedback/add", web.FeedbackAdd)
|
||||
|
||||
// 播放页 BFF
|
||||
r.GET("/api/playlet/guess-like/list", web.GuessLikeList)
|
||||
r.GET("/api/comment/list", web.CommentList)
|
||||
r.POST("/api/comment/send", web.CommentSend)
|
||||
r.POST("/api/comment/like/set", web.CommentLikeSet)
|
||||
r.POST("/api/playlet/chapter/like/set", web.ChapterLikeSet)
|
||||
r.POST("/api/playlet/chapter/collect/set", web.ChapterCollectSet)
|
||||
r.POST("/api/playlet/chapter/watch/report", web.ChapterWatchReport)
|
||||
r.POST("/api/playlet/chapter/unlock", web.ChapterUnlock)
|
||||
r.POST("/api/playlet/like/set", web.PlayletLikeSet)
|
||||
r.POST("/api/playlet/collect/set", web.PlayletCollectSet)
|
||||
r.POST("/api/video/like/set", web.VideoLikeSet)
|
||||
r.POST("/api/video/collect/set", web.VideoCollectSet)
|
||||
r.GET("/api/web/search/suggest", web.SearchSuggest)
|
||||
|
||||
// 搜索引擎抓取、发现与站点所有权验证。
|
||||
r.GET("/robots.txt", web.RobotsTXT)
|
||||
r.GET("/llms.txt", web.LLMSTXT)
|
||||
if key := seoser.VerifyFromConfig().IndexNowKey; key != "" {
|
||||
r.GET("/"+key+".txt", web.SEOVerifyFile)
|
||||
}
|
||||
|
||||
r.NoRoute(web.NotFound)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
webtpl "videoweb/app/template"
|
||||
)
|
||||
|
||||
func mustHTMLTemplate(funcMap template.FuncMap) *template.Template {
|
||||
tmpl := template.New("").Funcs(funcMap)
|
||||
err := fs.WalkDir(webtpl.FS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(d.Name()), ".html") {
|
||||
return nil
|
||||
}
|
||||
body, err := webtpl.FS.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tmpl.New(filepath.Base(path)).Parse(string(body))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if tmpl.Lookup("index.html") == nil {
|
||||
panic("embedded html templates are empty")
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"videoweb/app/appg"
|
||||
commonhttp "videoweb/pub/common/http"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/m3u8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func registerM3U8Routes(r *gin.Engine) {
|
||||
r.GET("/h5/m3u8/*path", proxyM3U8)
|
||||
r.GET("/enkey", serveM3U8Key)
|
||||
r.GET("/newenkey", serveNewM3U8Key)
|
||||
}
|
||||
|
||||
func proxyM3U8(c *gin.Context) {
|
||||
rawPath := strings.TrimPrefix(c.Param("path"), "/")
|
||||
if rawPath == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
rawPath += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
|
||||
serverAddr := strings.TrimRight(strings.TrimSpace(appg.Conf.ServerAddr), "/")
|
||||
if serverAddr == "" {
|
||||
log.Warn("proxyM3U8 empty serverAddr")
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
targetURL := serverAddr + "/h5/m3u8/" + rawPath
|
||||
|
||||
// 播放地址本身带签名 token;勿强制带登录 Authorization。
|
||||
body, status, err := fetchM3U8Upstream(c, targetURL, "")
|
||||
if err != nil {
|
||||
log.Warn("proxyM3U8 request failed", log.Any("target", targetURL), log.E(err))
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
c.Status(status)
|
||||
return
|
||||
}
|
||||
if code, ok := jsonErrorCode(body); ok {
|
||||
log.Warn("proxyM3U8 upstream error",
|
||||
log.Any("target", targetURL),
|
||||
log.Any("code", code),
|
||||
log.Any("body", string(body)))
|
||||
c.Data(http.StatusBadGateway, "application/json; charset=utf-8", body)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := detectMediaContentType(rawPath)
|
||||
if isM3U8Content(rawPath, body) {
|
||||
contentType = "application/vnd.apple.mpegurl"
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, contentType, body)
|
||||
}
|
||||
|
||||
func fetchM3U8Upstream(c *gin.Context, targetURL, auth string) ([]byte, int, error) {
|
||||
header := map[string]string{
|
||||
"Accept": "*/*",
|
||||
}
|
||||
if auth != "" {
|
||||
header["Authorization"] = auth
|
||||
}
|
||||
applyClientForwardHeaders(header, c)
|
||||
resp, err := commonhttp.GetLargeFile(targetURL, header)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return resp.Data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func jsonErrorCode(body []byte) (int, bool) {
|
||||
if len(body) == 0 || body[0] != '{' {
|
||||
return 0, false
|
||||
}
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if payload.Code == 200 || payload.Code == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return payload.Code, true
|
||||
}
|
||||
|
||||
func isM3U8Content(rawPath string, body []byte) bool {
|
||||
if strings.Contains(strings.Split(rawPath, "?")[0], ".m3u8") {
|
||||
return true
|
||||
}
|
||||
return len(body) > 0 && body[0] == '#'
|
||||
}
|
||||
|
||||
func detectMediaContentType(rawPath string) string {
|
||||
pathOnly := strings.Split(rawPath, "?")[0]
|
||||
switch {
|
||||
case strings.HasSuffix(pathOnly, ".m3u8"):
|
||||
return "application/vnd.apple.mpegurl"
|
||||
case strings.HasSuffix(pathOnly, ".ts"):
|
||||
return "video/mp2t"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func serveM3U8Key(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
|
||||
if key, ok := fetchUpstreamEnkey(c, c.Request.URL.RawQuery); ok {
|
||||
c.Data(http.StatusOK, "application/octet-stream", key)
|
||||
return
|
||||
}
|
||||
|
||||
buffer, err := m3u8.Enkey(c.Request.Context())
|
||||
if err != nil || buffer == nil || buffer.Len() == 0 {
|
||||
log.Warn("serveM3U8Key local key unavailable", log.E(err))
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/octet-stream", buffer.Bytes())
|
||||
}
|
||||
|
||||
func serveNewM3U8Key(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
|
||||
key, ok := fetchUpstreamKey(c, "/newenkey", c.Request.URL.RawQuery)
|
||||
if !ok {
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/octet-stream", key)
|
||||
}
|
||||
|
||||
func fetchUpstreamEnkey(c *gin.Context, rawQuery string) ([]byte, bool) {
|
||||
return fetchUpstreamKey(c, "/enkey", rawQuery)
|
||||
}
|
||||
|
||||
func fetchUpstreamKey(c *gin.Context, keyPath, rawQuery string) ([]byte, bool) {
|
||||
serverAddr := strings.TrimRight(strings.TrimSpace(appg.Conf.ServerAddr), "/")
|
||||
if serverAddr == "" {
|
||||
return nil, false
|
||||
}
|
||||
// 上游对 /enkey、/newenkey 会 301 到带尾斜杠路径;GetLargeFile 不跟随跳转。
|
||||
if !strings.HasSuffix(keyPath, "/") {
|
||||
keyPath += "/"
|
||||
}
|
||||
targetURL := serverAddr + keyPath
|
||||
if rawQuery = strings.TrimSpace(rawQuery); rawQuery != "" {
|
||||
targetURL += "?" + rawQuery
|
||||
}
|
||||
header := map[string]string{
|
||||
"Accept": "*/*",
|
||||
}
|
||||
applyClientForwardHeaders(header, c)
|
||||
resp, err := commonhttp.GetLargeFile(targetURL, header)
|
||||
if err != nil {
|
||||
log.Warn("fetchUpstreamKey request failed", log.Any("target", targetURL), log.E(err))
|
||||
return nil, false
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || len(resp.Data) == 0 || jsonErrorCodeBody(resp.Data) {
|
||||
log.Warn("fetchUpstreamKey invalid response",
|
||||
log.Any("target", targetURL),
|
||||
log.Any("status", resp.StatusCode),
|
||||
log.Any("len", len(resp.Data)))
|
||||
return nil, false
|
||||
}
|
||||
return resp.Data, true
|
||||
}
|
||||
|
||||
func jsonErrorCodeBody(body []byte) bool {
|
||||
_, ok := jsonErrorCode(body)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"videoweb/app/api/web"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 其他项目(hgdjnet-server)写入 H5 目录的 sitemap 产物路径。
|
||||
var sitemapShardDirs = []string{
|
||||
"./template/pages/sitemaps",
|
||||
}
|
||||
|
||||
var sitemapIndexFiles = []string{
|
||||
"./template/pages/sitemaps/sitemap.xml",
|
||||
"./template/pages/sitemap.xml",
|
||||
}
|
||||
|
||||
// registerSitemapRoutes 只暴露静态 sitemap,不在本项目生成。
|
||||
func registerSitemapRoutes(r *gin.Engine) {
|
||||
r.GET("/sitemap.xml", serveSitemapIndex)
|
||||
r.GET("/sitemaps/:name", serveSitemapShard)
|
||||
}
|
||||
|
||||
func serveSitemapIndex(c *gin.Context) {
|
||||
path, ok := firstExistingFile(sitemapIndexFiles...)
|
||||
if !ok {
|
||||
web.NotFound(c)
|
||||
return
|
||||
}
|
||||
c.File(path)
|
||||
}
|
||||
|
||||
func serveSitemapShard(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if !isValidSitemapShardName(name) {
|
||||
web.NotFound(c)
|
||||
return
|
||||
}
|
||||
var candidates []string
|
||||
for _, dir := range sitemapShardDirs {
|
||||
candidates = append(candidates, filepath.Join(dir, name))
|
||||
}
|
||||
path, ok := firstExistingFile(candidates...)
|
||||
if !ok {
|
||||
web.NotFound(c)
|
||||
return
|
||||
}
|
||||
c.File(path)
|
||||
}
|
||||
|
||||
func firstExistingFile(paths ...string) (string, bool) {
|
||||
for _, path := range paths {
|
||||
info, err := os.Stat(path)
|
||||
if err == nil && !info.IsDir() {
|
||||
return path, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// isValidSitemapShardName 校验分片文件名,防止路径遍历。
|
||||
func isValidSitemapShardName(name string) bool {
|
||||
if !strings.HasPrefix(name, "sitemap-") || !strings.HasSuffix(name, ".xml") {
|
||||
return false
|
||||
}
|
||||
base := strings.TrimSuffix(name, ".xml")
|
||||
if len(base) <= len("sitemap-") {
|
||||
return false
|
||||
}
|
||||
for _, ch := range base[len("sitemap-"):] {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/app/router"
|
||||
|
||||
// "videoweb/app/service/configser"
|
||||
// "videoweb/app/service/translationser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/srvversion"
|
||||
|
||||
// "videoweb/pub/storage/proxy"
|
||||
// "videoweb/web/service"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
time.Local, _ = time.LoadLocation("Asia/Shanghai")
|
||||
}
|
||||
func Run(args []string) {
|
||||
chdirToAppRoot()
|
||||
showVersion()
|
||||
if err := initConfig(); err != nil {
|
||||
log.Error("init config failed", log.E(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
initHttpServer()
|
||||
initDHServer()
|
||||
//tgbot.SendToProgram("启动", "app启动完毕")
|
||||
shutdownHandler()
|
||||
}
|
||||
|
||||
// chdirToAppRoot 发版后 supervisor 的工作目录经常不是二进制所在目录。
|
||||
// 仅当二进制旁边已有 conf/app.yaml 时才切换,避免本地 go run 切到临时目录。
|
||||
func chdirToAppRoot() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
|
||||
dir = resolved
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "conf", "app.yaml")); err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
log.Warn("chdir app root failed", log.E(err), log.Any("dir", dir))
|
||||
}
|
||||
}
|
||||
|
||||
func initConfig() error {
|
||||
|
||||
buf, err := ioutil.ReadFile("conf/app.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = yaml.Unmarshal(buf, &appg.Conf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// headWriter 拦截 WriteHeader 和 Write,丢弃 body 并记录长度,
|
||||
// 在 flush 时补设 Content-Length 后再发送真正的状态码。
|
||||
type headWriter struct {
|
||||
http.ResponseWriter
|
||||
contentLength int
|
||||
statusCode int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (w *headWriter) WriteHeader(code int) {
|
||||
if !w.wroteHeader {
|
||||
w.statusCode = code
|
||||
w.wroteHeader = true
|
||||
}
|
||||
}
|
||||
|
||||
func (w *headWriter) Write(b []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.statusCode = http.StatusOK
|
||||
w.wroteHeader = true
|
||||
}
|
||||
w.contentLength += len(b)
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
// Flush 实现 http.Flusher,HEAD 响应无 body,做 no-op
|
||||
func (w *headWriter) Flush() {}
|
||||
|
||||
// Unwrap 让 http.NewResponseController 能发现底层 writer 的其他接口
|
||||
func (w *headWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
// flush 将缓存的 Content-Length 和状态码写入底层 ResponseWriter
|
||||
func (w *headWriter) flush() {
|
||||
if !w.wroteHeader {
|
||||
w.statusCode = http.StatusOK
|
||||
w.wroteHeader = true
|
||||
}
|
||||
if w.contentLength > 0 && w.Header().Get("Content-Length") == "" {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(w.contentLength))
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(w.statusCode)
|
||||
}
|
||||
|
||||
// headToGet 将 HEAD 改写为 GET 让 Gin 路由匹配,同时用 headWriter 丢弃 body。
|
||||
// 通过 X-Origin-Head 内部 header 让 templatecache / filecache 等中间件识别出原始 HEAD 请求,
|
||||
// 从而避免一次性 URL 污染缓存造成 DoS 放大(进入前先 Del 防客户端伪造)。
|
||||
func headToGet(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Header.Del("X-Origin-Head")
|
||||
if r.Method == http.MethodHead {
|
||||
r.Method = http.MethodGet
|
||||
r.Header.Set("X-Origin-Head", "1")
|
||||
hw := &headWriter{ResponseWriter: w}
|
||||
h.ServeHTTP(hw, r)
|
||||
hw.flush()
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func initHttpServer() {
|
||||
addr := fmt.Sprintf(":%s", appg.Conf.ListenAddr)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: headToGet(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 initDHServer() {
|
||||
if strings.TrimSpace(appg.Conf.DHListenAddr) == "" {
|
||||
return
|
||||
}
|
||||
addr := fmt.Sprintf(":%s", appg.Conf.DHListenAddr)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: headToGet(router.DHInit()),
|
||||
}
|
||||
go func() {
|
||||
log.Info("导航服务已启动", log.Any("地址", addr))
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal("listen:", log.E(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func shutdownHandler() {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Kill)
|
||||
<-quit
|
||||
log.Warn("Shutdown... server")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package applistser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const listPath = "/api/app/applist/list"
|
||||
|
||||
// FetchList 拉取应用分类列表。categoryId=0 表示默认。
|
||||
func FetchList(c *gin.Context, categoryID int64) (*AppListResp, error) {
|
||||
path := fmt.Sprintf("%s?categoryId=%d", listPath, categoryID)
|
||||
var env APIEnvelope[AppListResp]
|
||||
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("applist code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchListOrEmpty 失败时返回空结构,保证 SSR 可渲染。
|
||||
func FetchListOrEmpty(c *gin.Context, categoryID int64) *AppListResp {
|
||||
data, err := FetchList(c, categoryID)
|
||||
if err != nil {
|
||||
log.Error("fetch applist failed", log.E(err))
|
||||
return &AppListResp{}
|
||||
}
|
||||
if data == nil {
|
||||
return &AppListResp{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package applistser
|
||||
|
||||
// AppListResp 对应 swagger proto.AppListResp 的业务字段(忽略无关 config/advitise)。
|
||||
type AppListResp struct {
|
||||
Category AppCategoryInfo `json:"category"`
|
||||
CategoryList []AppCategoryInfo `json:"categoryList"`
|
||||
}
|
||||
|
||||
// AppCategoryInfo 对应 swagger proto.AppCategoryInfo
|
||||
type AppCategoryInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Code int `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Intro string `json:"intro"`
|
||||
Sort int `json:"sort"`
|
||||
AppList []AppInfoData `json:"appList"`
|
||||
}
|
||||
|
||||
// AppInfoData 对应 swagger proto.AppInfoData
|
||||
type AppInfoData struct {
|
||||
ID int64 `json:"id"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
Name string `json:"name"`
|
||||
Intro string `json:"intro"`
|
||||
Description string `json:"description"`
|
||||
Logo string `json:"logo"`
|
||||
URL string `json:"url"`
|
||||
Sort int `json:"sort"`
|
||||
AdvitiseID string `json:"advitiseId"`
|
||||
Location int `json:"location"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package applistser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PageVM 福利应用页模板数据
|
||||
type PageVM struct {
|
||||
Categories []CategoryVM
|
||||
}
|
||||
|
||||
type CategoryVM struct {
|
||||
ID int64
|
||||
SectionID string
|
||||
Title string
|
||||
Intro string
|
||||
Total int
|
||||
Apps []AppCardVM
|
||||
IsFirst bool
|
||||
}
|
||||
|
||||
type AppCardVM struct {
|
||||
ID int64
|
||||
Name string
|
||||
Intro string
|
||||
Logo string
|
||||
URL string
|
||||
}
|
||||
|
||||
// BuildPageVM 按接口返回的 categoryList / appList 顺序组装页面数据,不做二次排序。
|
||||
func BuildPageVM(data *AppListResp) PageVM {
|
||||
vm := PageVM{Categories: []CategoryVM{}}
|
||||
if data == nil {
|
||||
return vm
|
||||
}
|
||||
list := data.CategoryList
|
||||
if len(list) == 0 && data.Category.ID > 0 {
|
||||
list = []AppCategoryInfo{data.Category}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return vm
|
||||
}
|
||||
|
||||
for i, cat := range list {
|
||||
cards := make([]AppCardVM, 0, len(cat.AppList))
|
||||
for _, app := range cat.AppList {
|
||||
name := strings.TrimSpace(app.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
href := strings.TrimSpace(app.URL)
|
||||
if href == "" {
|
||||
href = "#"
|
||||
}
|
||||
intro := strings.TrimSpace(app.Intro)
|
||||
if intro == "" {
|
||||
intro = strings.TrimSpace(app.Description)
|
||||
}
|
||||
cards = append(cards, AppCardVM{
|
||||
ID: app.ID,
|
||||
Name: name,
|
||||
Intro: intro,
|
||||
Logo: strings.TrimSpace(app.Logo),
|
||||
URL: href,
|
||||
})
|
||||
}
|
||||
title := strings.TrimSpace(cat.Title)
|
||||
if title == "" {
|
||||
title = "应用分类"
|
||||
}
|
||||
vm.Categories = append(vm.Categories, CategoryVM{
|
||||
ID: cat.ID,
|
||||
SectionID: fmt.Sprintf("app-category-%d", cat.ID),
|
||||
Title: title,
|
||||
Intro: strings.TrimSpace(cat.Intro),
|
||||
Total: len(cards),
|
||||
Apps: cards,
|
||||
IsFirst: i == 0,
|
||||
})
|
||||
}
|
||||
return vm
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
registerPath = "/api/app/auth/register"
|
||||
loginPath = "/api/app/auth/login"
|
||||
resetPath = "/api/app/auth/resetPassword"
|
||||
guestPath = "/api/app/auth/guest"
|
||||
getInfoPath = "/api/app/auth/getInfo"
|
||||
profilePath = "/api/app/auth/profile"
|
||||
uploadPath = "/api/app/upload/image"
|
||||
cookieMaxAge = 7 * 24 * 3600
|
||||
)
|
||||
|
||||
// Register 调用上游注册接口。
|
||||
func Register(c *gin.Context, req RegisterReq) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, registerPath, req, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// ResetPassword 通过账号和邮箱校验后重置密码。
|
||||
func ResetPassword(c *gin.Context, req ResetPasswordReq) (*APIEnvelope, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, resetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// Login 调用上游登录接口。
|
||||
func Login(c *gin.Context, req LoginReq) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, loginPath, req, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// GuestLogin 调用上游游客登录(按设备 ID 创建或找回游客账号)。
|
||||
func GuestLogin(c *gin.Context, req GuestLoginReq) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, guestPath, req, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// GetInfo 通过 token 拉取最新用户信息。
|
||||
func GetInfo(c *gin.Context) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if err := upstream.GetJSON(c, getInfoPath, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// UpdateProfile 修改资料(昵称 / 头像等)。
|
||||
func UpdateProfile(c *gin.Context, req UpdateProfileReq) (*APIEnvelope, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, profilePath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// UploadImage 上传图片;头像场景不加水印。
|
||||
func UploadImage(c *gin.Context, fileName string, r io.Reader) (*ImageUploadResp, error) {
|
||||
var resp ImageUploadResp
|
||||
_, err := upstream.PostMultipart(c, uploadPath, map[string]string{
|
||||
"watermark": "0",
|
||||
}, &upstream.MultipartFile{
|
||||
FieldName: "file",
|
||||
FileName: fileName,
|
||||
Reader: r,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func decodeLoginData(raw json.RawMessage) (*LoginResp, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" || string(raw) == `""` {
|
||||
return nil, fmt.Errorf("empty login data")
|
||||
}
|
||||
var user LoginResp
|
||||
if err := json.Unmarshal(raw, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// SetAuthCookies 只持久化 SSR 与前端登录态实际依赖的字段。
|
||||
// 个人简介、邮箱、邀请码、展示卡信息由 getInfo 即时提供,不再随每个页面响应重复写入;
|
||||
// sid 已存在于请求时也无需原样回写。ClearAuthCookies 仍保留这些历史键用于兼容清理。
|
||||
func SetAuthCookies(c *gin.Context, user *LoginResp) {
|
||||
if c == nil || user == nil {
|
||||
return
|
||||
}
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
if strings.TrimSpace(user.Token) != "" {
|
||||
setCookie(c, "token", user.Token, secure)
|
||||
}
|
||||
setCookie(c, "userId", strconv.FormatInt(user.UserID, 10), secure)
|
||||
setCookie(c, "accountNo", user.AccountNo, secure)
|
||||
setCookie(c, "nickName", user.NickName, secure)
|
||||
setCookie(c, "avatar", user.Avatar, secure)
|
||||
setCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10), secure)
|
||||
setCookie(c, "vipType", strconv.Itoa(user.VipType), secure)
|
||||
setCookie(c, "currentVipExpire", user.CurrentVipExpire, secure)
|
||||
setCookie(c, "userType", strconv.Itoa(user.Type), secure)
|
||||
isReg := "0"
|
||||
if user.IsRegister || user.Type == 1 {
|
||||
isReg = "1"
|
||||
}
|
||||
setCookie(c, "isRegister", isReg, secure)
|
||||
// 后续 SSR 服务必须能立即读取本次刚建立/刷新的会话;同时记录为本请求的
|
||||
// 权威结果,避免 HTML 渲染阶段再次调用 getInfo 并重复写整套 Cookie。
|
||||
patchAuthCookiesToRequest(c, user)
|
||||
rememberRequestAuthUser(c, user)
|
||||
}
|
||||
|
||||
func setCookie(c *gin.Context, name, value string, secure bool) {
|
||||
// Cookie Value 仅允许 ASCII;中文昵称等需 URL 编码。
|
||||
// 使用 PathEscape(空格→%20),与前端 encodeURIComponent 一致;
|
||||
// 避免 QueryEscape 的空格→+,否则 JS decodeURIComponent 会留下字面量 "+"。
|
||||
writeCookie(c, &http.Cookie{
|
||||
Name: name,
|
||||
Value: url.PathEscape(value),
|
||||
Path: "/",
|
||||
MaxAge: cookieMaxAge,
|
||||
HttpOnly: false,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(7 * 24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
// writeCookie 对同一响应中同名、同 Path、同 Domain 的 Cookie 做 upsert,避免会话恢复的
|
||||
// 「先清除、再重建」产生两套响应头,同时保留其他作用域下可能存在的同名 Cookie。
|
||||
func writeCookie(c *gin.Context, cookie *http.Cookie) {
|
||||
if c == nil || c.Writer == nil || cookie == nil || cookie.Name == "" {
|
||||
return
|
||||
}
|
||||
header := c.Writer.Header()
|
||||
existing := header.Values("Set-Cookie")
|
||||
if len(existing) > 0 {
|
||||
header.Del("Set-Cookie")
|
||||
for _, raw := range existing {
|
||||
if sameCookieScope(raw, cookie) {
|
||||
continue
|
||||
}
|
||||
header.Add("Set-Cookie", raw)
|
||||
}
|
||||
}
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
}
|
||||
|
||||
func sameCookieScope(raw string, target *http.Cookie) bool {
|
||||
response := &http.Response{Header: http.Header{"Set-Cookie": []string{raw}}}
|
||||
for _, existing := range response.Cookies() {
|
||||
if existing.Name == target.Name && existing.Path == target.Path && strings.EqualFold(existing.Domain, target.Domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cookieValue(c *gin.Context, name string) string {
|
||||
ck, err := c.Request.Cookie(name)
|
||||
if err != nil || ck == nil {
|
||||
return ""
|
||||
}
|
||||
raw := strings.TrimSpace(ck.Value)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// QueryUnescape 同时兼容历史 QueryEscape(+)与 PathEscape(%20)
|
||||
if decoded, err := url.QueryUnescape(raw); err == nil {
|
||||
return strings.TrimSpace(decoded)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ClearAuthCookies 清除登录相关 cookie。
|
||||
func ClearAuthCookies(c *gin.Context) {
|
||||
forgetRequestAuthUser(c)
|
||||
for _, name := range authCookieNames() {
|
||||
writeCookie(c, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func authCookieNames() []string {
|
||||
return []string{
|
||||
"token", "_token_", "sid", "_s_token_",
|
||||
"userId", "accountNo", "nickName", "introduction", "avatar",
|
||||
"email", "inviteCode", "recharge", "vipType", "currentVipExpire", "showCardId", "showCardName",
|
||||
"userType", "isRegister",
|
||||
}
|
||||
}
|
||||
|
||||
// AuthFromCookies 从 cookie 读取登录态,供 SSR 头部 / 个人中心展示。
|
||||
func AuthFromCookies(c *gin.Context) (loggedIn bool, userName, avatar, email string) {
|
||||
token := useragent.TokenFromRequest(c.Request)
|
||||
if token == "" {
|
||||
return false, "", "", ""
|
||||
}
|
||||
userName = cookieValue(c, "nickName")
|
||||
if userName == "" {
|
||||
userName = cookieValue(c, "accountNo")
|
||||
}
|
||||
avatar = usableAvatarURL(cookieValue(c, "avatar"))
|
||||
email = cookieValue(c, "email")
|
||||
return true, userName, avatar, email
|
||||
}
|
||||
|
||||
// IsRegisteredFromCookies 是否正式注册账号(非游客)。
|
||||
func IsRegisteredFromCookies(c *gin.Context) bool {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
return false
|
||||
}
|
||||
if cookieValue(c, "isRegister") == "1" {
|
||||
return true
|
||||
}
|
||||
return cookieValue(c, "userType") == "1"
|
||||
}
|
||||
|
||||
func usableAvatarURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "/static/") {
|
||||
return raw
|
||||
}
|
||||
// 无域名的相对路径暂不可用(解密拉取需要完整 URL)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const guestDevIDCookie = "guest_dev_id"
|
||||
const guestSkipAutoCookie = "guest_skip_auto"
|
||||
|
||||
// EnsureGuestOnEnter 页面进入时:无 token(未自己登录)则走游客登录并写 cookie。
|
||||
// 对应上游 POST /api/app/auth/guest。成功后会把会话补进当前 Request,便于同一次 SSR 读到登录态。
|
||||
// 手动退出后带 guest_skip_auto 时跳过本次;清掉标记后,下次进页再自动游客。
|
||||
func EnsureGuestOnEnter(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
if user := freshRequestAuthUser(c); user != nil {
|
||||
return user
|
||||
}
|
||||
if requestAutoAuthSuppressed(c) {
|
||||
return nil
|
||||
}
|
||||
// 已有任意会话(游客或正式账号)则跳过
|
||||
if useragent.TokenFromRequest(c.Request) != "" {
|
||||
return nil
|
||||
}
|
||||
// 手动退出:本次进页不自动游客,并清除标记,供下次进页恢复
|
||||
if IsGuestSkipAuto(c) {
|
||||
ClearGuestSkipAuto(c)
|
||||
dropRequestCookie(c, guestSkipAutoCookie)
|
||||
suppressRequestAutoAuth(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
devID := ensureGuestDevID(c)
|
||||
env, user, err := GuestLogin(c, GuestLoginReq{
|
||||
DevID: devID,
|
||||
DeviceID: devID,
|
||||
Channel: guestChannel(c),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("ensure guest on enter failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
if env == nil || env.Code != 200 || user == nil || strings.TrimSpace(user.Token) == "" {
|
||||
tip := ""
|
||||
if env != nil {
|
||||
tip = firstNonEmptyTip(env.Tip, env.Msg)
|
||||
}
|
||||
log.Error("ensure guest on enter rejected", log.E(fmt.Errorf("code=%v tip=%s", envCode(env), tip)))
|
||||
return nil
|
||||
}
|
||||
|
||||
SetAuthCookies(c, user)
|
||||
return user
|
||||
}
|
||||
|
||||
// SyncUserOnEnter 是 SSR 页面统一的会话同步入口。同一请求已经通过游客登录、
|
||||
// getInfo 或其他认证流程取得过最新用户时直接复用,避免再次访问上游和重复写 Cookie。
|
||||
func SyncUserOnEnter(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
if user := freshRequestAuthUser(c); user != nil {
|
||||
return user
|
||||
}
|
||||
if requestAutoAuthSuppressed(c) {
|
||||
return nil
|
||||
}
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
return EnsureGuestOnEnter(c)
|
||||
}
|
||||
return RefreshUserOnEnter(c)
|
||||
}
|
||||
|
||||
// RefreshUserOnEnter 页面进入时用 GET /api/app/auth/getInfo 刷新用户信息并写回 cookie。
|
||||
// 保证刷新后头部昵称、头像、金币等与上游一致(如解锁后余额变化)。
|
||||
func RefreshUserOnEnter(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
if user := freshRequestAuthUser(c); user != nil {
|
||||
return user
|
||||
}
|
||||
if requestAutoAuthSuppressed(c) {
|
||||
return nil
|
||||
}
|
||||
token := useragent.TokenFromRequest(c.Request)
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
env, user, err := GetInfo(c)
|
||||
if err != nil {
|
||||
log.Error("refresh user on enter failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
if env != nil && IsSessionExpiredCode(env.Code) {
|
||||
if retry, _ := OnSessionExpired(c, env.Code); retry {
|
||||
// 游客重建已经返回完整用户并写好 Cookie,无需立刻再 getInfo 一次。
|
||||
if fresh := freshRequestAuthUser(c); fresh != nil {
|
||||
return fresh
|
||||
}
|
||||
env, user, err = GetInfo(c)
|
||||
if err != nil {
|
||||
log.Error("refresh user on enter retry failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if env == nil || env.Code != 200 || user == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(user.Token) == "" {
|
||||
user.Token = useragent.TokenFromRequest(c.Request)
|
||||
if user.Token == "" {
|
||||
user.Token = token
|
||||
}
|
||||
}
|
||||
SetAuthCookies(c, user)
|
||||
return user
|
||||
}
|
||||
|
||||
// RefreshGuestSession 游客会话失效时(如上游 6001 用户不存在)重新游客登录。
|
||||
// 正式注册账号不处理,避免把用户踢成游客。
|
||||
func RefreshGuestSession(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
if IsRegisteredFromCookies(c) {
|
||||
return nil
|
||||
}
|
||||
forgetRequestAuthUser(c)
|
||||
dropRequestCookie(c, "token")
|
||||
dropRequestCookie(c, "_token_")
|
||||
return EnsureGuestOnEnter(c)
|
||||
}
|
||||
|
||||
func dropRequestCookie(c *gin.Context, name string) {
|
||||
if c == nil || c.Request == nil || name == "" {
|
||||
return
|
||||
}
|
||||
var parts []string
|
||||
for _, ck := range c.Request.Cookies() {
|
||||
if ck == nil || ck.Name == name {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, ck.Name+"="+ck.Value)
|
||||
}
|
||||
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
// MarkGuestSkipAuto 手动退出后标记:跳过紧接着的一次自动游客登录。
|
||||
func MarkGuestSkipAuto(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
suppressRequestAutoAuth(c)
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
writeCookie(c, &http.Cookie{
|
||||
Name: guestSkipAutoCookie,
|
||||
Value: "1",
|
||||
Path: "/",
|
||||
MaxAge: 0, // session cookie
|
||||
HttpOnly: false,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// IsGuestSkipAuto 是否处于「手动退出后跳过自动游客」状态。
|
||||
func IsGuestSkipAuto(c *gin.Context) bool {
|
||||
return cookieValue(c, guestSkipAutoCookie) == "1"
|
||||
}
|
||||
|
||||
// ClearGuestSkipAuto 清除手动退出跳过标记。
|
||||
func ClearGuestSkipAuto(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
writeCookie(c, &http.Cookie{
|
||||
Name: guestSkipAutoCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func ensureGuestDevID(c *gin.Context) string {
|
||||
if id := cookieValue(c, guestDevIDCookie); id != "" {
|
||||
return id
|
||||
}
|
||||
id := newGuestDevID()
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
setCookie(c, guestDevIDCookie, id, secure)
|
||||
patchRequestCookie(c, guestDevIDCookie, id)
|
||||
return id
|
||||
}
|
||||
|
||||
func newGuestDevID() string {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
now := time.Now()
|
||||
return fmt.Sprintf("%016X%d", uint64(now.UnixNano()), now.UnixMilli())
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(b)) + strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
}
|
||||
|
||||
// ResolveChannel 统一解析渠道码并按首次归因落盘。
|
||||
// 优先级:已有 channel cookie > 调用方传入值 > dc/ch/pc 查询参数;已有渠道不覆盖。
|
||||
func ResolveChannel(c *gin.Context, candidate string) string {
|
||||
channel := strings.TrimSpace(candidate)
|
||||
if c == nil || c.Request == nil {
|
||||
return channel
|
||||
}
|
||||
if stored := cookieValue(c, "channel"); stored != "" {
|
||||
return stored
|
||||
}
|
||||
if channel == "" {
|
||||
for _, key := range []string{"dc", "ch", "pc"} {
|
||||
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||
channel = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if channel == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
secure := c.Request.TLS != nil
|
||||
setCookie(c, "channel", channel, secure)
|
||||
patchRequestCookie(c, "channel", channel)
|
||||
return channel
|
||||
}
|
||||
|
||||
// guestChannel 为首屏服务端自动游客登录补渠道;cookie 用于复用前端首次归因结果。
|
||||
func guestChannel(c *gin.Context) string {
|
||||
return ResolveChannel(c, "")
|
||||
}
|
||||
|
||||
func patchAuthCookiesToRequest(c *gin.Context, user *LoginResp) {
|
||||
if c == nil || user == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(user.Token) != "" {
|
||||
patchRequestCookie(c, "token", user.Token)
|
||||
}
|
||||
patchRequestCookie(c, "userId", strconv.FormatInt(user.UserID, 10))
|
||||
patchRequestCookie(c, "accountNo", user.AccountNo)
|
||||
patchRequestCookie(c, "nickName", user.NickName)
|
||||
patchRequestCookie(c, "introduction", user.Introduction)
|
||||
patchRequestCookie(c, "avatar", user.Avatar)
|
||||
patchRequestCookie(c, "email", user.Email)
|
||||
patchRequestCookie(c, "inviteCode", user.InviteCode)
|
||||
patchRequestCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10))
|
||||
patchRequestCookie(c, "vipType", strconv.Itoa(user.VipType))
|
||||
patchRequestCookie(c, "currentVipExpire", user.CurrentVipExpire)
|
||||
patchRequestCookie(c, "showCardId", strconv.FormatInt(user.ShowCardID, 10))
|
||||
patchRequestCookie(c, "showCardName", user.ShowCardName)
|
||||
patchRequestCookie(c, "userType", strconv.Itoa(user.Type))
|
||||
isReg := "0"
|
||||
if user.IsRegister || user.Type == 1 {
|
||||
isReg = "1"
|
||||
}
|
||||
patchRequestCookie(c, "isRegister", isReg)
|
||||
}
|
||||
|
||||
func patchRequestCookie(c *gin.Context, name, value string) {
|
||||
if c == nil || c.Request == nil || name == "" {
|
||||
return
|
||||
}
|
||||
escaped := url.PathEscape(value)
|
||||
var parts []string
|
||||
for _, ck := range c.Request.Cookies() {
|
||||
if ck == nil || ck.Name == name {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, ck.Name+"="+ck.Value)
|
||||
}
|
||||
parts = append(parts, name+"="+escaped)
|
||||
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
func envCode(env *APIEnvelope) int {
|
||||
if env == nil {
|
||||
return 0
|
||||
}
|
||||
return env.Code
|
||||
}
|
||||
|
||||
func firstNonEmptyTip(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var authCookieNamesWritten = []string{
|
||||
"token",
|
||||
"userId",
|
||||
"accountNo",
|
||||
"nickName",
|
||||
"avatar",
|
||||
"recharge",
|
||||
"vipType",
|
||||
"currentVipExpire",
|
||||
"userType",
|
||||
"isRegister",
|
||||
}
|
||||
|
||||
func TestSyncUserOnEnterCreatesGuestWithoutRefreshingIt(t *testing.T) {
|
||||
var guestCalls atomic.Int32
|
||||
var getInfoCalls atomic.Int32
|
||||
|
||||
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case guestPath:
|
||||
guestCalls.Add(1)
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("guest method = %s, want POST", r.Method)
|
||||
}
|
||||
var request GuestLoginReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode guest request: %v", err)
|
||||
}
|
||||
if request.DevID == "" || request.DeviceID != request.DevID {
|
||||
t.Errorf("guest device ids = %#v, want matching non-empty ids", request)
|
||||
}
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "new-guest"))
|
||||
case getInfoPath:
|
||||
getInfoCalls.Add(1)
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "unexpected-refresh"))
|
||||
default:
|
||||
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
c, recorder := newAuthTestContext()
|
||||
user := SyncUserOnEnter(c)
|
||||
|
||||
if user == nil || user.Token != "guest-token" {
|
||||
t.Fatalf("SyncUserOnEnter user = %#v, want guest-token", user)
|
||||
}
|
||||
if got := guestCalls.Load(); got != 1 {
|
||||
t.Errorf("guest calls = %d, want 1", got)
|
||||
}
|
||||
if got := getInfoCalls.Load(); got != 0 {
|
||||
t.Errorf("getInfo calls = %d, want 0", got)
|
||||
}
|
||||
assertRequestToken(t, c, "guest-token")
|
||||
assertSingleAuthCookieSet(t, recorder)
|
||||
}
|
||||
|
||||
func TestSyncUserOnEnterRefreshesExistingTokenOnlyOnce(t *testing.T) {
|
||||
var guestCalls atomic.Int32
|
||||
var getInfoCalls atomic.Int32
|
||||
authorization := make(chan string, 1)
|
||||
|
||||
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case guestPath:
|
||||
guestCalls.Add(1)
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("unexpected-guest-token", "unexpected-guest"))
|
||||
case getInfoPath:
|
||||
getInfoCalls.Add(1)
|
||||
select {
|
||||
case authorization <- r.Header.Get("Authorization"):
|
||||
default:
|
||||
}
|
||||
// getInfo may omit token; the existing request token must be preserved.
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("", "fresh-user"))
|
||||
default:
|
||||
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
c, recorder := newAuthTestContext(
|
||||
&http.Cookie{Name: "token", Value: "existing-token"},
|
||||
&http.Cookie{Name: "sid", Value: "existing-sid"},
|
||||
)
|
||||
first := SyncUserOnEnter(c)
|
||||
second := SyncUserOnEnter(c)
|
||||
|
||||
if first == nil || first.Token != "existing-token" || first.NickName != "fresh-user" {
|
||||
t.Fatalf("first SyncUserOnEnter user = %#v, want refreshed user with existing token", first)
|
||||
}
|
||||
if second == nil || second.Token != "existing-token" || second.NickName != "fresh-user" {
|
||||
t.Fatalf("second SyncUserOnEnter user = %#v, want memoized refreshed user", second)
|
||||
}
|
||||
if got := guestCalls.Load(); got != 0 {
|
||||
t.Errorf("guest calls = %d, want 0", got)
|
||||
}
|
||||
if got := getInfoCalls.Load(); got != 1 {
|
||||
t.Errorf("getInfo calls = %d, want 1", got)
|
||||
}
|
||||
select {
|
||||
case got := <-authorization:
|
||||
if got != "existing-token" {
|
||||
t.Errorf("Authorization = %q, want existing-token", got)
|
||||
}
|
||||
default:
|
||||
t.Error("getInfo did not capture Authorization header")
|
||||
}
|
||||
assertRequestToken(t, c, "existing-token")
|
||||
if got := cookieValue(c, "nickName"); got != "fresh-user" {
|
||||
t.Errorf("request nickName cookie = %q, want fresh-user", got)
|
||||
}
|
||||
assertSingleAuthCookieSet(t, recorder)
|
||||
}
|
||||
|
||||
func TestSyncUserOnEnterReusesGuestCreatedBeforeRender(t *testing.T) {
|
||||
var guestCalls atomic.Int32
|
||||
var getInfoCalls atomic.Int32
|
||||
|
||||
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case guestPath:
|
||||
guestCalls.Add(1)
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "pre-render-guest"))
|
||||
case getInfoPath:
|
||||
getInfoCalls.Add(1)
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "unexpected-refresh"))
|
||||
default:
|
||||
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
c, recorder := newAuthTestContext()
|
||||
guest := EnsureGuestOnEnter(c)
|
||||
user := SyncUserOnEnter(c)
|
||||
|
||||
if guest == nil || guest.Token != "pre-render-token" {
|
||||
t.Fatalf("EnsureGuestOnEnter user = %#v, want pre-render guest", guest)
|
||||
}
|
||||
if user == nil || user.Token != "pre-render-token" || user.NickName != "pre-render-guest" {
|
||||
t.Fatalf("SyncUserOnEnter user = %#v, want memoized pre-render guest", user)
|
||||
}
|
||||
if got := guestCalls.Load(); got != 1 {
|
||||
t.Errorf("guest calls = %d, want 1", got)
|
||||
}
|
||||
if got := getInfoCalls.Load(); got != 0 {
|
||||
t.Errorf("getInfo calls = %d, want 0", got)
|
||||
}
|
||||
assertRequestToken(t, c, "pre-render-token")
|
||||
assertSingleAuthCookieSet(t, recorder)
|
||||
}
|
||||
|
||||
func TestRefreshUserOnEnterReturnsRebuiltGuestWithoutRefreshingItAgain(t *testing.T) {
|
||||
var guestCalls atomic.Int32
|
||||
var getInfoCalls atomic.Int32
|
||||
getInfoAuthorization := make(chan string, 1)
|
||||
guestAuthorization := make(chan string, 1)
|
||||
|
||||
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case getInfoPath:
|
||||
getInfoCalls.Add(1)
|
||||
select {
|
||||
case getInfoAuthorization <- r.Header.Get("Authorization"):
|
||||
default:
|
||||
}
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 6001, nil)
|
||||
case guestPath:
|
||||
guestCalls.Add(1)
|
||||
select {
|
||||
case guestAuthorization <- r.Header.Get("Authorization"):
|
||||
default:
|
||||
}
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("rebuilt-guest-token", "rebuilt-guest"))
|
||||
default:
|
||||
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
c, recorder := newAuthTestContext(
|
||||
&http.Cookie{Name: "token", Value: "expired-guest-token"},
|
||||
&http.Cookie{Name: "isRegister", Value: "0"},
|
||||
&http.Cookie{Name: "userType", Value: "0"},
|
||||
)
|
||||
user := RefreshUserOnEnter(c)
|
||||
// The rebuilt guest must also be reusable by the page-level sync call.
|
||||
synced := SyncUserOnEnter(c)
|
||||
|
||||
if user == nil || user.Token != "rebuilt-guest-token" || user.NickName != "rebuilt-guest" {
|
||||
t.Fatalf("RefreshUserOnEnter user = %#v, want rebuilt guest", user)
|
||||
}
|
||||
if synced == nil || synced.Token != "rebuilt-guest-token" {
|
||||
t.Fatalf("SyncUserOnEnter user = %#v, want memoized rebuilt guest", synced)
|
||||
}
|
||||
if got := getInfoCalls.Load(); got != 1 {
|
||||
t.Errorf("getInfo calls = %d, want 1 (no refresh of rebuilt guest)", got)
|
||||
}
|
||||
if got := guestCalls.Load(); got != 1 {
|
||||
t.Errorf("guest calls = %d, want 1", got)
|
||||
}
|
||||
select {
|
||||
case got := <-getInfoAuthorization:
|
||||
if got != "expired-guest-token" {
|
||||
t.Errorf("getInfo Authorization = %q, want expired-guest-token", got)
|
||||
}
|
||||
default:
|
||||
t.Error("getInfo did not capture Authorization header")
|
||||
}
|
||||
select {
|
||||
case got := <-guestAuthorization:
|
||||
if got != "" {
|
||||
t.Errorf("guest Authorization = %q, want empty after expired token is removed", got)
|
||||
}
|
||||
default:
|
||||
t.Error("guest login did not capture Authorization header")
|
||||
}
|
||||
assertRequestToken(t, c, "rebuilt-guest-token")
|
||||
assertSingleAuthCookieSet(t, recorder)
|
||||
}
|
||||
|
||||
func TestSetAuthCookiesUpsertsSameNameWithinResponse(t *testing.T) {
|
||||
c, recorder := newAuthTestContext()
|
||||
SetAuthCookies(c, testLoginUser("first-token", "first-user"))
|
||||
SetAuthCookies(c, testLoginUser("latest-token", "latest-user"))
|
||||
|
||||
assertSingleAuthCookieSet(t, recorder)
|
||||
assertRequestToken(t, c, "latest-token")
|
||||
if got := cookieValue(c, "nickName"); got != "latest-user" {
|
||||
t.Errorf("request nickName cookie = %q, want latest-user", got)
|
||||
}
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.Name == "token" && cookie.Value != "latest-token" {
|
||||
t.Errorf("response token cookie = %q, want latest-token", cookie.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func useAuthTestUpstream(t *testing.T, handler http.Handler) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
previousServerAddr := appg.Conf.ServerAddr
|
||||
appg.Conf.ServerAddr = server.URL
|
||||
t.Cleanup(func() {
|
||||
appg.Conf.ServerAddr = previousServerAddr
|
||||
server.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func newAuthTestContext(cookies ...*http.Cookie) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "https://site.test/page", nil)
|
||||
for _, cookie := range cookies {
|
||||
c.Request.AddCookie(cookie)
|
||||
}
|
||||
return c, recorder
|
||||
}
|
||||
|
||||
func writeAuthTestEnvelope(w http.ResponseWriter, status, code int, data *LoginResp) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data *LoginResp `json:"data"`
|
||||
}{
|
||||
Code: code,
|
||||
Msg: http.StatusText(status),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func testLoginUser(token, nickName string) *LoginResp {
|
||||
return &LoginResp{
|
||||
Token: token,
|
||||
UserID: 101,
|
||||
AccountNo: "account-101",
|
||||
Email: "user101@example.test",
|
||||
NickName: nickName,
|
||||
// This field is intentionally large: it must stay out of Set-Cookie headers.
|
||||
Introduction: strings.Repeat("简介", 150),
|
||||
Avatar: "https://cdn.example.test/avatar.png",
|
||||
InviteCode: "INVITE101",
|
||||
VipType: 1,
|
||||
CurrentVipExpire: "2099-01-01T00:00:00Z",
|
||||
ShowCardID: 7,
|
||||
ShowCardName: "monthly",
|
||||
Type: 0,
|
||||
Recharge: 88,
|
||||
}
|
||||
}
|
||||
|
||||
func assertRequestToken(t *testing.T, c *gin.Context, want string) {
|
||||
t.Helper()
|
||||
if got := useragent.TokenFromRequest(c.Request); got != want {
|
||||
t.Errorf("request token = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSingleAuthCookieSet(t *testing.T, recorder *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
counts := make(map[string]int)
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
counts[cookie.Name]++
|
||||
}
|
||||
for name, count := range counts {
|
||||
if count > 1 {
|
||||
t.Errorf("Set-Cookie %q written %d times, want at most once", name, count)
|
||||
}
|
||||
}
|
||||
for _, name := range authCookieNamesWritten {
|
||||
if got := counts[name]; got != 1 {
|
||||
t.Errorf("auth Set-Cookie %q written %d times, want exactly once", name, got)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"introduction", "email", "inviteCode", "showCardId", "showCardName", "sid"} {
|
||||
if got := counts[name]; got != 0 {
|
||||
t.Errorf("non-session Set-Cookie %q written %d times, want none", name, got)
|
||||
}
|
||||
}
|
||||
var headerBytes int
|
||||
for _, raw := range recorder.Header().Values("Set-Cookie") {
|
||||
headerBytes += len("Set-Cookie: ") + len(raw) + len("\r\n")
|
||||
}
|
||||
if headerBytes >= 4096 {
|
||||
t.Errorf("Set-Cookie response headers = %d bytes, want below 4096", headerBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package authser
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
const requestAuthStateKey = "authser.request-auth-state"
|
||||
|
||||
// requestAuthState 只在单个 Gin 请求内有效,用来避免多个 SSR 服务重复刷新同一会话。
|
||||
// freshUser 表示本请求已经从上游取得并写回过的最新用户;suppressAutoAuth
|
||||
// 表示本请求明确不应再自动创建游客(例如用户刚退出或正式账号已过期)。
|
||||
type requestAuthState struct {
|
||||
freshUser *LoginResp
|
||||
suppressAutoAuth bool
|
||||
}
|
||||
|
||||
func authState(c *gin.Context) *requestAuthState {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if value, ok := c.Get(requestAuthStateKey); ok {
|
||||
if state, ok := value.(*requestAuthState); ok && state != nil {
|
||||
return state
|
||||
}
|
||||
}
|
||||
state := &requestAuthState{}
|
||||
c.Set(requestAuthStateKey, state)
|
||||
return state
|
||||
}
|
||||
|
||||
func freshRequestAuthUser(c *gin.Context) *LoginResp {
|
||||
state := authState(c)
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
return state.freshUser
|
||||
}
|
||||
|
||||
func rememberRequestAuthUser(c *gin.Context, user *LoginResp) {
|
||||
state := authState(c)
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.freshUser = user
|
||||
state.suppressAutoAuth = false
|
||||
}
|
||||
|
||||
func forgetRequestAuthUser(c *gin.Context) {
|
||||
if state := authState(c); state != nil {
|
||||
state.freshUser = nil
|
||||
}
|
||||
}
|
||||
|
||||
func suppressRequestAutoAuth(c *gin.Context) {
|
||||
if state := authState(c); state != nil {
|
||||
state.suppressAutoAuth = true
|
||||
}
|
||||
}
|
||||
|
||||
func allowRequestAutoAuth(c *gin.Context) {
|
||||
if state := authState(c); state != nil {
|
||||
state.suppressAutoAuth = false
|
||||
}
|
||||
}
|
||||
|
||||
func requestAutoAuthSuppressed(c *gin.Context) bool {
|
||||
state := authState(c)
|
||||
return state != nil && state.suppressAutoAuth
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package authser
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// TokenExpiredCode 上游约定:token 失效,需重新登录。
|
||||
const TokenExpiredCode = 1000
|
||||
|
||||
// IsSessionExpiredCode 是否为会话失效类业务码。
|
||||
func IsSessionExpiredCode(code int) bool {
|
||||
switch code {
|
||||
case TokenExpiredCode, 1002, 1012, 6001: // InvalidToken / NoToken / InvalidateToken / 用户不存在
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// OnSessionExpired 处理上游会话失效。
|
||||
// retry=true:游客已重新登录,调用方应重试原请求;
|
||||
// needLogin=true:注册账号已清 cookie,调用方应跳转登录弹窗/页。
|
||||
func OnSessionExpired(c *gin.Context, code int) (retry, needLogin bool) {
|
||||
if c == nil || !IsSessionExpiredCode(code) {
|
||||
return false, false
|
||||
}
|
||||
if IsRegisteredFromCookies(c) {
|
||||
ClearAuthCookies(c)
|
||||
dropRequestCookie(c, "token")
|
||||
dropRequestCookie(c, "_token_")
|
||||
// 正式账号失效后,本请求保持退出态,避免后续 HTML 渲染又静默转成游客。
|
||||
suppressRequestAutoAuth(c)
|
||||
return false, true
|
||||
}
|
||||
if RefreshGuestSession(c) != nil {
|
||||
return true, false
|
||||
}
|
||||
ClearAuthCookies(c)
|
||||
dropRequestCookie(c, "token")
|
||||
dropRequestCookie(c, "_token_")
|
||||
return false, false
|
||||
}
|
||||
|
||||
// RecoverContentSession 用于允许游客访问的内容读取接口。
|
||||
// 正式账号或游客 token 失效后,清理旧登录态、重新建立游客会话,调用方随后重试一次原请求。
|
||||
// 写操作仍应使用 OnSessionExpired,避免把用户操作静默降级为游客操作。
|
||||
func RecoverContentSession(c *gin.Context, code int) bool {
|
||||
if c == nil || !IsSessionExpiredCode(code) {
|
||||
return false
|
||||
}
|
||||
ClearAuthCookies(c)
|
||||
for _, name := range authCookieNames() {
|
||||
dropRequestCookie(c, name)
|
||||
}
|
||||
dropRequestCookie(c, guestSkipAutoCookie)
|
||||
ClearGuestSkipAuto(c)
|
||||
// 内容读取接口明确允许降级为游客,因此覆盖本请求可能已有的自动认证抑制。
|
||||
allowRequestAutoAuth(c)
|
||||
return EnsureGuestOnEnter(c) != nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package authser
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// RegisterReq 对应 swagger authapi.registerReq
|
||||
type RegisterReq struct {
|
||||
AccountNo string `json:"accountNo"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Password string `json:"password"`
|
||||
AgentInviteCode string `json:"agentInviteCode,omitempty"`
|
||||
DevID string `json:"devID,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// LoginReq 对应 swagger authapi.loginReq
|
||||
type LoginReq struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
DevID string `json:"devID,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// ResetPasswordReq 对应 swagger authapi.resetPasswordReq。
|
||||
type ResetPasswordReq struct {
|
||||
AccountNo string `json:"accountNo"`
|
||||
Email string `json:"email"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
// GuestLoginReq 对应 swagger authser.GuestLoginReq
|
||||
// POST /api/app/auth/guest
|
||||
type GuestLoginReq struct {
|
||||
DevID string `json:"devID"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Mac string `json:"mac,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProfileReq 对应 swagger authapi.updateProfileReq
|
||||
type UpdateProfileReq struct {
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
Introduction *string `json:"introduction,omitempty"`
|
||||
NickName *string `json:"nickName,omitempty"`
|
||||
OldPassword *string `json:"oldPassword,omitempty"`
|
||||
NewPassword *string `json:"newPassword,omitempty"`
|
||||
PrivacyMode *bool `json:"privacyMode,omitempty"`
|
||||
}
|
||||
|
||||
// LoginResp 对应 swagger authser.LoginResp
|
||||
// GET /api/app/auth/getInfo、登录/注册/游客成功 data。
|
||||
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"` // 简介
|
||||
Avatar string `json:"avatar"` // 头像
|
||||
InviteCode string `json:"inviteCode"` // 邀请码
|
||||
VipType int `json:"vipType"` // 0没卡 1月卡 2年卡
|
||||
CurrentVipExpire string `json:"currentVipExpire"` // VIP 过期时间,需结合 vipType 判断是否有效
|
||||
ShowCardID int64 `json:"showCardId"` // 最新会员卡 id
|
||||
ShowCardName string `json:"showCardName"` // 最新会员卡名称,如「1个月会员」
|
||||
Status int `json:"status"` // 0正常 1封禁 2禁言
|
||||
Type int `json:"type"` // 身份类型:0游客 1普通用户
|
||||
IsNewUser bool `json:"isNewUser"` // 本次是否新建游客
|
||||
IsRegister bool `json:"isRegister"` // 是否已注册
|
||||
HasPassword *bool `json:"hasPassword,omitempty"` // 是否已设置密码;上游未返回时由账号状态推断
|
||||
LoginType int `json:"loginType"` // 登录方式
|
||||
PrivacyMode bool `json:"privacyMode"` // 隐私模式
|
||||
Score int64 `json:"score"` // 积分
|
||||
Recharge int64 `json:"recharge"` // 金币余额
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳。Data 在失败时可能是 string/null。
|
||||
type APIEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// ImageUploadData 对应 swagger uploadser.ImageUploadData
|
||||
type ImageUploadData struct {
|
||||
ID string `json:"id"`
|
||||
SourceNameFs string `json:"sourceNameFs"`
|
||||
}
|
||||
|
||||
// ImageUploadResp 对应 swagger uploadser.ImageUploadResp
|
||||
type ImageUploadResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data *ImageUploadData `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SettingPageVM 账号设置页展示模型。
|
||||
type SettingPageVM struct {
|
||||
UserID int64
|
||||
AccountNo string
|
||||
HasAccount bool
|
||||
HasPassword bool
|
||||
NickName string
|
||||
Introduction string
|
||||
Email string
|
||||
EmailMasked string
|
||||
EmailBound bool
|
||||
Avatar string // 可用头像 URL;空则模板用默认图
|
||||
InviteCode string
|
||||
PrivacyMode bool
|
||||
VipType int
|
||||
VipExpire string
|
||||
Recharge int64
|
||||
Score int64
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// BuildSettingVM 由 LoginResp 构建设置页 VM。
|
||||
func BuildSettingVM(user *LoginResp) SettingPageVM {
|
||||
if user == nil {
|
||||
return SettingPageVM{}
|
||||
}
|
||||
nick := strings.TrimSpace(user.NickName)
|
||||
account := strings.TrimSpace(user.AccountNo)
|
||||
registered := user.IsRegister || user.Type == 1
|
||||
hasAccount := registered && account != ""
|
||||
hasPassword := hasAccount
|
||||
if user.HasPassword != nil {
|
||||
hasPassword = *user.HasPassword
|
||||
}
|
||||
email := strings.TrimSpace(user.Email)
|
||||
intro := strings.TrimSpace(user.Introduction)
|
||||
display := nick
|
||||
if display == "" {
|
||||
display = account
|
||||
}
|
||||
if display == "" {
|
||||
display = "用户"
|
||||
}
|
||||
return SettingPageVM{
|
||||
UserID: user.UserID,
|
||||
AccountNo: account,
|
||||
HasAccount: hasAccount,
|
||||
HasPassword: hasPassword,
|
||||
NickName: nick,
|
||||
Introduction: intro,
|
||||
Email: email,
|
||||
EmailMasked: MaskEmail(email),
|
||||
EmailBound: email != "",
|
||||
Avatar: usableAvatarURL(user.Avatar),
|
||||
InviteCode: strings.TrimSpace(user.InviteCode),
|
||||
PrivacyMode: user.PrivacyMode,
|
||||
VipType: user.VipType,
|
||||
VipExpire: strings.TrimSpace(user.CurrentVipExpire),
|
||||
Recharge: user.Recharge,
|
||||
Score: user.Score,
|
||||
DisplayName: display,
|
||||
}
|
||||
}
|
||||
|
||||
// MaskEmail 邮箱脱敏,如 ab***@mail.com。
|
||||
func MaskEmail(email string) string {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return ""
|
||||
}
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at <= 0 || at == len(email)-1 {
|
||||
return email
|
||||
}
|
||||
local := email[:at]
|
||||
domain := email[at:]
|
||||
runes := []rune(local)
|
||||
if len(runes) <= 2 {
|
||||
return string(runes[:1]) + "***" + domain
|
||||
}
|
||||
return string(runes[:2]) + "***" + domain
|
||||
}
|
||||
|
||||
// FormatUserID 展示用 ID。
|
||||
func FormatUserID(id int64) string {
|
||||
if id <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", id)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func parseVipExpireMs(raw string) int64 {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
if n > 0 && n < 1e12 {
|
||||
return n * 1000
|
||||
}
|
||||
return n
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// UserIDFromCookies 读取 cookie userId。
|
||||
func UserIDFromCookies(c *gin.Context) int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "userId")), 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// VipTypeFromCookies 读取 cookie vipType。
|
||||
func VipTypeFromCookies(c *gin.Context) int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(cookieValue(c, "vipType")))
|
||||
return n
|
||||
}
|
||||
|
||||
// VipTypeLabel vipType 文案:0普通 1月卡 2年卡(与 91ly / 编辑资料注释一致)。
|
||||
func VipTypeLabel(vipType int) string {
|
||||
switch vipType {
|
||||
case 1:
|
||||
return "月卡会员"
|
||||
case 2:
|
||||
return "年卡会员"
|
||||
default:
|
||||
return "普通会员"
|
||||
}
|
||||
}
|
||||
|
||||
// ShowCardNameFromCookies 读取 cookie showCardName。
|
||||
func ShowCardNameFromCookies(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cookieValue(c, "showCardName"))
|
||||
}
|
||||
|
||||
// ShowCardIDFromCookies 读取 cookie showCardId。
|
||||
func ShowCardIDFromCookies(c *gin.Context) int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "showCardId")), 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// VipDisplayLabel 优先使用 getInfo.showCardName,否则回退 vipType 文案。
|
||||
func VipDisplayLabel(showCardName string, vipType int) string {
|
||||
if s := strings.TrimSpace(showCardName); s != "" {
|
||||
return s
|
||||
}
|
||||
return VipTypeLabel(vipType)
|
||||
}
|
||||
|
||||
// VipDisplayLabelFromCookies 从 cookie 组装会员展示名。
|
||||
func VipDisplayLabelFromCookies(c *gin.Context) string {
|
||||
return VipDisplayLabel(ShowCardNameFromCookies(c), VipTypeFromCookies(c))
|
||||
}
|
||||
|
||||
// IsActiveVipUser 根据 vipType + currentVipExpire 判断是否有效会员。
|
||||
func IsActiveVipUser(vipType int, currentVipExpire string) bool {
|
||||
if vipType == 0 {
|
||||
return false
|
||||
}
|
||||
expireMs := parseVipExpireMs(currentVipExpire)
|
||||
if expireMs <= 0 {
|
||||
return false
|
||||
}
|
||||
return expireMs > time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
// FormatVipExpireText 格式化到期时间展示。
|
||||
func FormatVipExpireText(currentVipExpire string) string {
|
||||
ms := parseVipExpireMs(currentVipExpire)
|
||||
if ms <= 0 {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).In(time.Local).Format("有效期至 2006/01/02")
|
||||
}
|
||||
|
||||
// VipExpireTextFromCookies 格式化到期时间展示。
|
||||
func VipExpireTextFromCookies(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return FormatVipExpireText(cookieValue(c, "currentVipExpire"))
|
||||
}
|
||||
|
||||
// IsActiveVipFromCookies vipType≠0 且 currentVipExpire 未过期(与 91ly 一致)。
|
||||
func IsActiveVipFromCookies(c *gin.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return IsActiveVipUser(VipTypeFromCookies(c), cookieValue(c, "currentVipExpire"))
|
||||
}
|
||||
|
||||
// RechargeFromCookies 读取 cookie recharge(金币余额)。
|
||||
func RechargeFromCookies(c *gin.Context) int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "recharge")), 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// SetRechargeCookie 更新金币余额 cookie(解锁/充值后同步)。
|
||||
func SetRechargeCookie(c *gin.Context, balance int64) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if balance < 0 {
|
||||
balance = 0
|
||||
}
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
setCookie(c, "recharge", strconv.FormatInt(balance, 10), secure)
|
||||
}
|
||||
|
||||
// FormatRecharge 金币余额千分位展示。
|
||||
func FormatRecharge(n int64) string {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
s := strconv.FormatInt(n, 10)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
rem := len(s) % 3
|
||||
if rem > 0 {
|
||||
b.WriteString(s[:rem])
|
||||
if len(s) > rem {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
}
|
||||
for i := rem; i < len(s); i += 3 {
|
||||
b.WriteString(s[i : i+3])
|
||||
if i+3 < len(s) {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const likeSetPath = "/api/app/comment/like/set"
|
||||
|
||||
// LikeSet 设置评论点赞/取消点赞。
|
||||
func LikeSet(c *gin.Context, req CommentLikeSetReq) (*APIEnvelope[CommentLikeSetResp], error) {
|
||||
if req.CommentID <= 0 {
|
||||
return nil, fmt.Errorf("comment like invalid commentId")
|
||||
}
|
||||
var env APIEnvelope[CommentLikeSetResp]
|
||||
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
listPath = "/api/app/comment/list"
|
||||
defaultCommentSize = 10
|
||||
)
|
||||
|
||||
// ListQuery 评论列表查询参数。
|
||||
type ListQuery struct {
|
||||
ObjectID int64
|
||||
ObjectType int
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// Normalize 校正分页与排序默认值。
|
||||
func (q *ListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultCommentSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
q.ObjectType = NormalizeObjectType(q.ObjectType)
|
||||
if q.SortOrder != SortLatest {
|
||||
q.SortOrder = SortHot
|
||||
}
|
||||
}
|
||||
|
||||
// FetchList 分页查询评论。
|
||||
func FetchList(c *gin.Context, q ListQuery) (*GetCommentListResp, error) {
|
||||
q.Normalize()
|
||||
if q.ObjectID <= 0 {
|
||||
return nil, fmt.Errorf("comment list invalid objectId")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("objectId", strconv.FormatInt(q.ObjectID, 10))
|
||||
params.Set("objectType", strconv.Itoa(q.ObjectType))
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
|
||||
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
requestPath := listPath + "?" + params.Encode()
|
||||
env, err := fetchListOnce(c, requestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) && authser.RecoverContentSession(c, env.Code) {
|
||||
env, err = fetchListOnce(c, requestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("comment list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
func fetchListOnce(c *gin.Context, path string) (*APIEnvelope[GetCommentListResp], error) {
|
||||
var env APIEnvelope[GetCommentListResp]
|
||||
if err := upstream.GetJSON(c, path, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchListOrEmpty 失败时返回空列表。
|
||||
func FetchListOrEmpty(c *gin.Context, q ListQuery) *GetCommentListResp {
|
||||
data, err := FetchList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch comment list failed", log.E(err))
|
||||
return &GetCommentListResp{
|
||||
ObjectID: q.ObjectID,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
if data == nil {
|
||||
return &GetCommentListResp{
|
||||
ObjectID: q.ObjectID,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ParseSortOrder 解析站点评论排序(1 热门 / 2 最新)。
|
||||
func ParseSortOrder(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n != SortLatest {
|
||||
return SortHot
|
||||
}
|
||||
return SortLatest
|
||||
}
|
||||
|
||||
// FormatCreatedAt 展示用时间格式化(非字段映射)。
|
||||
func FormatCreatedAt(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t.Local().Format("2006/1/2 15:04")
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// AvatarOrDefault 头像空时回退默认图。
|
||||
func AvatarOrDefault(avatar string) string {
|
||||
avatar = strings.TrimSpace(avatar)
|
||||
if avatar == "" {
|
||||
return "/static/images/profile/avatar.png"
|
||||
}
|
||||
return avatar
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const sendPath = "/api/app/comment/send"
|
||||
|
||||
// Send 发布评论(一级 / 二级)。
|
||||
func Send(c *gin.Context, req CommentSendReq) (*APIEnvelope[any], error) {
|
||||
req.Text = strings.TrimSpace(req.Text)
|
||||
if req.ObjectID <= 0 {
|
||||
return nil, fmt.Errorf("comment send invalid objectId")
|
||||
}
|
||||
req.ObjectType = NormalizeObjectType(req.ObjectType)
|
||||
if req.Text == "" {
|
||||
return nil, fmt.Errorf("comment send empty text")
|
||||
}
|
||||
if utf8.RuneCountInString(req.Text) > 200 {
|
||||
return nil, fmt.Errorf("comment send text too long")
|
||||
}
|
||||
if req.ParentsID < 0 {
|
||||
req.ParentsID = 0
|
||||
}
|
||||
if req.ReplyCommentID < 0 {
|
||||
req.ReplyCommentID = 0
|
||||
}
|
||||
// 二级评论:未显式指定 replyCommentId 时默认回复父评论
|
||||
if req.ParentsID > 0 && req.ReplyCommentID <= 0 {
|
||||
req.ReplyCommentID = req.ParentsID
|
||||
}
|
||||
|
||||
var env APIEnvelope[any]
|
||||
if _, err := upstream.PostJSON(c, sendPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package commentser
|
||||
|
||||
import "html/template"
|
||||
|
||||
// TemplateFuncs 评论展示辅助。
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatCommentTime": FormatCreatedAt,
|
||||
"commentAvatar": AvatarOrDefault,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package commentser
|
||||
|
||||
// CommentSendReq 对应 swagger proto.CommentSendReq
|
||||
// parentsId 为 0 发一级评论,大于 0 发二级评论;replyCommentId 为实际被回复的评论。
|
||||
type CommentSendReq struct {
|
||||
ObjectID int64 `json:"objectId"`
|
||||
ObjectType int `json:"objectType"`
|
||||
Text string `json:"text"`
|
||||
ParentsID int64 `json:"parentsId"`
|
||||
ReplyCommentID int64 `json:"replyCommentId"`
|
||||
}
|
||||
|
||||
// CommentLikeSetReq 对应 swagger proto.CommentLikeSetReq
|
||||
type CommentLikeSetReq struct {
|
||||
CommentID int64 `json:"commentId"`
|
||||
IsLike bool `json:"isLike"`
|
||||
}
|
||||
|
||||
// CommentLikeSetResp 对应 swagger proto.CommentLikeSetResp
|
||||
type CommentLikeSetResp struct {
|
||||
CommentID int64 `json:"commentId"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsLike int `json:"isLike"`
|
||||
}
|
||||
|
||||
// CommentInfo 对应 swagger proto.CommentInfo
|
||||
type CommentInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
ObjectID int64 `json:"objectId"`
|
||||
ObjectType int `json:"objectType"`
|
||||
Text string `json:"text"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
UserAvatar string `json:"userAvatar"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
CommentCount int64 `json:"commentCount"`
|
||||
IsLike int `json:"isLike"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Level int `json:"level,omitempty"`
|
||||
ParentsID int64 `json:"parentsId,omitempty"`
|
||||
ReplyCommentID int64 `json:"replyCommentId,omitempty"`
|
||||
ReplyUserName string `json:"replyUserName,omitempty"`
|
||||
Children []CommentInfo `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// GetCommentListResp 对应 swagger proto.GetCommentListResp
|
||||
type GetCommentListResp struct {
|
||||
List []CommentInfo `json:"list"`
|
||||
ObjectID int64 `json:"objectId"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
const (
|
||||
// ObjectTypePlaylet 短剧评论
|
||||
ObjectTypePlaylet = 10
|
||||
// ObjectTypeChapter 短剧剧集评论
|
||||
ObjectTypeChapter = 11
|
||||
// ObjectTypeVideo 视频评论
|
||||
ObjectTypeVideo = 20
|
||||
|
||||
SortHot = 1 // 热门
|
||||
SortLatest = 2 // 最新
|
||||
)
|
||||
|
||||
// NormalizeObjectType 校正评论对象类型:10-短剧 11-短剧剧集 20-视频。
|
||||
func NormalizeObjectType(objectType int) int {
|
||||
switch objectType {
|
||||
case ObjectTypeChapter, ObjectTypeVideo:
|
||||
return objectType
|
||||
default:
|
||||
return ObjectTypePlaylet
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const footerPath = "/api/app/config/footer/info"
|
||||
const footerCacheTTL = 60 * time.Second
|
||||
|
||||
var (
|
||||
footerMu sync.Mutex
|
||||
footerCache *FooterInfo
|
||||
footerAt time.Time
|
||||
)
|
||||
|
||||
// FetchFooter 拉取底部配置,返回与 swagger proto.FooterInfo 一致的结构。
|
||||
func FetchFooter(c *gin.Context) (*FooterInfo, error) {
|
||||
var env APIEnvelope[FooterInfoResp]
|
||||
if err := upstream.GetPublicJSON(c, footerPath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("footer info code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
info := env.Data.FooterInfo
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// FetchFooterOrEmpty 带短缓存;失败或空数据时返回空结构,不做写死兜底/字段映射。
|
||||
func FetchFooterOrEmpty(c *gin.Context) FooterInfo {
|
||||
footerMu.Lock()
|
||||
defer footerMu.Unlock()
|
||||
if footerCache != nil && time.Since(footerAt) < footerCacheTTL {
|
||||
return *footerCache
|
||||
}
|
||||
|
||||
info, err := FetchFooter(c)
|
||||
if err != nil {
|
||||
log.Error("fetch footer info failed", log.E(err))
|
||||
return FooterInfo{}
|
||||
}
|
||||
footerCache = info
|
||||
footerAt = time.Now()
|
||||
return *info
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HomeRoadLinkVM 是“回家的路”页面中的可访问线路。
|
||||
type HomeRoadLinkVM struct {
|
||||
Label string
|
||||
URL string
|
||||
}
|
||||
|
||||
// HomeRoadContactVM 是防失联联系方式。
|
||||
type HomeRoadContactVM struct {
|
||||
Title string
|
||||
Description string
|
||||
Action string
|
||||
URL string
|
||||
}
|
||||
|
||||
// HomeRoadPageVM 将 config/info 中的地址字段整理成页面所需结构。
|
||||
type HomeRoadPageVM struct {
|
||||
MainURL string
|
||||
Routes []HomeRoadLinkVM
|
||||
Permanent []HomeRoadLinkVM
|
||||
Contacts []HomeRoadContactVM
|
||||
}
|
||||
|
||||
func firstHomeRoadURL(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// BuildHomeRoadPageVM 使用服务端动态配置组装“回家的路”页面。
|
||||
func BuildHomeRoadPageVM(cfg AppConfigVM) HomeRoadPageVM {
|
||||
mainURL := firstHomeRoadURL(cfg.NewURLAddr, cfg.ForeverURLAddr)
|
||||
routes := make([]HomeRoadLinkVM, 0, 3)
|
||||
for _, item := range cfg.NavPageList {
|
||||
if len(routes) == 3 {
|
||||
break
|
||||
}
|
||||
if url := strings.TrimSpace(item.URL); url != "" {
|
||||
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: url})
|
||||
}
|
||||
}
|
||||
for _, url := range []string{cfg.NewURLAddr, cfg.NewURLAddr1, cfg.NewURLAddr2} {
|
||||
if len(routes) == 3 {
|
||||
break
|
||||
}
|
||||
if url = strings.TrimSpace(url); url != "" {
|
||||
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: url})
|
||||
}
|
||||
}
|
||||
for len(routes) < 3 {
|
||||
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: mainURL})
|
||||
}
|
||||
|
||||
permanent := make([]HomeRoadLinkVM, 0, 3)
|
||||
for _, item := range []HomeRoadLinkVM{
|
||||
{Label: "永久地址", URL: cfg.ForeverURLAddr},
|
||||
{Label: "永久中转页", URL: cfg.NewURLAddr},
|
||||
{Label: "最新中转页(免翻墙)", URL: cfg.NewURLAddr1},
|
||||
} {
|
||||
item.URL = strings.TrimSpace(item.URL)
|
||||
if item.URL != "" {
|
||||
permanent = append(permanent, item)
|
||||
}
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(cfg.GmailAddr)
|
||||
emailURL := ""
|
||||
if email != "" {
|
||||
emailURL = "mailto:" + email
|
||||
}
|
||||
contacts := []HomeRoadContactVM{
|
||||
{Title: "✉ 邮箱自动回复", Description: "发送任意邮件到下方地址,30秒内自动回复当前所有可用入口的最新汇总。", Action: firstHomeRoadURL(email, "huangguoaiai@gmail.com") + " →", URL: firstHomeRoadURL(emailURL, "mailto:huangguoaiai@gmail.com")},
|
||||
{Title: "➤ TG 官方交流群", Description: "获取域名变更通知的首选渠道,实时播报并加入用户互助社区。", Action: "立即订阅最新动态 →", URL: firstHomeRoadURL(cfg.TGGroup, "/")},
|
||||
{Title: "◆ 官方商务合作", Description: "联系官方商务合作,洽谈商务事宜,精诚合作,财富共赢。", Action: "立即联系 →", URL: firstHomeRoadURL(cfg.Telegram, "/")},
|
||||
{Title: "𝕏 推特社区", Description: "关注官方推特社区,第一时间获取地址变更与官方公告。", Action: "快速访问 →", URL: firstHomeRoadURL(cfg.TwitterAddr, "/")},
|
||||
}
|
||||
|
||||
return HomeRoadPageVM{MainURL: mainURL, Routes: routes, Permanent: permanent, Contacts: contacts}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
configInfoPath = "/api/app/config/info"
|
||||
configInfoCacheTTL = 60 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
configMu sync.Mutex
|
||||
configCache *AppConfigVM
|
||||
configAt time.Time
|
||||
)
|
||||
|
||||
// FetchConfigInfo 拉取 APP 公共配置。
|
||||
func FetchConfigInfo(c *gin.Context) (*ConfigInfoResp, error) {
|
||||
var env APIEnvelope[ConfigInfoResp]
|
||||
if err := upstream.GetPublicJSON(c, configInfoPath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("config info code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchAppConfigVMOrEmpty 带短缓存;刷新失败时优先返回最近一次成功配置。
|
||||
func FetchAppConfigVMOrEmpty(c *gin.Context) AppConfigVM {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
if configCache != nil && time.Since(configAt) < configInfoCacheTTL {
|
||||
return *configCache
|
||||
}
|
||||
|
||||
info, err := FetchConfigInfo(c)
|
||||
if err != nil {
|
||||
log.Error("fetch app config info failed", log.E(err))
|
||||
if configCache != nil {
|
||||
return *configCache
|
||||
}
|
||||
return ApplyTrackerFallback(AppConfigVM{})
|
||||
}
|
||||
vm := ApplyTrackerFallback(BuildAppConfigVM(info))
|
||||
configCache = &vm
|
||||
configAt = time.Now()
|
||||
return vm
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const richtextListPath = "/api/app/config/richtext/list"
|
||||
|
||||
// RichTextPageVM 富文本页模板数据。
|
||||
type RichTextPageVM struct {
|
||||
Title string
|
||||
Subtitle string
|
||||
ContentHTML template.HTML
|
||||
Found bool
|
||||
}
|
||||
|
||||
// FetchRichTextList 查询富文本列表(ids / titles 逗号分隔)。
|
||||
func FetchRichTextList(c *gin.Context, ids, titles string) ([]AppRichTextItemResp, error) {
|
||||
params := url.Values{}
|
||||
if s := strings.TrimSpace(ids); s != "" {
|
||||
params.Set("ids", s)
|
||||
}
|
||||
if s := strings.TrimSpace(titles); s != "" {
|
||||
params.Set("titles", s)
|
||||
}
|
||||
path := richtextListPath
|
||||
if q := params.Encode(); q != "" {
|
||||
path += "?" + q
|
||||
}
|
||||
|
||||
var env APIEnvelope[AppRichTextListResp]
|
||||
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("richtext list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return env.Data.List, nil
|
||||
}
|
||||
|
||||
// FetchRichTextByTitle 按标题取第一条启用内容;失败返回空页 VM。
|
||||
func FetchRichTextByTitle(c *gin.Context, title string) RichTextPageVM {
|
||||
title = strings.TrimSpace(title)
|
||||
vm := RichTextPageVM{Title: title}
|
||||
if title == "" {
|
||||
return vm
|
||||
}
|
||||
|
||||
list, err := FetchRichTextList(c, "", title)
|
||||
if err != nil {
|
||||
log.Error("fetch richtext by title failed", log.E(err), log.Any("title", title))
|
||||
return vm
|
||||
}
|
||||
|
||||
item := matchRichTextByTitle(list, title)
|
||||
if item == nil {
|
||||
return vm
|
||||
}
|
||||
|
||||
displayTitle := strings.TrimSpace(item.Desc)
|
||||
if displayTitle == "" {
|
||||
displayTitle = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if displayTitle == "" {
|
||||
displayTitle = title
|
||||
}
|
||||
|
||||
vm.Found = true
|
||||
vm.Title = displayTitle
|
||||
vm.Subtitle = strings.TrimSpace(item.Desc)
|
||||
if vm.Subtitle == displayTitle {
|
||||
vm.Subtitle = ""
|
||||
}
|
||||
vm.ContentHTML = template.HTML(item.Content)
|
||||
return vm
|
||||
}
|
||||
|
||||
func matchRichTextByTitle(list []AppRichTextItemResp, title string) *AppRichTextItemResp {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range list {
|
||||
if strings.TrimSpace(list[i].Title) == title || strings.TrimSpace(list[i].Desc) == title {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
return &list[0]
|
||||
}
|
||||
|
||||
// RichTextHref 返回富文本标题对应的站内 canonical 地址。
|
||||
func RichTextHref(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return "/richtext"
|
||||
}
|
||||
if href, ok := RichTextSemanticHref(title); ok {
|
||||
return href
|
||||
}
|
||||
return "/richtext/" + url.PathEscape(title)
|
||||
}
|
||||
|
||||
// RichTextSemanticHref 将固定栏目及上游常用别名收敛到唯一语义路由。
|
||||
func RichTextSemanticHref(title string) (string, bool) {
|
||||
normalized := strings.ToLower(strings.Join(strings.Fields(title), ""))
|
||||
switch normalized {
|
||||
case "关于我们", "aboutus":
|
||||
return "/about", true
|
||||
case "用户协议", "用户条款", "useragreement", "termsofservice":
|
||||
return "/terms", true
|
||||
case "隐私政策", "privacy", "privacypolicy":
|
||||
return "/privacy", true
|
||||
case "2257合规声明", "2257", "usc2257", "合规声明":
|
||||
return "/2257", true
|
||||
case "dmca版权声明", "dmca", "版权声明", "版权投诉":
|
||||
return "/dmca", true
|
||||
case "帮助中心", "疑问解答", "常见问题", "help", "helpcenter", "faq":
|
||||
return "/help", true
|
||||
case "问题反馈", "意见反馈", "联系我们", "feedback", "contact", "contactus":
|
||||
return "/feedback", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnmarshalJSON 兼容 seoNavInfo 为空字符串,以及 dhKeyworld 为字符串/数组。
|
||||
func (s *SeoNavInfo) UnmarshalJSON(raw []byte) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '"' {
|
||||
var encoded string
|
||||
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded = strings.TrimSpace(encoded)
|
||||
if encoded == "" {
|
||||
return nil
|
||||
}
|
||||
inner := bytes.TrimSpace([]byte(encoded))
|
||||
if len(inner) == 0 || inner[0] != '{' {
|
||||
return nil
|
||||
}
|
||||
return s.UnmarshalJSON(inner)
|
||||
}
|
||||
var aux struct {
|
||||
DhTitle string `json:"dhTitle"`
|
||||
DhDescription string `json:"dhDescription"`
|
||||
DhSiteName string `json:"dhSiteName"`
|
||||
DhKeyworld json.RawMessage `json:"dhKeyworld"`
|
||||
Keywords json.RawMessage `json:"keywords"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
s.DhTitle = strings.TrimSpace(aux.DhTitle)
|
||||
s.DhDescription = strings.TrimSpace(aux.DhDescription)
|
||||
s.DhSiteName = strings.TrimSpace(aux.DhSiteName)
|
||||
s.DhKeyworld = decodeNavKeywords(aux.DhKeyworld, aux.Keywords)
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeNavKeywords(values ...json.RawMessage) []string {
|
||||
for _, raw := range values {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
continue
|
||||
}
|
||||
if raw[0] == '[' {
|
||||
var list []string
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
continue
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
continue
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
continue
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/appg"
|
||||
)
|
||||
|
||||
// ResolveTrackerAppID 优先 yaml,否则固定 JHA-1045。
|
||||
func ResolveTrackerAppID() string {
|
||||
return appg.TrackerAppID()
|
||||
}
|
||||
|
||||
// ResolveReportSDKConfig 上游 reportSdkConfig 优先,本地 yaml 兜底。
|
||||
func ResolveReportSDKConfig(fromAPI string) string {
|
||||
if v := strings.TrimSpace(fromAPI); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(appg.Conf.Tracker.EncryptedConfig)
|
||||
}
|
||||
|
||||
// ApplyTrackerFallback 补齐 AppConfigVM 中的上报密文。
|
||||
func ApplyTrackerFallback(vm AppConfigVM) AppConfigVM {
|
||||
vm.ReportSDKConfig = ResolveReportSDKConfig(vm.ReportSDKConfig)
|
||||
return vm
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package configser
|
||||
|
||||
// FooterInfoResp 对应 swagger proto.FooterInfoResp
|
||||
type FooterInfoResp struct {
|
||||
FooterInfo FooterInfo `json:"footerInfo"`
|
||||
}
|
||||
|
||||
// FooterInfo 对应 swagger proto.FooterInfo
|
||||
// GET /api/app/config/footer/info — 模板直接使用本结构,不做自定义 VM 映射。
|
||||
type FooterInfo struct {
|
||||
Brand FooterBrand `json:"brand"`
|
||||
NavList []TranslationCategory `json:"navList"`
|
||||
ContactList []FooterContact `json:"contactList"`
|
||||
}
|
||||
|
||||
// FooterBrand 对应 swagger proto.FooterBrand
|
||||
type FooterBrand struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// TranslationCategory 对应 swagger proto.TranslationCategory(footer.navList 元素)
|
||||
type TranslationCategory struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
URLValue string `json:"urlValue"`
|
||||
CategoryType int `json:"categoryType"`
|
||||
TranslationList []TranslationInfo `json:"translationList"`
|
||||
SeoInfo SeoValue `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// SeoValue 对应 swagger pubdef.SeoValue(导航分类 seoInfo)
|
||||
type SeoValue struct {
|
||||
Title string `json:"title"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// TranslationInfo 对应 swagger proto.TranslationInfo
|
||||
type TranslationInfo struct {
|
||||
Name string `json:"name"`
|
||||
URLValue string `json:"urlValue"`
|
||||
IsFriendAds bool `json:"isFriendAds"`
|
||||
}
|
||||
|
||||
// FooterContact 对应 swagger proto.FooterContact
|
||||
type FooterContact struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// ConfigInfoResp 对应 swagger proto.ConfigInfoResp
|
||||
// GET /api/app/config/info
|
||||
// reportSdkConfig 与 proto.ConfigInfo 对齐;上游若尚未下发则为空,可由本地 yaml 兜底。
|
||||
// config 为上游实际下发的完整站点配置(swagger 未完整声明,但含 potaoGroup 等字段)。
|
||||
type ConfigInfoResp struct {
|
||||
NewAddressInfo NewAddressInfo `json:"newAddressInfo"`
|
||||
Config ConfigInfo `json:"config"`
|
||||
Advitise AdvitiseData `json:"advitise"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
SeoNavInfo SeoNavInfo `json:"seoNavInfo"`
|
||||
NavPageList []NavPageItem `json:"navPageList"`
|
||||
ReportSDKConfig string `json:"reportSdkConfig"`
|
||||
}
|
||||
|
||||
// ConfigInfo 对应上游 data.config(proto.ConfigInfo 子集,按页面需要取用)。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/APP配置相关接口/get_api_app_config_info
|
||||
type ConfigInfo struct {
|
||||
AppAddr string `json:"appAddr"`
|
||||
OfficialAppAddr string `json:"officialAppAddr"`
|
||||
ForeverURLAddr string `json:"foreverUrlAddr"`
|
||||
NewURLAddr string `json:"newUrlAddr"`
|
||||
NewURLAddr1 string `json:"newUrlAddr1"`
|
||||
NewURLAddr2 string `json:"newUrlAddr2"`
|
||||
GmailAddr string `json:"gmailAddr"`
|
||||
QQ string `json:"qq"`
|
||||
WeiChart string `json:"weiChart"`
|
||||
TGGroup string `json:"tgGroup"`
|
||||
Telegram string `json:"telegram"`
|
||||
TelegramAdd string `json:"telegramAdd"`
|
||||
PotaoGroup string `json:"potaoGroup"`
|
||||
CustomerServiceHost string `json:"customerServiceHost"`
|
||||
PDFAddr string `json:"pdfAddr"`
|
||||
TwitterAddr string `json:"twitterAddr"`
|
||||
GitAddr string `json:"gitAddr"`
|
||||
FreeVPN string `json:"freeVPN"`
|
||||
}
|
||||
|
||||
// AdvitiseData 对应 GET /api/app/config/info 返回的完整 advitise 数据。
|
||||
type AdvitiseData struct {
|
||||
HomeList []Advertisement `json:"homeList"`
|
||||
DetailTopList []Advertisement `json:"detailTopList"`
|
||||
NineGridList []Advertisement `json:"nineGridList"`
|
||||
DetailBannerList []Advertisement `json:"detailbannerList"`
|
||||
LaunchScreenList []Advertisement `json:"launchScreenList"`
|
||||
FloatingList []Advertisement `json:"floatingList"`
|
||||
AList []Advertisement `json:"AList"`
|
||||
BList []Advertisement `json:"BList"`
|
||||
DialogList []Advertisement `json:"dialogList"`
|
||||
VideoInfoList []Advertisement `json:"videoInfoList"`
|
||||
VideoList []Advertisement `json:"videoList"`
|
||||
DownList []Advertisement `json:"downList"`
|
||||
FriendList []Advertisement `json:"friendList"`
|
||||
HomeBanner []Advertisement `json:"homeBanner"`
|
||||
BottomBanner []Advertisement `json:"bottomBanner"`
|
||||
TitleList []Advertisement `json:"titleList"`
|
||||
PlayList []Advertisement `json:"playList"`
|
||||
PauseyList []Advertisement `json:"pauseyList"`
|
||||
}
|
||||
|
||||
// Advertisement 对应 swagger proxy.AdvitiseInfo。
|
||||
type Advertisement struct {
|
||||
ID string `json:"id"`
|
||||
Images string `json:"images"`
|
||||
Link string `json:"link"`
|
||||
Location int `json:"location"`
|
||||
Title string `json:"title"`
|
||||
AdvertiseCode string `json:"advertiseCode"`
|
||||
AdvertiseLocationCode string `json:"advertiseLocationCode"`
|
||||
AdvertiseLocationName string `json:"advertiseLocationName"`
|
||||
}
|
||||
|
||||
// NewAddressInfo 对应 swagger proto.NewAddressInfo(GET /api/app/config/info)。
|
||||
type NewAddressInfo struct {
|
||||
NewURLAddr string `json:"newUrlAddr"`
|
||||
NewURLAddr1 string `json:"newUrlAddr1"`
|
||||
NewURLAddr2 string `json:"newUrlAddr2"`
|
||||
ForeverURLAddr string `json:"foreverUrlAddr"`
|
||||
GmailAddr string `json:"gmailAddr"`
|
||||
QQ string `json:"qq"`
|
||||
TGGroup string `json:"tgGroup"`
|
||||
Telegram string `json:"telegram"`
|
||||
PotaoGroup string `json:"potaoGroup"`
|
||||
CustomerServiceHost string `json:"customerServiceHost"`
|
||||
AppAddr string `json:"appAddr"`
|
||||
OfficialAppAddr string `json:"officialAppAddr"`
|
||||
PDFAddr string `json:"pdfAddr"`
|
||||
TwitterAddr string `json:"twitterAddr"`
|
||||
}
|
||||
|
||||
// SeoInfo 对应 swagger proto.SeoInfo
|
||||
type SeoInfo struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Keywords []string `json:"keywords"`
|
||||
CanonicalURL string `json:"canonicalUrl"`
|
||||
WebSiteName string `json:"webSiteName"`
|
||||
}
|
||||
|
||||
// SeoNavInfo 对应 swagger proto.SeoNavInfo
|
||||
// GET /api/app/config/info 的 seoNavInfo。
|
||||
type SeoNavInfo struct {
|
||||
DhTitle string `json:"dhTitle"`
|
||||
DhDescription string `json:"dhDescription"`
|
||||
DhKeyworld []string `json:"dhKeyworld"`
|
||||
DhSiteName string `json:"dhSiteName"`
|
||||
}
|
||||
|
||||
// NavPageItem 对应 swagger proto.NavPageItem(GET /api/app/config/info 的 navPageList)。
|
||||
type NavPageItem struct {
|
||||
Line int `json:"line"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// AppRichTextItemResp 对应 swagger proto.AppRichTextItemResp
|
||||
// GET /api/app/config/richtext/list
|
||||
type AppRichTextItemResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Desc string `json:"desc"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// AppRichTextListResp 对应 swagger proto.AppRichTextListResp
|
||||
type AppRichTextListResp struct {
|
||||
List []AppRichTextItemResp `json:"list"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AppConfigVM 注入全站页面的公共配置(字段名与接口 newAddressInfo / seoInfo 对齐)。
|
||||
type AppConfigVM struct {
|
||||
// Config 为 data.config 原样字段(页脚社交等优先用此对象)。
|
||||
Config ConfigInfo `json:"config"`
|
||||
ForeverURLAddr string `json:"foreverUrlAddr"`
|
||||
NewURLAddr string `json:"newUrlAddr"`
|
||||
NewURLAddr1 string `json:"newUrlAddr1"`
|
||||
NewURLAddr2 string `json:"newUrlAddr2"`
|
||||
GmailAddr string `json:"gmailAddr"`
|
||||
QQ string `json:"qq"`
|
||||
TGGroup string `json:"tgGroup"`
|
||||
Telegram string `json:"telegram"`
|
||||
TelegramAdd string `json:"telegramAdd"`
|
||||
PotaoGroup string `json:"potaoGroup"`
|
||||
CustomerServiceHost string `json:"customerServiceHost"`
|
||||
AppAddr string `json:"appAddr"`
|
||||
OfficialAppAddr string `json:"officialAppAddr"`
|
||||
PDFAddr string `json:"pdfAddr"`
|
||||
TwitterAddr string `json:"twitterAddr"`
|
||||
GitAddr string `json:"gitAddr"`
|
||||
FreeVPN string `json:"freeVPN"`
|
||||
SeoInfo SeoInfo `json:"seoInfo,omitempty"`
|
||||
SeoNavInfo SeoNavInfo `json:"seoNavInfo,omitempty"`
|
||||
NavPageList []NavPageItem `json:"navPageList,omitempty"`
|
||||
ReportSDKConfig string `json:"reportSdkConfig,omitempty"`
|
||||
Advitise AdvitiseData `json:"advitise"`
|
||||
DetailTopList []Advertisement `json:"detailTopList,omitempty"`
|
||||
NineGridList []Advertisement `json:"nineGridList,omitempty"`
|
||||
DetailBannerList []Advertisement `json:"detailbannerList,omitempty"`
|
||||
BottomBanner []Advertisement `json:"bottomBanner,omitempty"`
|
||||
DialogList []Advertisement `json:"dialogList,omitempty"`
|
||||
}
|
||||
|
||||
// BuildAppConfigVM 从上游配置组装前端可用字段;社交/地址优先取 data.config。
|
||||
func BuildAppConfigVM(info *ConfigInfoResp) AppConfigVM {
|
||||
if info == nil {
|
||||
return AppConfigVM{}
|
||||
}
|
||||
cfg := sanitizeConfigInfo(info.Config)
|
||||
addr := info.NewAddressInfo
|
||||
return AppConfigVM{
|
||||
Config: cfg,
|
||||
ForeverURLAddr: firstNonEmptyURL(cfg.ForeverURLAddr, addr.ForeverURLAddr),
|
||||
NewURLAddr: firstNonEmptyURL(cfg.NewURLAddr, addr.NewURLAddr),
|
||||
NewURLAddr1: firstNonEmptyURL(cfg.NewURLAddr1, addr.NewURLAddr1),
|
||||
NewURLAddr2: firstNonEmptyURL(cfg.NewURLAddr2, addr.NewURLAddr2),
|
||||
GmailAddr: firstNonEmpty(normalizeEmailAddress(cfg.GmailAddr), normalizeEmailAddress(addr.GmailAddr)),
|
||||
QQ: firstNonEmpty(cfg.QQ, strings.TrimSpace(addr.QQ)),
|
||||
TGGroup: firstNonEmptyURL(cfg.TGGroup, addr.TGGroup),
|
||||
Telegram: firstNonEmptyURL(cfg.Telegram, addr.Telegram),
|
||||
TelegramAdd: sanitizeURL(cfg.TelegramAdd),
|
||||
PotaoGroup: firstNonEmptyURL(cfg.PotaoGroup, addr.PotaoGroup),
|
||||
CustomerServiceHost: firstNonEmptyURL(cfg.CustomerServiceHost, addr.CustomerServiceHost),
|
||||
AppAddr: firstNonEmptyURL(cfg.AppAddr, addr.AppAddr),
|
||||
OfficialAppAddr: firstNonEmptyURL(cfg.OfficialAppAddr, addr.OfficialAppAddr),
|
||||
PDFAddr: firstNonEmptyURL(cfg.PDFAddr, addr.PDFAddr),
|
||||
TwitterAddr: firstNonEmptyURL(cfg.TwitterAddr, addr.TwitterAddr),
|
||||
GitAddr: sanitizeURL(cfg.GitAddr),
|
||||
FreeVPN: sanitizeURL(cfg.FreeVPN),
|
||||
ReportSDKConfig: strings.TrimSpace(info.ReportSDKConfig),
|
||||
NavPageList: buildNavPageList(info.NavPageList),
|
||||
Advitise: info.Advitise,
|
||||
DetailTopList: buildAdvertisementList(info.Advitise.DetailTopList),
|
||||
NineGridList: buildAdvertisementList(info.Advitise.NineGridList),
|
||||
DetailBannerList: buildAdvertisementList(info.Advitise.DetailBannerList),
|
||||
BottomBanner: buildAdvertisementList(info.Advitise.BottomBanner),
|
||||
DialogList: buildAdvertisementList(info.Advitise.DialogList),
|
||||
SeoInfo: SeoInfo{
|
||||
Title: strings.TrimSpace(info.SeoInfo.Title),
|
||||
Description: strings.TrimSpace(info.SeoInfo.Description),
|
||||
Keywords: info.SeoInfo.Keywords,
|
||||
CanonicalURL: strings.TrimSpace(info.SeoInfo.CanonicalURL),
|
||||
WebSiteName: strings.TrimSpace(info.SeoInfo.WebSiteName),
|
||||
},
|
||||
SeoNavInfo: SeoNavInfo{
|
||||
DhTitle: strings.TrimSpace(info.SeoNavInfo.DhTitle),
|
||||
DhDescription: strings.TrimSpace(info.SeoNavInfo.DhDescription),
|
||||
DhKeyworld: info.SeoNavInfo.DhKeyworld,
|
||||
DhSiteName: strings.TrimSpace(info.SeoNavInfo.DhSiteName),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeConfigInfo(cfg ConfigInfo) ConfigInfo {
|
||||
return ConfigInfo{
|
||||
AppAddr: sanitizeURL(cfg.AppAddr),
|
||||
OfficialAppAddr: sanitizeURL(cfg.OfficialAppAddr),
|
||||
ForeverURLAddr: sanitizeURL(cfg.ForeverURLAddr),
|
||||
NewURLAddr: sanitizeURL(cfg.NewURLAddr),
|
||||
NewURLAddr1: sanitizeURL(cfg.NewURLAddr1),
|
||||
NewURLAddr2: sanitizeURL(cfg.NewURLAddr2),
|
||||
GmailAddr: normalizeEmailAddress(cfg.GmailAddr),
|
||||
QQ: strings.TrimSpace(cfg.QQ),
|
||||
WeiChart: strings.TrimSpace(cfg.WeiChart),
|
||||
TGGroup: sanitizeURL(cfg.TGGroup),
|
||||
Telegram: sanitizeURL(cfg.Telegram),
|
||||
TelegramAdd: sanitizeURL(cfg.TelegramAdd),
|
||||
PotaoGroup: sanitizeURL(cfg.PotaoGroup),
|
||||
CustomerServiceHost: sanitizeURL(cfg.CustomerServiceHost),
|
||||
PDFAddr: sanitizeURL(cfg.PDFAddr),
|
||||
TwitterAddr: sanitizeURL(cfg.TwitterAddr),
|
||||
GitAddr: sanitizeURL(cfg.GitAddr),
|
||||
FreeVPN: sanitizeURL(cfg.FreeVPN),
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildAdvertisementList(list []Advertisement) []Advertisement {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Advertisement, 0, len(list))
|
||||
for _, item := range list {
|
||||
item.ID = strings.TrimSpace(item.ID)
|
||||
item.AdvertiseCode = strings.TrimSpace(item.AdvertiseCode)
|
||||
item.AdvertiseLocationCode = strings.TrimSpace(item.AdvertiseLocationCode)
|
||||
item.AdvertiseLocationName = strings.TrimSpace(item.AdvertiseLocationName)
|
||||
if item.AdvertiseCode != "" {
|
||||
item.ID = item.AdvertiseCode
|
||||
}
|
||||
item.Images = sanitizeURL(item.Images)
|
||||
item.Link = sanitizeURL(item.Link)
|
||||
item.Title = strings.TrimSpace(item.Title)
|
||||
if item.Images == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NineGridRow 按首页设计每行返回两个 banner,超出当前行时返回空切片。
|
||||
func (c AppConfigVM) NineGridRow(row int) []Advertisement {
|
||||
if row < 0 {
|
||||
return nil
|
||||
}
|
||||
start := row * 2
|
||||
if start >= len(c.NineGridList) {
|
||||
return nil
|
||||
}
|
||||
end := start + 2
|
||||
if end > len(c.NineGridList) {
|
||||
end = len(c.NineGridList)
|
||||
}
|
||||
return c.NineGridList[start:end]
|
||||
}
|
||||
|
||||
// QRText 应用下载二维码及跳转地址:仅使用应用下载地址 appAddr。
|
||||
func (c AppConfigVM) QRText() string {
|
||||
return strings.TrimSpace(c.AppAddr)
|
||||
}
|
||||
|
||||
func buildNavPageList(list []NavPageItem) []NavPageItem {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]NavPageItem, 0, len(list))
|
||||
for _, item := range list {
|
||||
u := sanitizeURL(item.URL)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, NavPageItem{Line: item.Line, URL: u})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (n NavPageItem) Label() string {
|
||||
digits := []string{"", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"}
|
||||
if n.Line >= 1 && n.Line <= 10 {
|
||||
return "线路" + digits[n.Line]
|
||||
}
|
||||
if n.Line > 0 {
|
||||
return "线路" + strconv.Itoa(n.Line)
|
||||
}
|
||||
return "线路"
|
||||
}
|
||||
|
||||
func sanitizeURL(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimRight(v, "\\")
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// normalizeEmailAddress 兼容上游把邮箱误配为 mailto/http(s) 地址的情况。
|
||||
func normalizeEmailAddress(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
for {
|
||||
lower := strings.ToLower(value)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "mailto:"):
|
||||
value = strings.TrimSpace(value[len("mailto:"):])
|
||||
case strings.HasPrefix(lower, "https://"):
|
||||
value = strings.TrimSpace(value[len("https://"):])
|
||||
case strings.HasPrefix(lower, "http://"):
|
||||
value = strings.TrimSpace(value[len("http://"):])
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyURL(values ...string) string {
|
||||
for _, v := range values {
|
||||
if u := sanitizeURL(v); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TelegramURL 客服/商务 TG:优先 telegram,其次 telegramAdd、tgGroup。
|
||||
func (c ConfigInfo) TelegramURL() string {
|
||||
return firstNonEmptyURL(c.Telegram, c.TelegramAdd, c.TGGroup)
|
||||
}
|
||||
|
||||
// TelegramHandle 从 TG 链接提取展示用 @用户名;无法解析时返回空。
|
||||
func (c ConfigInfo) TelegramHandle() string {
|
||||
raw := c.TelegramURL()
|
||||
raw = strings.TrimRight(raw, "/")
|
||||
if i := strings.LastIndex(raw, "/"); i >= 0 && i+1 < len(raw) {
|
||||
raw = raw[i+1:]
|
||||
}
|
||||
raw = strings.TrimPrefix(strings.TrimSpace(raw), "@")
|
||||
if raw == "" || strings.Contains(raw, ".") || strings.Contains(raw, "?") {
|
||||
return ""
|
||||
}
|
||||
return "@" + raw
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package feedbackser
|
||||
|
||||
import (
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const addPath = "/api/app/feedback/add"
|
||||
|
||||
const contactUsAddPath = "/api/app/contactus/add"
|
||||
|
||||
// Add 调用上游提交意见反馈。
|
||||
func Add(c *gin.Context, req FeedbackReq) (*authser.APIEnvelope, error) {
|
||||
var env authser.APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, addPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// AddContactUs 调用上游联系我们接口。
|
||||
func AddContactUs(c *gin.Context, req ContactUsAddReq) (*authser.APIEnvelope, error) {
|
||||
var env authser.APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, contactUsAddPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package feedbackser
|
||||
|
||||
// FeedbackReq 对应 swagger proto.FeedbackReq。
|
||||
// type(接口说明):1-功能建议、2-播放问题、3-账号问题、4-其他
|
||||
type FeedbackReq struct {
|
||||
Type int `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Contact string `json:"contact,omitempty"`
|
||||
Screenshots []string `json:"screenshots,omitempty"`
|
||||
}
|
||||
|
||||
// ContactUsAddReq 对应 swagger proto.ContactUsAddReq。
|
||||
type ContactUsAddReq struct {
|
||||
Type int `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Email string `json:"email"`
|
||||
Attachments []string `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
type ContactUsAddResp struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const boughtListPath = "/api/app/playlet/bought/list"
|
||||
|
||||
// PlayletBoughtData 对应 proto.PlayletBoughtData;只列出页面所需字段。
|
||||
type PlayletBoughtData struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PlayName string `json:"playName"`
|
||||
Slug string `json:"slug"`
|
||||
Cover string `json:"cover"`
|
||||
ThemeNames []string `json:"themeNames"`
|
||||
TotalEpisode int `json:"totalEpisode"`
|
||||
BoughtChapterCount int `json:"boughtChapterCount"`
|
||||
CostCoins int64 `json:"costCoins"`
|
||||
BoughtAt string `json:"boughtAt"`
|
||||
LastChapterID int64 `json:"lastChapterId"`
|
||||
LastEpisode int `json:"lastEpisode"`
|
||||
WatchedCount int `json:"watchedCount"`
|
||||
}
|
||||
|
||||
type PlayletBoughtListResp struct {
|
||||
List []PlayletBoughtData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
func FetchBoughtList(c *gin.Context, pageNum, pageSize int) (*PlayletBoughtListResp, int, error) {
|
||||
if pageNum < 1 {
|
||||
pageNum = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(pageNum))
|
||||
params.Set("pageSize", strconv.Itoa(pageSize))
|
||||
var env APIEnvelope[PlayletBoughtListResp]
|
||||
if err := upstream.GetJSON(c, boughtListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("playlet bought list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
categoryDetailPath = "/api/app/playlet/recommend/list"
|
||||
defaultCategoryPageSize = 25
|
||||
)
|
||||
|
||||
// CategoryDetailQuery 分类详情分页参数。categorySlug 非空时优先使用 slug。
|
||||
type CategoryDetailQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
CategoryID int64
|
||||
CategorySlug string
|
||||
}
|
||||
|
||||
func (q *CategoryDetailQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultCategoryPageSize
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
if q.CategoryID < 0 {
|
||||
q.CategoryID = 0
|
||||
}
|
||||
q.CategorySlug = strings.TrimSpace(q.CategorySlug)
|
||||
}
|
||||
|
||||
func categoryDetailListURL(q CategoryDetailQuery) string {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
if q.CategorySlug != "" {
|
||||
params.Set("categorySlug", q.CategorySlug)
|
||||
} else if q.CategoryID > 0 {
|
||||
params.Set("categoryId", strconv.FormatInt(q.CategoryID, 10))
|
||||
}
|
||||
return categoryDetailPath + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// FetchCategoryDetail 根据分类 slug 或 ID 获取分类详情列表。
|
||||
func FetchCategoryDetail(c *gin.Context, q CategoryDetailQuery) (*PlayletRecommendListResp, error) {
|
||||
var env APIEnvelope[PlayletRecommendListResp]
|
||||
if err := upstream.GetPublicJSON(c, categoryDetailListURL(q), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet category detail code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchCategoryDetailOrEmpty 保持页面可渲染,接口失败时返回当前分页的空结果。
|
||||
func FetchCategoryDetailOrEmpty(c *gin.Context, q CategoryDetailQuery) *PlayletRecommendListResp {
|
||||
q.Normalize()
|
||||
data, err := FetchCategoryDetail(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet category detail failed", log.E(err))
|
||||
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func categoryDetailPageURL(categoryID int64, categorySlug string, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/category"
|
||||
if segment := pathSegment(categorySlug, categoryID); segment != "" {
|
||||
path += "/" + segment
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func categoryDetailURL(categorySlug string, categoryID int64) string {
|
||||
return categoryDetailPageURL(categoryID, categorySlug, 1)
|
||||
}
|
||||
|
||||
// ParseCategoryDetailQuery 从语义化路由读取分类标识和页码。
|
||||
func ParseCategoryDetailQuery(identifier, pageStr string) CategoryDetailQuery {
|
||||
q := CategoryDetailQuery{PageSize: defaultCategoryPageSize}
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if id, err := strconv.ParseInt(identifier, 10, 64); err == nil && id > 0 {
|
||||
q.CategoryID = id
|
||||
} else {
|
||||
q.CategorySlug = identifier
|
||||
}
|
||||
if page, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = page
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
func CategoryDetailCanonicalPath(q CategoryDetailQuery) string {
|
||||
q.Normalize()
|
||||
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, q.PageNum)
|
||||
}
|
||||
|
||||
func BuildCategoryDetailPagination(q CategoryDetailQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(page int) string {
|
||||
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, page)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
func CategoryDetailPageTitle(categoryName string, pageNum int, seo SeoInfo) string {
|
||||
name := strings.TrimSpace(categoryName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(seo.Title)
|
||||
}
|
||||
if name == "" {
|
||||
name = "分类短剧"
|
||||
}
|
||||
return name + PageTitleSuffix(pageNum)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
chapterDetailPath = "/api/app/playlet/chapter/detail"
|
||||
chapterListPath = "/api/app/playlet/chapter/list"
|
||||
)
|
||||
|
||||
// PlayQuery 播放页参数。
|
||||
type PlayQuery struct {
|
||||
PlayletID int64
|
||||
PlayletSlug string
|
||||
ChapterID int64
|
||||
}
|
||||
|
||||
// ParsePlayQuery 解析播放页的路径参数。
|
||||
func ParsePlayQuery(playletKey, chapterIDRaw string) PlayQuery {
|
||||
q := PlayQuery{PlayletSlug: strings.TrimSpace(playletKey)}
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(playletKey), 10, 64); err == nil && n > 0 {
|
||||
q.PlayletID = n
|
||||
q.PlayletSlug = ""
|
||||
}
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(chapterIDRaw), 10, 64); err == nil && n > 0 {
|
||||
q.ChapterID = n
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// FetchChapterDetail 查询剧集播放详情。
|
||||
func FetchChapterDetail(c *gin.Context, chapterID, playletID int64, playletSlug string) (*PlayletChapterInfoResp, error) {
|
||||
if chapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter detail invalid chapterId")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("chapterId", strconv.FormatInt(chapterID, 10))
|
||||
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
|
||||
params.Set("playletSlug", playletSlug)
|
||||
} else if playletID > 0 {
|
||||
params.Set("playletId", strconv.FormatInt(playletID, 10))
|
||||
}
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
var env APIEnvelope[PlayletChapterInfoResp]
|
||||
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("chapter detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
env.Data.SeoInfo.Normalize()
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchChapterDetailOrNil 失败返回 nil。
|
||||
func FetchChapterDetailOrNil(c *gin.Context, chapterID, playletID int64, playletSlug string) *PlayletChapterInfoResp {
|
||||
data, err := FetchChapterDetail(c, chapterID, playletID, playletSlug)
|
||||
if err != nil {
|
||||
log.Error("fetch chapter detail failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
if data == nil || data.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchChapterList 根据短剧 slug 或 ID 查询选集列表,slug 优先。
|
||||
func FetchChapterList(c *gin.Context, playletID int64, playletSlug string) (*PlayletChapterListResp, error) {
|
||||
params := url.Values{}
|
||||
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
|
||||
params.Set("playletSlug", playletSlug)
|
||||
} else if playletID > 0 {
|
||||
params.Set("playletId", strconv.FormatInt(playletID, 10))
|
||||
} else {
|
||||
return nil, fmt.Errorf("chapter list missing playletSlug or playletId")
|
||||
}
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
var env APIEnvelope[PlayletChapterListResp]
|
||||
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet chapter list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// ResolvePlayChapterID 无 chapterId 时取第 1 集(currentEpisode=1),否则取列表首条。
|
||||
func ResolvePlayChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
|
||||
if chapterID > 0 {
|
||||
return chapterID
|
||||
}
|
||||
if playlet == nil {
|
||||
return 0
|
||||
}
|
||||
var firstID int64
|
||||
for _, ch := range playlet.PlayletChapterInfo {
|
||||
if ch.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if firstID == 0 {
|
||||
firstID = ch.ID
|
||||
}
|
||||
if ch.CurrentEpisode == 1 {
|
||||
return ch.ID
|
||||
}
|
||||
}
|
||||
return firstID
|
||||
}
|
||||
|
||||
// FindChapter 在详情选集中按 chapterId 查找。
|
||||
func FindChapter(playlet *PlayletInfoResp, chapterID int64) *PlayletChapterData {
|
||||
if playlet == nil || chapterID <= 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range playlet.PlayletChapterInfo {
|
||||
if playlet.PlayletChapterInfo[i].ID == chapterID {
|
||||
return &playlet.PlayletChapterInfo[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NextChapterID 返回下一集 chapterId;没有则 0。
|
||||
func NextChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
|
||||
if playlet == nil || chapterID <= 0 {
|
||||
return 0
|
||||
}
|
||||
found := false
|
||||
for _, ch := range playlet.PlayletChapterInfo {
|
||||
if found && ch.ID > 0 {
|
||||
return ch.ID
|
||||
}
|
||||
if ch.ID == chapterID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FirstUnlockCost 取第一个需金币解锁的单集价格。
|
||||
func FirstUnlockCost(chapters []PlayletChapterData) int {
|
||||
for _, ch := range chapters {
|
||||
if ch.NeedCoinGate() {
|
||||
return ch.UnlockCost()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ChapterFromList 将详情选集条目转成章节详情结构(chapter/detail 失败时的 UI 兜底)。
|
||||
func ChapterFromList(ch PlayletChapterData) *PlayletChapterInfoResp {
|
||||
return &PlayletChapterInfoResp{
|
||||
ID: ch.ID,
|
||||
PlayletID: ch.PlayletID,
|
||||
CurrentEpisode: ch.CurrentEpisode,
|
||||
Title: ch.Title,
|
||||
MD5: ch.MD5,
|
||||
Hash: ch.Hash,
|
||||
Height: ch.Height,
|
||||
Width: ch.Width,
|
||||
Ratio: ch.Ratio,
|
||||
VideoURL: ch.VideoURL,
|
||||
MediaSize: ch.MediaSize,
|
||||
MediaTime: ch.MediaTime,
|
||||
PayType: ch.PayType,
|
||||
SingleCoins: ch.SingleCoins,
|
||||
CostCoins: ch.CostCoins,
|
||||
Balance: ch.Balance,
|
||||
CanWatch: ch.CanWatch,
|
||||
IsUnlocked: ch.IsUnlocked,
|
||||
NeedUnlock: ch.NeedUnlock,
|
||||
NeedVip: ch.NeedVip,
|
||||
VipValid: ch.VipValid,
|
||||
}
|
||||
}
|
||||
|
||||
// AllowWatch 是否可播放(优先 canWatch;兼容仅返回 videoUrl 的旧数据)。
|
||||
func (c *PlayletChapterInfoResp) AllowWatch() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if c.CanWatch {
|
||||
return true
|
||||
}
|
||||
if c.NeedUnlock || c.NeedVip {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(c.VideoURL) != "" && c.PayType == 0
|
||||
}
|
||||
|
||||
// UnlockCost 金币解锁消耗:优先 costCoins。
|
||||
func (c *PlayletChapterInfoResp) UnlockCost() int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
if c.CostCoins > 0 {
|
||||
return c.CostCoins
|
||||
}
|
||||
if c.SingleCoins > 0 {
|
||||
return int64(c.SingleCoins)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AllowWatch 详情选集是否可直达播放(优先 canWatch)。
|
||||
func (c PlayletChapterData) AllowWatch() bool {
|
||||
if c.CanWatch {
|
||||
return true
|
||||
}
|
||||
if c.NeedUnlock || c.NeedVip {
|
||||
return false
|
||||
}
|
||||
if c.IsUnlocked {
|
||||
return true
|
||||
}
|
||||
return c.PayType == 0
|
||||
}
|
||||
|
||||
// NeedCoinGate 是否弹出金币解锁。
|
||||
func (c PlayletChapterData) NeedCoinGate() bool {
|
||||
if c.AllowWatch() {
|
||||
return false
|
||||
}
|
||||
if c.NeedUnlock {
|
||||
return true
|
||||
}
|
||||
return c.PayType == 1
|
||||
}
|
||||
|
||||
// NeedVipGate 是否弹出 VIP 开通。
|
||||
func (c PlayletChapterData) NeedVipGate() bool {
|
||||
if c.AllowWatch() || c.NeedCoinGate() {
|
||||
return false
|
||||
}
|
||||
if c.NeedVip {
|
||||
return true
|
||||
}
|
||||
return c.PayType == 2
|
||||
}
|
||||
|
||||
// UnlockCost 金币解锁消耗:优先 costCoins。
|
||||
func (c PlayletChapterData) UnlockCost() int {
|
||||
if c.CostCoins > 0 {
|
||||
return int(c.CostCoins)
|
||||
}
|
||||
return c.SingleCoins
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const chapterCollectListPath = "/api/app/playlet/chapter/collect/list"
|
||||
|
||||
// PlayletChapterCollectData 对应 swagger proto.PlayletChapterCollectData
|
||||
type PlayletChapterCollectData struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
PlayletSlug string `json:"playletSlug"`
|
||||
PlayletTitle string `json:"playletTitle"`
|
||||
PlayletCover string `json:"playletCover"`
|
||||
CurrentEpisode int `json:"currentEpisode"`
|
||||
TotalEpisode int `json:"totalEpisode"`
|
||||
UpdateStatus int `json:"updateStatus"`
|
||||
CountBrowse int64 `json:"countBrowse"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
CollectedAt string `json:"collectedAt"`
|
||||
MediaTime int `json:"mediaTime"`
|
||||
PayType int `json:"payType"`
|
||||
SingleCoins int `json:"singleCoins"`
|
||||
VideoURL string `json:"videoUrl"`
|
||||
}
|
||||
|
||||
// PlayletChapterCollectListResp 对应 swagger proto.PlayletChapterCollectListResp
|
||||
type PlayletChapterCollectListResp struct {
|
||||
List []PlayletChapterCollectData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// FetchChapterCollectList 拉取当前用户剧集收藏列表。
|
||||
// 第二个返回值为上游业务 code(网络错误时为 0)。
|
||||
func FetchChapterCollectList(c *gin.Context, q CollectListQuery) (*PlayletChapterCollectListResp, int, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[PlayletChapterCollectListResp]
|
||||
if err := upstream.GetJSON(c, chapterCollectListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("playlet chapter collect list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
|
||||
// FetchChapterCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
|
||||
func FetchChapterCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletChapterCollectListResp {
|
||||
data, code, err := FetchChapterCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
data, _, err = FetchChapterCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
log.Error("fetch playlet chapter collect list failed", log.E(err))
|
||||
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
chapterLikeSetPath = "/api/app/playlet/chapter/like/set"
|
||||
chapterCollectSetPath = "/api/app/playlet/chapter/collect/set"
|
||||
)
|
||||
|
||||
// PlayletChapterLikeSetReq 对应 swagger proto.PlayletChapterLikeSetReq
|
||||
type PlayletChapterLikeSetReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
IsLike bool `json:"isLike"`
|
||||
}
|
||||
|
||||
// PlayletChapterLikeSetResp 对应 swagger proto.PlayletChapterLikeSetResp
|
||||
type PlayletChapterLikeSetResp struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsLike int `json:"isLike"`
|
||||
}
|
||||
|
||||
// ChapterLikeSet 设置剧集点赞/取消点赞。
|
||||
func ChapterLikeSet(c *gin.Context, req PlayletChapterLikeSetReq) (*APIEnvelope[PlayletChapterLikeSetResp], error) {
|
||||
if req.ChapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter like invalid chapterId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterLikeSetResp]
|
||||
if _, err := upstream.PostJSON(c, chapterLikeSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// PlayletChapterCollectSetReq 对应 swagger proto.PlayletChapterCollectSetReq
|
||||
type PlayletChapterCollectSetReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
IsCollect bool `json:"isCollect"`
|
||||
}
|
||||
|
||||
// PlayletChapterCollectSetResp 对应 swagger proto.PlayletChapterCollectSetResp
|
||||
type PlayletChapterCollectSetResp struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
}
|
||||
|
||||
// ChapterCollectSet 设置剧集收藏/取消收藏。
|
||||
func ChapterCollectSet(c *gin.Context, req PlayletChapterCollectSetReq) (*APIEnvelope[PlayletChapterCollectSetResp], error) {
|
||||
if req.ChapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter collect invalid chapterId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterCollectSetResp]
|
||||
if _, err := upstream.PostJSON(c, chapterCollectSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const chapterUnlockPath = "/api/app/playlet/chapter/unlock"
|
||||
|
||||
// PlayletChapterUnlockReq 对应 swagger proto.PlayletChapterUnlockReq
|
||||
// POST /api/app/playlet/chapter/unlock
|
||||
type PlayletChapterUnlockReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
}
|
||||
|
||||
// PlayletChapterUnlockResp 对应 swagger proto.PlayletChapterUnlockResp
|
||||
type PlayletChapterUnlockResp struct {
|
||||
Balance int64 `json:"balance"`
|
||||
CanWatch bool `json:"canWatch"`
|
||||
ChangeCoin int64 `json:"changeCoin"`
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
CostCoins int64 `json:"costCoins"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
Unlocked bool `json:"unlocked"`
|
||||
}
|
||||
|
||||
// ChapterUnlock 使用金币解锁剧集(幂等)。
|
||||
func ChapterUnlock(c *gin.Context, req PlayletChapterUnlockReq) (*APIEnvelope[PlayletChapterUnlockResp], error) {
|
||||
if req.ChapterID <= 0 || req.PlayletID <= 0 {
|
||||
return nil, fmt.Errorf("chapter unlock invalid chapterId/playletId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterUnlockResp]
|
||||
if _, err := upstream.PostJSON(c, chapterUnlockPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const chapterWatchReportPath = "/api/app/playlet/chapter/watch/report"
|
||||
|
||||
// PlayletChapterWatchReportReq 对应 swagger proto.PlayletChapterWatchReportReq
|
||||
type PlayletChapterWatchReportReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId,omitempty"`
|
||||
}
|
||||
|
||||
// PlayletChapterWatchReportResp 对应 swagger proto.PlayletChapterWatchReportResp
|
||||
type PlayletChapterWatchReportResp struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
CurrentEpisode int `json:"currentEpisode"`
|
||||
LastChapterID int64 `json:"lastChapterId"`
|
||||
LastEpisode int `json:"lastEpisode"`
|
||||
WatchedCount int `json:"watchedCount"`
|
||||
UnwatchedCount int `json:"unwatchedCount"`
|
||||
}
|
||||
|
||||
// ChapterWatchReport 上报短剧剧集观看。
|
||||
func ChapterWatchReport(c *gin.Context, req PlayletChapterWatchReportReq) (*APIEnvelope[PlayletChapterWatchReportResp], error) {
|
||||
if req.ChapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter watch report invalid chapterId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterWatchReportResp]
|
||||
if _, err := upstream.PostJSON(c, chapterWatchReportPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
collectSetPath = "/api/app/playlet/collect/set"
|
||||
collectListPath = "/api/app/playlet/collect/list"
|
||||
defaultCollectPageSize = 40
|
||||
|
||||
CollectTypePlaylet = "playlet"
|
||||
CollectTypeChapter = "chapter"
|
||||
)
|
||||
|
||||
// PlayletCollectSetReq 对应 swagger proto.PlayletCollectSetReq
|
||||
type PlayletCollectSetReq struct {
|
||||
ID int64 `json:"id"`
|
||||
IsCollect bool `json:"isCollect"`
|
||||
}
|
||||
|
||||
// PlayletCollectSetResp 对应 swagger proto.PlayletCollectSetResp
|
||||
type PlayletCollectSetResp struct {
|
||||
ID int64 `json:"id"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
}
|
||||
|
||||
// PlayletCollectData 对应 swagger proto.PlayletCollectData
|
||||
type PlayletCollectData struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PlayName string `json:"playName"`
|
||||
Slug string `json:"slug"`
|
||||
Cover string `json:"cover"`
|
||||
Desc string `json:"desc"`
|
||||
TagIDs []int64 `json:"tagIds"`
|
||||
TagNames []string `json:"tagNames"`
|
||||
TagSlugs []string `json:"tagSlugs"`
|
||||
ThemeIDs []int64 `json:"themeIds"`
|
||||
ThemeNames []string `json:"themeNames"`
|
||||
ThemeSlugs []string `json:"themeSlugs"`
|
||||
TotalEpisode int `json:"totalEpisode"`
|
||||
UpdateStatus int `json:"updateStatus"`
|
||||
CountBrowse int64 `json:"countBrowse"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsFeatured bool `json:"isFeatured"`
|
||||
IsNew bool `json:"isNew"`
|
||||
IsRecommend bool `json:"isRecommend"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
CollectedAt string `json:"collectedAt"`
|
||||
WatchedCount int `json:"watchedCount"`
|
||||
UnwatchedCount int `json:"unwatchedCount"`
|
||||
LastChapterID int64 `json:"lastChapterId"`
|
||||
LastEpisode int `json:"lastEpisode"`
|
||||
}
|
||||
|
||||
// PlayletCollectListResp 对应 swagger proto.PlayletCollectListResp
|
||||
type PlayletCollectListResp struct {
|
||||
List []PlayletCollectData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// CollectListQuery 收藏列表分页参数。
|
||||
type CollectListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
WatchStatus int // 0 全部、1 追剧中、2 已看完;仅短剧收藏列表使用
|
||||
Type string // playlet | chapter
|
||||
}
|
||||
|
||||
// NormalizeCollectType 归一化收藏类型。
|
||||
func NormalizeCollectType(raw string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(raw)) {
|
||||
case CollectTypeChapter, "2", "episode", "ep":
|
||||
return CollectTypeChapter
|
||||
default:
|
||||
return CollectTypePlaylet
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *CollectListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultCollectPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.WatchStatus < 0 || q.WatchStatus > 2 {
|
||||
q.WatchStatus = 0
|
||||
}
|
||||
q.Type = NormalizeCollectType(q.Type)
|
||||
}
|
||||
|
||||
// CollectSet 设置短剧收藏/取消收藏。
|
||||
func CollectSet(c *gin.Context, req PlayletCollectSetReq) (*APIEnvelope[PlayletCollectSetResp], error) {
|
||||
if req.ID <= 0 {
|
||||
return nil, fmt.Errorf("playlet collect invalid id")
|
||||
}
|
||||
var env APIEnvelope[PlayletCollectSetResp]
|
||||
if _, err := upstream.PostJSON(c, collectSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchCollectList 拉取当前用户短剧收藏列表。
|
||||
// 第二个返回值为上游业务 code(网络错误时为 0)。
|
||||
func FetchCollectList(c *gin.Context, q CollectListQuery) (*PlayletCollectListResp, int, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("watchStatus", strconv.Itoa(q.WatchStatus))
|
||||
|
||||
var env APIEnvelope[PlayletCollectListResp]
|
||||
if err := upstream.GetJSON(c, collectListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("playlet collect list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
|
||||
// FetchCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
|
||||
func FetchCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletCollectListResp {
|
||||
data, code, err := FetchCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
data, _, err = FetchCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
log.Error("fetch playlet collect list failed", log.E(err))
|
||||
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user