@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
~$*
|
||||
outputs/
|
||||
.codex/
|
||||
app/docs/
|
||||
*.tar.gz
|
||||
@@ -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_mdfls_h5
|
||||
env GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_mdfls_h5 -v
|
||||
tar czf web_mdfls_h5.tar.gz web_mdfls_h5 templates/ static/ conf/m3u8.key
|
||||
rm -f web_mdfls_h5
|
||||
|
||||
test:
|
||||
@echo "test, GOPATH=$(GOPATH)"
|
||||
GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_mdfls_h5
|
||||
tar czf web_mdfls_h5.tar.gz web_mdfls_h5 templates/ static/ conf/m3u8.key
|
||||
rm -f web_mdfls_h5
|
||||
|
||||
|
||||
localtest:
|
||||
@echo "test, GOPATH=$(GOPATH)"
|
||||
go build $(APP_BUILD_FLAG) -o app
|
||||
@@ -0,0 +1,23 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/applistser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Application 福利应用页 SSR:拉取 /api/app/applist/list。
|
||||
func Application(c *gin.Context) {
|
||||
raw := applistser.FetchListOrEmpty(c, 0)
|
||||
vm := applistser.BuildPageVM(raw)
|
||||
data := gin.H{
|
||||
"title": "福利应用",
|
||||
"pageName": "应用推荐",
|
||||
"navActive": "application",
|
||||
"categories": vm.Categories,
|
||||
}
|
||||
if len(vm.Categories) == 0 {
|
||||
setNoIndex(data, true)
|
||||
}
|
||||
HTMLOK(c, "application.html", data)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CategoryDetail 分类详情页:按分类 slug 查询,slug 缺失时兼容数值 ID。
|
||||
func CategoryDetail(c *gin.Context) {
|
||||
identifier := strings.TrimSpace(c.Param("slug"))
|
||||
if identifier == "" {
|
||||
NotFound(c, "分类不存在")
|
||||
return
|
||||
}
|
||||
|
||||
q := playletser.ParseCategoryDetailQuery(identifier, routeOrQuery(c, "page", "page"))
|
||||
raw := playletser.FetchCategoryDetailOrEmpty(c, q)
|
||||
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
if emptyScopedPage(true, raw.TotalCount, len(raw.List)) {
|
||||
NotFound(c, "分类不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 接口返回了规范 slug 时优先使用 slug;没有 slug 才继续使用 ID。
|
||||
if slug := strings.TrimSpace(raw.CategorySlug); slug != "" {
|
||||
q.CategorySlug = slug
|
||||
q.CategoryID = raw.CategoryID
|
||||
} else if raw.CategoryID > 0 {
|
||||
q.CategoryID = raw.CategoryID
|
||||
}
|
||||
if redirectCanonical(c, playletser.CategoryDetailCanonicalPath(q)) {
|
||||
return
|
||||
}
|
||||
|
||||
pageName := strings.TrimSpace(raw.CategoryName)
|
||||
if pageName == "" {
|
||||
pageName = strings.TrimSpace(raw.SeoInfo.Title)
|
||||
}
|
||||
if pageName == "" {
|
||||
pageName = identifier
|
||||
}
|
||||
description := strings.TrimSpace(raw.SeoInfo.Description)
|
||||
if description == "" {
|
||||
description = "汇集近期最新发布的短剧、漫剧、AI换脸、AI魔改等内容,持续更新不同题材与类型的新作品,方便快速发现站内最近上线和最新更新的精彩内容。"
|
||||
}
|
||||
title := playletser.CategoryDetailPageTitle(pageName, q.PageNum, raw.SeoInfo)
|
||||
|
||||
data := gin.H{
|
||||
"title": title,
|
||||
"pageKey": "category_detail",
|
||||
"pageName": pageName,
|
||||
"pageTitle": pageName,
|
||||
"categoryDescription": description,
|
||||
"sectionTitle": "最新上架作品",
|
||||
"pageNum": q.PageNum,
|
||||
"pagePath": playletser.CategoryDetailCanonicalPath(q),
|
||||
"seoInfo": raw.SeoInfo,
|
||||
"list": raw.List,
|
||||
"totalCount": raw.TotalCount,
|
||||
"itemListOffset": (q.PageNum - 1) * q.PageSize,
|
||||
"pagination": playletser.BuildCategoryDetailPagination(q, raw.TotalCount),
|
||||
}
|
||||
if raw.TotalCount <= 0 || len(raw.List) == 0 {
|
||||
setNoIndex(data, true)
|
||||
}
|
||||
HTMLOK(c, "categoryDetail.html", data)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ChapterDetail 代理上游 GET /api/app/playlet/chapter/detail(播放页 CSR 切集)。
|
||||
func ChapterDetail(c *gin.Context) {
|
||||
chapterID, _ := strconv.ParseInt(c.Query("chapterId"), 10, 64)
|
||||
playletID, _ := strconv.ParseInt(c.Query("playletId"), 10, 64)
|
||||
playletSlug := strings.TrimSpace(c.Query("playletSlug"))
|
||||
if chapterID <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "缺少 chapterId",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := playletser.FetchChapterDetail(c, chapterID, playletID, playletSlug)
|
||||
if err != nil {
|
||||
log.Error("chapter detail upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "chapter detail failed",
|
||||
"tip": "获取剧集失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"tip": "成功",
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"videoweb/app/service/commentser"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CommentList 代理上游 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"))
|
||||
|
||||
q := commentser.ListQuery{
|
||||
ObjectID: objectID,
|
||||
ObjectType: objectType,
|
||||
PageNum: pageNum,
|
||||
PageSize: pageSize,
|
||||
SortOrder: sortOrder,
|
||||
}
|
||||
if q.ObjectID <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "缺少 objectId",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
data, err := commentser.FetchList(c, q)
|
||||
if err != nil {
|
||||
log.Error("comment list upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "comment list failed",
|
||||
"tip": "获取评论失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"tip": "成功",
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Detail 详情页 SSR:GET /api/app/playlet/detail;seoInfo 直接注入模板。
|
||||
func Detail(c *gin.Context) {
|
||||
key := strings.TrimSpace(c.Param("slug"))
|
||||
if key == "" {
|
||||
NotFound(c, "未找到该短剧")
|
||||
return
|
||||
}
|
||||
raw := playletser.FetchDetailOrNilByKey(c, key)
|
||||
if raw == nil {
|
||||
NotFound(c, "未找到该短剧")
|
||||
return
|
||||
}
|
||||
// 有 slug 使用 slug,否则保留 ID 路由。
|
||||
if redirectCanonical(c, playletser.DetailPath(raw.Slug, raw.ID)) {
|
||||
return
|
||||
}
|
||||
HTMLOK(c, "detail.html", gin.H{
|
||||
"title": raw.Title,
|
||||
"detail": raw,
|
||||
"seoInfo": raw.SeoInfo,
|
||||
"episodeRanges": playletser.BuildEpisodeRanges(raw.PlayletChapterInfo, raw.TotalEpisode, 30),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"videoweb/app/service/configser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var dialogTemplates = map[string]string{
|
||||
"download": "downloadAppDialog",
|
||||
"search": "searchDialog",
|
||||
}
|
||||
|
||||
// DialogFragment renders only the requested dialog body. Styles and behavior
|
||||
// are loaded by dialogLoader.js after the first user interaction.
|
||||
func DialogFragment(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
templateName, ok := dialogTemplates[name]
|
||||
if !ok {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{}
|
||||
if name == "download" {
|
||||
data["appConfig"] = configser.FetchAppConfigVMOrEmpty(c)
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", "private, max-age=300")
|
||||
c.HTML(http.StatusOK, templateName, data)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/feedbackser"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type feedbackBody struct {
|
||||
Type int `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Contact string `json:"contact"`
|
||||
Screenshots []string `json:"screenshots"`
|
||||
}
|
||||
|
||||
type contactUsBody struct {
|
||||
Type int `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Email string `json:"email"`
|
||||
Attachments []string `json:"attachments"`
|
||||
}
|
||||
|
||||
// AddFeedback 代理上游 POST /api/app/feedback/add。
|
||||
func AddFeedback(c *gin.Context) {
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
|
||||
var body feedbackBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"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": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "请选择问题类型",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.Content == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "请填写问题描述",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(body.Content) > 500 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "问题描述最多 500 字",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
shots := make([]string, 0, len(body.Screenshots))
|
||||
for _, s := range body.Screenshots {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
shots = append(shots, s)
|
||||
}
|
||||
}
|
||||
if len(shots) > 10 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "最多上传 10 张图片",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := feedbackser.Add(c, feedbackser.FeedbackReq{
|
||||
Type: body.Type,
|
||||
Content: body.Content,
|
||||
Contact: body.Contact,
|
||||
Screenshots: shots,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("feedback add upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "feedback failed",
|
||||
"tip": "提交失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env != nil && TryJSONSessionRecover(c, env.Code) {
|
||||
env, err = feedbackser.Add(c, feedbackser.FeedbackReq{
|
||||
Type: body.Type,
|
||||
Content: body.Content,
|
||||
Contact: body.Contact,
|
||||
Screenshots: shots,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("feedback add retry failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "feedback failed",
|
||||
"tip": "提交失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if env == nil || env.Code != 200 {
|
||||
tip := "提交失败"
|
||||
msg := ""
|
||||
code := 500
|
||||
if env != nil {
|
||||
code = env.Code
|
||||
msg = env.Msg
|
||||
tip = firstNonEmpty(env.Tip, env.Msg, tip)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
"tip": tip,
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": firstNonEmpty(env.Msg, "success"),
|
||||
"tip": firstNonEmpty(env.Tip, "提交成功"),
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// AddContactUs 代理上游 POST /api/app/contactus/add。
|
||||
func AddContactUs(c *gin.Context) {
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
var body contactUsBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1001, "msg": "参数错误", "tip": "参数错误", "data": nil})
|
||||
return
|
||||
}
|
||||
body.Title = strings.TrimSpace(body.Title)
|
||||
body.Content = strings.TrimSpace(body.Content)
|
||||
body.Email = strings.TrimSpace(body.Email)
|
||||
if body.Type < 1 || body.Type > 5 || body.Title == "" || body.Content == "" || body.Email == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 1001, "msg": "参数错误", "tip": "请完整填写问题类型、标题、内容和邮箱", "data": nil})
|
||||
return
|
||||
}
|
||||
attachments := make([]string, 0, len(body.Attachments))
|
||||
for _, attachment := range body.Attachments {
|
||||
if attachment = strings.TrimSpace(attachment); attachment != "" {
|
||||
attachments = append(attachments, attachment)
|
||||
}
|
||||
}
|
||||
req := feedbackser.ContactUsAddReq{
|
||||
Type: body.Type, Title: body.Title, Content: body.Content, Email: body.Email, Attachments: attachments,
|
||||
}
|
||||
env, err := feedbackser.AddContactUs(c, req)
|
||||
if err == nil && env != nil && TryJSONSessionRecover(c, env.Code) {
|
||||
env, err = feedbackser.AddContactUs(c, req)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("contact us upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "contact us failed", "tip": "提交失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
if env == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "contact us failed", "tip": "提交失败", "data": nil})
|
||||
return
|
||||
}
|
||||
defaultTip := "提交失败"
|
||||
if env.Code == 200 {
|
||||
defaultTip = "提交成功"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": env.Code,
|
||||
"msg": env.Msg,
|
||||
"tip": firstNonEmpty(env.Tip, env.Msg, defaultTip),
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/seoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HomeRoad 渲染黄果短剧最新地址与防失联入口页。
|
||||
func HomeRoad(c *gin.Context) {
|
||||
cfg := configser.FetchAppConfigVMOrEmpty(c)
|
||||
HTMLOK(c, "homeRoad.html", gin.H{
|
||||
"title": "回家的路 - 黄果短剧官方最新地址发布页",
|
||||
"pageKey": "home_road",
|
||||
"pageName": "回家的路",
|
||||
"pagePath": "/home-road/",
|
||||
"navActive": "homeRoad",
|
||||
"appConfig": cfg,
|
||||
"homeRoad": configser.BuildHomeRoadPageVM(cfg),
|
||||
"faqItems": []seoser.FAQItem{
|
||||
{Question: "回家的路页面有什么作用?", Answer: "集中展示最新地址、永久入口、备用入口和保存方式,方便访问异常时快速返回黄果短剧。"},
|
||||
{Question: "建议保存哪个入口?", Answer: "优先收藏当前推荐入口,并额外保存永久入口或备用入口。"},
|
||||
{Question: "入口打不开怎么办?", Answer: "先切换网络,再尝试永久入口或备用入口;仍无法访问时返回本页查看最新地址。"},
|
||||
{Question: "如何确认入口是否安全?", Answer: "只从本页、站内导航或黄果社区提示进入,不要从陌生弹窗、未知短链跳转。"},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HotLike 代理上游 GET /api/app/playlet/hot-like。
|
||||
func HotLike(c *gin.Context) {
|
||||
data, err := playletser.FetchHotLike(c)
|
||||
if err != nil {
|
||||
log.Error("playlet hot-like upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "playlet hot-like failed",
|
||||
"tip": "获取热搜和猜你喜欢失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"tip": "成功",
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Index 首页 SSR:拉取 /api/app/playlet/home,直接用接口字段渲染。
|
||||
func Index(c *gin.Context) {
|
||||
raw := playletser.FetchHomeOrEmpty(c)
|
||||
data := gin.H{
|
||||
"title": "黄果短剧",
|
||||
"navActive": "home",
|
||||
"homeResponse": raw,
|
||||
"seoInfo": raw.SeoInfo,
|
||||
"announcements": raw.Announcements,
|
||||
"homeCategoryData": raw.HomeCategoryData,
|
||||
"homeRecommendThemes": raw.HomeRecommendThemes,
|
||||
"homeBannerData": playletser.ActiveHomeBanners(raw.HomeBannerData),
|
||||
}
|
||||
HTMLOK(c, "index.html", data)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// List 短剧列表页 SSR:GET /api/app/playlet/operate/list,operateType 固定为 1。
|
||||
func List(c *gin.Context) {
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
if slug == "ai-series" {
|
||||
legacy := playletser.ParseListQuery(
|
||||
routeOrQuery(c, "page", "page"),
|
||||
routeOrQuery(c, "sortOrder", "sortOrder"),
|
||||
c.Query("type"),
|
||||
)
|
||||
redirectCanonical(c, playletser.SeriesPathFromLegacyList(legacy.SortOrder, legacy.PageNum))
|
||||
return
|
||||
}
|
||||
filter := routeOrQuery(c, "sortOrder", "filterType")
|
||||
if filter == "" {
|
||||
filter = strings.TrimSpace(c.Query("sortOrder"))
|
||||
}
|
||||
q, ok := playletser.ParseShortPlayQuery(filter, routeOrQuery(c, "page", "page"))
|
||||
if !ok {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
if redirectCanonical(c, playletser.ShortPlayCanonicalPath(q)) {
|
||||
return
|
||||
}
|
||||
raw := playletser.FetchOperateListOrEmpty(c, q)
|
||||
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
sectionTitle := playletser.ShortPlaySectionTitle(q.FilterType)
|
||||
data := gin.H{
|
||||
"title": "短剧",
|
||||
"description": "浏览黄果短剧全部短剧列表,按分类、标签筛选海量高清短剧,发现更多精彩剧集。",
|
||||
"pageKey": "list",
|
||||
"pageName": "AI成人短剧",
|
||||
"pagePath": playletser.ShortPlayCanonicalPath(q),
|
||||
"navActive": "list",
|
||||
"seoInfo": playletser.SeoInfo{},
|
||||
"seoSelections": []playletser.SEOSelection{{Dimension: "筛选", Value: strings.TrimSuffix(sectionTitle, "的AI成人短剧")}},
|
||||
"filters": playletser.BuildShortPlayFilterTabs(q),
|
||||
"sectionTitle": sectionTitle,
|
||||
"list": raw.List,
|
||||
"itemListOffset": (q.PageNum - 1) * q.PageSize,
|
||||
"pagination": playletser.BuildShortPlayPagination(q, raw.TotalCount),
|
||||
}
|
||||
// /list/ai-short-play/original/ 入口页始终可索引;其余空结果仍 noindex。
|
||||
isOriginalEntry := q.FilterType == 3 && q.PageNum <= 1
|
||||
if !isOriginalEntry && (raw.TotalCount <= 0 || len(raw.List) == 0) {
|
||||
setNoIndex(data, true)
|
||||
}
|
||||
HTMLOK(c, "list.html", data)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// More 专题详情页 SSR:专题由路由 slug 标识;历史 /more 地址会跳转到 /topic canonical。
|
||||
func More(c *gin.Context) {
|
||||
q := playletser.ParseRecommendQuery(routeOrQuery(c, "page", "page"))
|
||||
if slug := strings.TrimSpace(c.Param("slug")); slug != "" {
|
||||
if id := playletser.ParseDetailID(slug); id > 0 {
|
||||
q.ThemeID = id
|
||||
} else {
|
||||
q.ThemeSlug = slug
|
||||
}
|
||||
}
|
||||
if path := c.Request.URL.Path; path == "/more" || strings.HasPrefix(path, "/more/") {
|
||||
redirectCanonical(c, playletser.RecommendPagePath(q))
|
||||
return
|
||||
}
|
||||
themes := playletser.FetchThemesOrEmpty(c, 2)
|
||||
themeName := ""
|
||||
for _, theme := range themes {
|
||||
matchesID := q.ThemeID > 0 && theme.ID == q.ThemeID
|
||||
matchesSlug := strings.TrimSpace(q.ThemeSlug) != "" && strings.EqualFold(strings.TrimSpace(theme.Slug), strings.TrimSpace(q.ThemeSlug))
|
||||
if !matchesID && !matchesSlug {
|
||||
continue
|
||||
}
|
||||
themeName = strings.TrimSpace(theme.Name)
|
||||
if q.ThemeID <= 0 {
|
||||
q.ThemeID = theme.ID
|
||||
}
|
||||
if strings.TrimSpace(theme.Slug) != "" {
|
||||
q.ThemeSlug = strings.TrimSpace(theme.Slug)
|
||||
}
|
||||
break
|
||||
}
|
||||
if redirectCanonical(c, playletser.RecommendPagePath(q)) {
|
||||
return
|
||||
}
|
||||
raw := playletser.FetchRecommendListOrEmpty(c, q)
|
||||
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
hasIdentifier := q.ThemeID > 0 || strings.TrimSpace(q.ThemeSlug) != ""
|
||||
if emptyScopedPage(hasIdentifier, raw.TotalCount, len(raw.List)) {
|
||||
NotFound(c, "专题不存在")
|
||||
return
|
||||
}
|
||||
title := playletser.RecommendThemeTitle(themeName, raw.SeoInfo.Title, q.ThemeID)
|
||||
if queryTitle := strings.TrimSpace(c.Query("title")); queryTitle != "" {
|
||||
title = queryTitle
|
||||
}
|
||||
heading := title
|
||||
if !strings.HasSuffix(heading, "介绍") {
|
||||
heading += "介绍"
|
||||
}
|
||||
description := strings.TrimSpace(raw.SeoInfo.Description)
|
||||
if description == "" {
|
||||
description = "精品高分 AI 成人短剧专题,精选高人气、高评分 AI 原创短剧,每日持续更新。涵盖多种热门剧情与角色设定,为你带来高质量、高清流畅的观影体验。"
|
||||
}
|
||||
data := gin.H{
|
||||
"title": title,
|
||||
"pageKey": "topic",
|
||||
"pageName": title,
|
||||
"pageTitle": title,
|
||||
"topicHeading": heading,
|
||||
"topicDescription": description,
|
||||
"pagePath": playletser.RecommendCanonicalPath(q.ThemeID, q.ThemeSlug),
|
||||
"seoInfo": raw.SeoInfo,
|
||||
"list": raw.List,
|
||||
"itemListOffset": (q.PageNum - 1) * q.PageSize,
|
||||
"pagination": playletser.BuildRecommendPagination(q, raw.TotalCount),
|
||||
}
|
||||
if raw.TotalCount <= 0 || len(raw.List) == 0 {
|
||||
setNoIndex(data, true)
|
||||
}
|
||||
HTMLOK(c, "more.html", data)
|
||||
}
|
||||
@@ -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,27 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NotFound 统一 404 页(HTTP 404)。
|
||||
func NotFound(c *gin.Context, title string) {
|
||||
if title == "" {
|
||||
title = "页面未找到"
|
||||
}
|
||||
data := gin.H{
|
||||
"title": title,
|
||||
"path": c.Request.URL.RequestURI(),
|
||||
}
|
||||
setNoIndex(data, true)
|
||||
HTML(c, http.StatusNotFound, "404.html", data)
|
||||
}
|
||||
|
||||
// emptyScopedPage 判断带明确资源标识的列表详情是否不存在。
|
||||
// 聚合页允许暂时为空;标签、分类、专题等详情页成功返回空结果时应响应 404,
|
||||
// 避免用空模板和 HTTP 200 形成搜索引擎软 404。
|
||||
func emptyScopedPage(hasIdentifier bool, totalCount int64, itemCount int) bool {
|
||||
return hasIdentifier && totalCount <= 0 && itemCount == 0
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEmptyScopedPage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hasIdentifier bool
|
||||
totalCount int64
|
||||
itemCount int
|
||||
want bool
|
||||
}{
|
||||
{name: "missing detail", hasIdentifier: true, want: true},
|
||||
{name: "empty aggregate page", hasIdentifier: false, want: false},
|
||||
{name: "has total", hasIdentifier: true, totalCount: 1, want: false},
|
||||
{name: "has item", hasIdentifier: true, itemCount: 1, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := emptyScopedPage(tt.hasIdentifier, tt.totalCount, tt.itemCount); got != tt.want {
|
||||
t.Fatalf("emptyScopedPage(%v, %d, %d) = %v, want %v", tt.hasIdentifier, tt.totalCount, tt.itemCount, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Play 播放页 SSR:GET /api/app/playlet/chapter/detail;seoInfo 直接注入模板。
|
||||
func Play(c *gin.Context) {
|
||||
playletKey := strings.TrimSpace(c.Param("slug"))
|
||||
requestedChapterID := strings.TrimSpace(c.Param("chapterId"))
|
||||
if playletKey == "" {
|
||||
NotFound(c, "未找到可播放内容")
|
||||
return
|
||||
}
|
||||
if requestedChapterID != "" && playletser.ParseDetailID(requestedChapterID) <= 0 {
|
||||
NotFound(c, "未找到该剧集")
|
||||
return
|
||||
}
|
||||
playlet := playletser.FetchDetailOrNilByKey(c, playletKey)
|
||||
if playlet == nil {
|
||||
NotFound(c, "未找到可播放内容")
|
||||
return
|
||||
}
|
||||
q := playletser.ParsePlayQuery(playletKey, c.Param("chapterId"))
|
||||
q.PlayletID = playlet.ID
|
||||
q.PlayletSlug = playlet.Slug
|
||||
|
||||
q.ChapterID = playletser.ResolvePlayChapterID(playlet, q.ChapterID)
|
||||
if q.ChapterID <= 0 {
|
||||
NotFound(c, "未找到可播放内容")
|
||||
return
|
||||
}
|
||||
target := playletser.PlayPath(q.PlayletSlug, q.PlayletID)
|
||||
if q.ChapterID > 0 {
|
||||
target = playletser.ChapterPlayPath(q.PlayletSlug, q.PlayletID, q.ChapterID)
|
||||
}
|
||||
if redirectCanonical(c, target) {
|
||||
return
|
||||
}
|
||||
|
||||
chapter := playletser.FetchChapterDetailOrNil(c, q.ChapterID, q.PlayletID, q.PlayletSlug)
|
||||
if chapter == nil {
|
||||
NotFound(c, "未找到该剧集")
|
||||
return
|
||||
}
|
||||
|
||||
var episodeRanges []playletser.EpisodeRange
|
||||
var chapters []playletser.PlayletChapterData
|
||||
playletID := playlet.ID
|
||||
playletSlug := playlet.Slug
|
||||
chapters = playlet.PlayletChapterInfo
|
||||
episodeRanges = playletser.BuildEpisodeRanges(chapters, playlet.TotalEpisode, 30)
|
||||
|
||||
seoInfo := playletser.ResolvePlayPageSEO(chapter, playlet)
|
||||
|
||||
data := gin.H{
|
||||
"title": playletser.ResolvePlayPageTitle(chapter, playlet),
|
||||
"chapter": chapter,
|
||||
"playlet": playlet,
|
||||
"playletID": playletID,
|
||||
"playletSlug": playletSlug,
|
||||
"seoInfo": seoInfo,
|
||||
"chapters": chapters,
|
||||
"episodeRanges": episodeRanges,
|
||||
}
|
||||
HTMLOK(c, "play.html", data)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ChapterList 代理上游短剧选集列表接口。
|
||||
func ChapterList(c *gin.Context) {
|
||||
playletID, _ := strconv.ParseInt(c.Query("playletId"), 10, 64)
|
||||
playletSlug := strings.TrimSpace(c.Query("playletSlug"))
|
||||
if playletSlug == "" && playletID <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 1001, "msg": "参数错误", "tip": "缺少 playletSlug 或 playletId", "data": nil})
|
||||
return
|
||||
}
|
||||
data, err := playletser.FetchChapterList(c, playletID, playletSlug)
|
||||
if err != nil {
|
||||
log.Error("chapter list upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "chapter list failed", "tip": "获取选集失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "success", "tip": "成功", "data": data})
|
||||
}
|
||||
|
||||
// OperateList 代理上游运营位短剧列表接口。
|
||||
func OperateList(c *gin.Context) {
|
||||
q := playletser.OperateListQuery{
|
||||
OperateType: parsePositiveInt(c.Query("operateType")),
|
||||
FilterType: parsePositiveInt(c.Query("filterType")),
|
||||
PageNum: parsePositiveInt(c.Query("pageNum")),
|
||||
PageSize: parsePositiveInt(c.Query("pageSize")),
|
||||
}
|
||||
q.Normalize()
|
||||
data, err := playletser.FetchOperateList(c, q)
|
||||
if err != nil {
|
||||
log.Error("operate list upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "operate list failed", "tip": "获取运营短剧失败,请稍后重试", "data": nil})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "success", "tip": "成功", "data": data})
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type playletForwardHeatBody struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// PlayletForwardHeat 代理上游 POST /api/app/playlet/forward/heat。
|
||||
func PlayletForwardHeat(c *gin.Context) {
|
||||
var body playletForwardHeatBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "参数错误",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if body.ID <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 1001,
|
||||
"msg": "参数错误",
|
||||
"tip": "缺少 id",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
env, err := playletser.ForwardHeat(c, playletser.PlayletForwardHeatReq{ID: body.ID})
|
||||
if err != nil {
|
||||
log.Error("playlet forward heat upstream failed", log.E(err))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 500,
|
||||
"msg": "playlet forward heat failed",
|
||||
"tip": "操作失败,请稍后重试",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
if env == nil || env.Code != 200 {
|
||||
tip := "操作失败"
|
||||
msg := ""
|
||||
code := 500
|
||||
if env != nil {
|
||||
code = env.Code
|
||||
msg = env.Msg
|
||||
tip = firstNonEmpty(env.Tip, env.Msg, tip)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
"tip": tip,
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": env.Msg,
|
||||
"tip": firstNonEmpty(env.Tip, env.Msg, "success"),
|
||||
"data": env.Data,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Rank 排行榜页 SSR:GET /api/app/playlet/rank,列表用接口字段渲染。
|
||||
func Rank(c *gin.Context) {
|
||||
sortOrder := strings.TrimSpace(c.Param("sortOrder"))
|
||||
if sortOrder != "" {
|
||||
value, err := strconv.Atoi(sortOrder)
|
||||
if err != nil || value < 1 || value > 3 {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
if sortOrder == "" {
|
||||
sortOrder = strings.TrimSpace(c.Query("sortOrder"))
|
||||
}
|
||||
page := strings.TrimSpace(c.Param("page"))
|
||||
if page != "" {
|
||||
value, err := strconv.Atoi(page)
|
||||
if err != nil || value <= 0 {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
if page == "" {
|
||||
page = strings.TrimSpace(c.Query("page"))
|
||||
}
|
||||
q := playletser.ParseRankQuery(page, sortOrder)
|
||||
if c.Query("sortOrder") != "" || c.Query("page") != "" {
|
||||
target := "/rank"
|
||||
if q.SortOrder != 1 {
|
||||
target += "/" + strconv.Itoa(q.SortOrder)
|
||||
}
|
||||
if q.PageNum > 1 {
|
||||
target += "/page/" + strconv.Itoa(q.PageNum)
|
||||
}
|
||||
target += "/"
|
||||
c.Redirect(http.StatusMovedPermanently, target)
|
||||
return
|
||||
}
|
||||
if redirectCanonical(c, playletser.RankCanonicalPath(q)) {
|
||||
return
|
||||
}
|
||||
raw := playletser.FetchRankOrEmpty(c, q)
|
||||
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
tabs := playletser.BuildRankTabs(q)
|
||||
seoSelections := make([]playletser.SEOSelection, 0, 1)
|
||||
for _, tab := range tabs {
|
||||
if tab.Active {
|
||||
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "排行榜类型", Value: tab.Label})
|
||||
break
|
||||
}
|
||||
}
|
||||
data := gin.H{
|
||||
"title": "排行榜",
|
||||
"pageName": "排行榜",
|
||||
"description": "黄果短剧排行榜实时更新热播短剧,查看热门榜、飙升榜,不错过高口碑必看剧集。",
|
||||
"navActive": "rank",
|
||||
"seoInfo": raw.SeoInfo,
|
||||
"seoSelections": seoSelections,
|
||||
"tabs": tabs,
|
||||
"list": raw.List,
|
||||
"rankOffset": playletser.RankOffset(q),
|
||||
"sortOrder": q.SortOrder,
|
||||
"isBinge": playletser.IsRankBinge(q.SortOrder),
|
||||
"pagination": playletser.BuildRankPagination(q, raw.TotalCount),
|
||||
}
|
||||
if raw.TotalCount <= 0 || len(raw.List) == 0 {
|
||||
setNoIndex(data, true)
|
||||
}
|
||||
HTMLOK(c, "rank.html", data)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HTML 渲染页面并注入 footer / 导航 / 公共配置,并确保存在游客会话。
|
||||
func HTML(c *gin.Context, code int, name string, data gin.H) {
|
||||
if data == nil {
|
||||
data = gin.H{}
|
||||
}
|
||||
if _, ok := data["footer"]; !ok {
|
||||
footer := configser.FetchFooterOrEmpty(c)
|
||||
data["footer"] = footer
|
||||
}
|
||||
headerNav, ok := data["headerNav"].([]translationser.HeaderNavItemVM)
|
||||
if !ok {
|
||||
headerNav = translationser.FetchHeaderNavOrDefault(c)
|
||||
data["headerNav"] = headerNav
|
||||
}
|
||||
activeKey, _ := data["navActive"].(string)
|
||||
resolvedNavActive := translationser.ResolveNavigationActiveKey(activeKey, c.Request.URL.Path)
|
||||
headerNav = translationser.MarkHeaderNavActive(headerNav, c.Request.URL.Path)
|
||||
data["headerNav"] = headerNav
|
||||
data["resolvedNavActive"] = resolvedNavActive
|
||||
data["pageConsoleData"] = buildPageConsoleData(c.Request.URL.RequestURI(), code, name, data)
|
||||
seoInfo := pageSEOFromData(data["seoInfo"])
|
||||
seoChanged := false
|
||||
if navSEO, found := translationser.FindHeaderNavSEO(headerNav, activeKey, c.Request.URL.Path); found {
|
||||
seoInfo = playletser.MergeSeoInfo(playletser.SeoInfo{
|
||||
Title: navSEO.Title,
|
||||
Description: navSEO.Description,
|
||||
Keywords: navSEO.Keyworld,
|
||||
}, seoInfo)
|
||||
seoChanged = true
|
||||
}
|
||||
if selections, ok := data["seoSelections"].([]playletser.SEOSelection); ok && len(selections) > 0 {
|
||||
seoInfo = playletser.DifferentiateSeoInfo(seoInfo, selections)
|
||||
seoChanged = true
|
||||
}
|
||||
if pageNum := paginationPage(data["pagination"]); pageNum > 1 {
|
||||
pageTitle, _ := data["title"].(string)
|
||||
pageTitle = playletser.PaginatedTitle(pageTitle, pageNum)
|
||||
if pageTitle != "" {
|
||||
data["title"] = pageTitle
|
||||
}
|
||||
seoInfo = playletser.PaginateSeoInfo(seoInfo, paginationDescription(data, pageTitle), pageNum)
|
||||
seoChanged = true
|
||||
}
|
||||
if seoChanged {
|
||||
data["seoInfo"] = seoInfo
|
||||
if seoInfo.Title != "" {
|
||||
data["title"] = seoInfo.Title
|
||||
}
|
||||
}
|
||||
if _, ok := data["appConfig"]; !ok {
|
||||
data["appConfig"] = configser.FetchAppConfigVMOrEmpty(c)
|
||||
}
|
||||
cfg, ok := data["appConfig"].(configser.AppConfigVM)
|
||||
if !ok {
|
||||
cfg = configser.FetchAppConfigVMOrEmpty(c)
|
||||
}
|
||||
data["appConfig"] = cfg
|
||||
siteURL := seoser.ResolveSiteURL(c, cfg)
|
||||
data["foreverUrlAddr"] = siteURL
|
||||
data["siteURL"] = siteURL
|
||||
attachPaginationLinks(data, siteURL)
|
||||
canonicalURL := seoser.CanonicalWithoutQuery(seoser.PageCanonicalURL(siteURL, cfg.ForeverURLAddr, c))
|
||||
canonicalURL = seoser.ContentCanonicalURL(siteURL, canonicalURL, map[string]any(data))
|
||||
data["canonicalURL"] = canonicalURL
|
||||
data["seoVerify"] = seoser.VerifyFromConfig()
|
||||
noindex := pageNoIndex(data)
|
||||
if noindex {
|
||||
data["noindex"] = true
|
||||
}
|
||||
if code >= http.StatusOK && code < http.StatusMultipleChoices && !noindex && seoser.IsIndexablePath(c.Request.URL.Path) {
|
||||
data["jsonLD"] = seoser.PageJSONLD(siteURL, canonicalURL, c.Request.URL.RequestURI(), map[string]any(data), cfg)
|
||||
}
|
||||
|
||||
// 无 token 时静默建立游客会话(POST /api/app/auth/guest),上游内容接口依赖该会话。
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
|
||||
c.HTML(code, name, data)
|
||||
}
|
||||
|
||||
// buildPageConsoleData 构建浏览器控制台使用的页面返回数据快照。
|
||||
// 快照不包含公共配置等大字段。
|
||||
func buildPageConsoleData(path string, code int, templateName string, data gin.H) gin.H {
|
||||
pageData := make(gin.H, len(data))
|
||||
for key, value := range data {
|
||||
if pageConsoleExcludedKey(key) {
|
||||
continue
|
||||
}
|
||||
pageData[key] = value
|
||||
}
|
||||
payload := gin.H{
|
||||
"path": path,
|
||||
"status": code,
|
||||
"template": templateName,
|
||||
"data": pageData,
|
||||
}
|
||||
if headerNav, ok := data["headerNav"]; ok {
|
||||
payload["headerNav"] = headerNav
|
||||
}
|
||||
if footer, ok := data["footer"]; ok {
|
||||
payload["footer"] = footer
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func pageConsoleExcludedKey(key string) bool {
|
||||
switch key {
|
||||
case "pageConsoleData", "appConfig", "footer", "headerNav", "jsonLD", "seoVerify":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pageSEOFromData(value any) playletser.SeoInfo {
|
||||
switch seo := value.(type) {
|
||||
case playletser.SeoInfo:
|
||||
return seo
|
||||
case *playletser.SeoInfo:
|
||||
if seo != nil {
|
||||
return *seo
|
||||
}
|
||||
}
|
||||
return playletser.SeoInfo{}
|
||||
}
|
||||
|
||||
func paginationPage(value any) int {
|
||||
switch pagination := value.(type) {
|
||||
case playletser.PaginationVM:
|
||||
return pagination.Page
|
||||
case *playletser.PaginationVM:
|
||||
if pagination != nil {
|
||||
return pagination.Page
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func paginationDescription(data gin.H, pageTitle string) string {
|
||||
for _, key := range []string{"description", "categoryDescription", "tagDescription", "topicDescription"} {
|
||||
if value, _ := data[key].(string); strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
pageTitle = strings.TrimSpace(pageTitle)
|
||||
if pageTitle == "" {
|
||||
return "浏览本页内容与站内相关推荐。"
|
||||
}
|
||||
return "浏览" + pageTitle + "的本页内容与站内相关推荐。"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// HTMLOK 200 快捷方法。
|
||||
func HTMLOK(c *gin.Context, name string, data gin.H) {
|
||||
HTML(c, http.StatusOK, name, data)
|
||||
}
|
||||
|
||||
// setNoIndex 统一设置页面级索引策略,并让公共渲染层同步关闭 JSON-LD。
|
||||
func setNoIndex(data gin.H, follow bool) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
data["noindex"] = true
|
||||
if follow {
|
||||
data["robots"] = "noindex,follow"
|
||||
return
|
||||
}
|
||||
data["robots"] = "noindex,nofollow"
|
||||
}
|
||||
|
||||
func pageNoIndex(data gin.H) bool {
|
||||
if noindex, _ := data["noindex"].(bool); noindex {
|
||||
return true
|
||||
}
|
||||
robots, _ := data["robots"].(string)
|
||||
for _, directive := range strings.Split(strings.ToLower(robots), ",") {
|
||||
if strings.TrimSpace(directive) == "noindex" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func toInt(v any) int {
|
||||
return int(toInt64(v))
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestBuildPageConsoleDataFiltersCommonFields(t *testing.T) {
|
||||
payload := buildPageConsoleData("/detail/example", http.StatusOK, "detail.html", gin.H{
|
||||
"title": "示例短剧",
|
||||
"detail": gin.H{"id": 12, "slug": "example"},
|
||||
"appConfig": "public config",
|
||||
"headerNav": "navigation",
|
||||
"footer": "footer data",
|
||||
"pageConsoleData": "old payload",
|
||||
})
|
||||
|
||||
if payload["path"] != "/detail/example" || payload["status"] != http.StatusOK || payload["template"] != "detail.html" {
|
||||
t.Fatalf("unexpected page console metadata: %#v", payload)
|
||||
}
|
||||
pageData, ok := payload["data"].(gin.H)
|
||||
if !ok {
|
||||
t.Fatalf("payload data type = %T, want gin.H", payload["data"])
|
||||
}
|
||||
if _, ok := pageData["detail"]; !ok {
|
||||
t.Fatalf("page response data was removed: %#v", pageData)
|
||||
}
|
||||
if payload["headerNav"] != "navigation" || payload["footer"] != "footer data" {
|
||||
t.Fatalf("common navigation/footer data missing: %#v", payload)
|
||||
}
|
||||
for _, key := range []string{"appConfig", "headerNav", "pageConsoleData"} {
|
||||
if _, ok := pageData[key]; ok {
|
||||
t.Fatalf("excluded key %q remains in console data: %#v", key, pageData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNoIndexAndPageNoIndex(t *testing.T) {
|
||||
data := gin.H{}
|
||||
setNoIndex(data, false)
|
||||
if data["robots"] != "noindex,nofollow" || !pageNoIndex(data) {
|
||||
t.Fatalf("noindex policy was not applied: %#v", data)
|
||||
}
|
||||
|
||||
if pageNoIndex(gin.H{"robots": "index,follow"}) {
|
||||
t.Fatal("indexable robots value was treated as noindex")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichtextIndexPolicy(t *testing.T) {
|
||||
publicData := gin.H{}
|
||||
applyRichtextIndexPolicy(publicData, "关于我们")
|
||||
if pageNoIndex(publicData) {
|
||||
t.Fatal("about page should remain indexable")
|
||||
}
|
||||
|
||||
legalData := gin.H{}
|
||||
applyRichtextIndexPolicy(legalData, "隐私政策")
|
||||
if !pageNoIndex(legalData) || legalData["robots"] != "noindex,follow" {
|
||||
t.Fatalf("privacy page policy = %#v, want noindex,follow", legalData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/seoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Richtext 富文本页 SSR:GET /api/app/config/richtext/list?titles=
|
||||
func Richtext(c *gin.Context) {
|
||||
title := routeOrQuery(c, "title", "title")
|
||||
target := "/richtext"
|
||||
if title != "" {
|
||||
target += "/" + url.PathEscape(title)
|
||||
}
|
||||
target += "/"
|
||||
if redirectCanonical(c, target) {
|
||||
return
|
||||
}
|
||||
renderRichtext(c, title, "富文本页")
|
||||
}
|
||||
|
||||
func RichtextPage(title string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page := configser.FetchRichTextByTitle(c, title)
|
||||
if !page.Found {
|
||||
fallbackTemplates := map[string]string{
|
||||
"关于我们": "aboutUs.html",
|
||||
"用户协议": "userAgreement.html",
|
||||
"隐私政策": "privacyPolicy.html",
|
||||
"帮助中心": "helpCenter.html",
|
||||
}
|
||||
if fallbackTemplate := fallbackTemplates[title]; fallbackTemplate != "" {
|
||||
data := gin.H{"title": title, "pageName": title}
|
||||
applyRichtextIndexPolicy(data, title)
|
||||
if title == "帮助中心" {
|
||||
data["faqItems"] = []seoser.FAQItem{
|
||||
{Question: "付费剧集怎么观看?", Answer: "网页端可免费观看免费剧集;付费剧集请下载黄果短剧 App 观看完整内容。"},
|
||||
{Question: "需要注册账号吗?", Answer: "不需要,网页端无需注册即可直接观看免费剧集。"},
|
||||
{Question: "视频无法播放或加载缓慢?", Answer: "建议切换网络、刷新页面或更新浏览器;问题持续时可提交反馈。"},
|
||||
{Question: "在哪里下载 App?", Answer: "点击页面上的“下载APP”入口,按提示扫码或直接下载安装。"},
|
||||
}
|
||||
}
|
||||
HTMLOK(c, fallbackTemplate, data)
|
||||
return
|
||||
}
|
||||
}
|
||||
renderRichtextData(c, title, title, page)
|
||||
}
|
||||
}
|
||||
|
||||
func renderRichtext(c *gin.Context, title, pageName string) {
|
||||
title = strings.TrimSpace(title)
|
||||
page := configser.FetchRichTextByTitle(c, title)
|
||||
renderRichtextData(c, title, pageName, page)
|
||||
}
|
||||
|
||||
// pageName 为面包屑名称,与上游富文本标题可能不同。
|
||||
func renderRichtextData(c *gin.Context, title, pageName string, page configser.RichTextPageVM) {
|
||||
if !page.Found {
|
||||
NotFound(c, "未找到该内容")
|
||||
return
|
||||
}
|
||||
|
||||
displayTitle := page.Title
|
||||
if displayTitle == "" {
|
||||
displayTitle = "内容详情"
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"title": displayTitle,
|
||||
"richtext": page,
|
||||
"richtextKey": title,
|
||||
"pageName": pageName,
|
||||
}
|
||||
applyRichtextIndexPolicy(data, title)
|
||||
HTMLOK(c, "richtext.html", data)
|
||||
}
|
||||
|
||||
func applyRichtextIndexPolicy(data gin.H, title string) {
|
||||
switch strings.TrimSpace(title) {
|
||||
case "关于我们", "帮助中心":
|
||||
return
|
||||
default:
|
||||
setNoIndex(data, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func routeOrQuery(c *gin.Context, routeName, queryName string) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
if value := strings.TrimSpace(c.Param(routeName)); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(c.Query(queryName))
|
||||
}
|
||||
|
||||
func hasQuery(c *gin.Context, names ...string) bool {
|
||||
if c == nil || c.Request == nil || c.Request.URL == nil {
|
||||
return false
|
||||
}
|
||||
values := c.Request.URL.Query()
|
||||
for _, name := range names {
|
||||
if _, ok := values[name]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redirectCanonical(c *gin.Context, target string) bool {
|
||||
target = strings.TrimSpace(target)
|
||||
if c == nil || target == "" || c.Request == nil || c.Request.URL == nil {
|
||||
return false
|
||||
}
|
||||
if c.Request.URL.RequestURI() == target {
|
||||
return false
|
||||
}
|
||||
c.Redirect(http.StatusMovedPermanently, target)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Search 搜索页 SSR:GET /api/app/playlet/search,列表直接用接口字段渲染。
|
||||
func Search(c *gin.Context) {
|
||||
q := playletser.ParseSearchQuery(routeOrQuery(c, "keyword", "q"), routeOrQuery(c, "page", "page"))
|
||||
if redirectCanonical(c, playletser.SearchCanonicalPath(q)) {
|
||||
return
|
||||
}
|
||||
var raw *playletser.PlayletVideoResp
|
||||
if q.Keyword != "" {
|
||||
raw = playletser.FetchSearchOrEmpty(c, q)
|
||||
} else {
|
||||
raw = &playletser.PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
emptyTitle := "请输入关键词搜索"
|
||||
emptyDesc := "在上方输入剧名、题材或标签开始搜索"
|
||||
if q.Keyword != "" {
|
||||
emptyTitle = "未找到相关短剧"
|
||||
emptyDesc = "换个关键词试试,或返回首页看看热门内容"
|
||||
}
|
||||
pageTitle := "搜索短剧"
|
||||
description := "在黄果短剧搜索短剧名称、标签与关键词,快速找到想看的剧集。"
|
||||
if q.Keyword != "" {
|
||||
pageTitle = "搜索:" + q.Keyword
|
||||
description = "在黄果短剧搜索「" + q.Keyword + "」相关短剧结果,快速找到想看的剧集。"
|
||||
}
|
||||
data := gin.H{
|
||||
"title": pageTitle,
|
||||
"description": description,
|
||||
"query": q.Keyword,
|
||||
"list": raw.List,
|
||||
"totalCount": raw.TotalCount,
|
||||
"pagination": playletser.BuildSearchPagination(q, raw.TotalCount),
|
||||
"emptyTitle": emptyTitle,
|
||||
"emptyDesc": emptyDesc,
|
||||
}
|
||||
setNoIndex(data, true)
|
||||
HTMLOK(c, "search.html", data)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/seoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RobotsTXT 动态 robots.txt(含 Sitemap 声明)。
|
||||
func RobotsTXT(c *gin.Context) {
|
||||
cfg := configser.FetchAppConfigVMOrEmpty(c)
|
||||
foreverURL := seoser.ResolveSiteURL(c, cfg)
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.String(http.StatusOK, seoser.BuildRobotsTXT(foreverURL))
|
||||
}
|
||||
|
||||
// SEOVerifyFile 提供百度 HTML 验证文件和 IndexNow key.txt。
|
||||
func SEOVerifyFile(c *gin.Context) {
|
||||
codes := seoser.VerifyFromConfig()
|
||||
name := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
|
||||
switch {
|
||||
case codes.BaiduHTML != "" && name == codes.BaiduHTML:
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.String(http.StatusOK, codes.Baidu)
|
||||
return
|
||||
case codes.IndexNowKey != "" && name == codes.IndexNowKey+".txt":
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(codes.IndexNowKey))
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Series AI 成人剧集 SSR:Swagger operate/list 固定 operateType=2,筛选映射 filterType=1/2/3。
|
||||
func Series(c *gin.Context) {
|
||||
q, ok := playletser.ParseSeriesQuery(c.Param("filter"), routeOrQuery(c, "page", "page"))
|
||||
if !ok {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
if redirectCanonical(c, playletser.SeriesCanonicalPath(q)) {
|
||||
return
|
||||
}
|
||||
raw := playletser.FetchOperateListOrEmpty(c, q)
|
||||
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
|
||||
sectionTitle := playletser.SeriesSectionTitle(q.FilterType)
|
||||
data := gin.H{
|
||||
"title": "AI成人剧集",
|
||||
"description": "黄果短剧精选AI成人剧集,持续更新热门漫剧、独家原创剧集,支持免费在线观看。",
|
||||
"pageKey": "series",
|
||||
"pageName": "AI成人剧集",
|
||||
"pagePath": playletser.SeriesCanonicalPath(q),
|
||||
"navActive": "aiSeries",
|
||||
"filters": playletser.BuildSeriesFilterTabs(q),
|
||||
"sectionTitle": sectionTitle,
|
||||
"seoSelections": []playletser.SEOSelection{{Dimension: "筛选", Value: strings.TrimSuffix(sectionTitle, "的AI成人剧集")}},
|
||||
"list": raw.List,
|
||||
"itemListOffset": (q.PageNum - 1) * q.PageSize,
|
||||
"pagination": playletser.BuildSeriesPagination(q, raw.TotalCount),
|
||||
}
|
||||
// 剧集入口页(/series/、/series/popular/、/series/original/)始终可索引;空结果仅对分页打 noindex。
|
||||
if q.PageNum > 1 && (raw.TotalCount <= 0 || len(raw.List) == 0) {
|
||||
setNoIndex(data, true)
|
||||
}
|
||||
HTMLOK(c, "series.html", data)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/authser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TryJSONSessionRecover 上游返回会话失效时重建游客会话;返回 true 表示调用方应重试一次。
|
||||
func TryJSONSessionRecover(c *gin.Context, code int) bool {
|
||||
return authser.RecoverContentSession(c, code)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TagList(c *gin.Context) {
|
||||
q := playletser.ParseTagListQuery(routeOrQuery(c, "page", "page"))
|
||||
if slug := strings.TrimSpace(c.Param("slug")); slug != "" {
|
||||
if id := playletser.ParseDetailID(slug); id > 0 {
|
||||
q.TagID = id
|
||||
} else {
|
||||
q.TagSlug = slug
|
||||
}
|
||||
}
|
||||
if path := c.Request.URL.Path; path == "/tag-list" || strings.HasPrefix(path, "/tag-list/") {
|
||||
redirectCanonical(c, playletser.TagListCanonicalPath(q))
|
||||
return
|
||||
}
|
||||
raw := playletser.FetchTagListOrEmpty(c, q)
|
||||
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
hasIdentifier := q.TagID > 0 || strings.TrimSpace(q.TagSlug) != ""
|
||||
if emptyScopedPage(hasIdentifier, raw.TotalCount, len(raw.List)) {
|
||||
NotFound(c, "标签不存在")
|
||||
return
|
||||
}
|
||||
if q.TagID > 0 {
|
||||
q.TagSlug = playletser.FindTagSlug(raw.List, q.TagID)
|
||||
}
|
||||
if redirectCanonical(c, playletser.TagListCanonicalPath(q)) {
|
||||
return
|
||||
}
|
||||
|
||||
pageName := playletser.FindTagNameBySlug(raw.List, q.TagSlug)
|
||||
if pageName == "" {
|
||||
pageName = playletser.FindTagName(raw.List, q.TagID)
|
||||
}
|
||||
if pageName == "" {
|
||||
if name := strings.TrimSpace(raw.SeoInfo.Title); name != "" {
|
||||
pageName = name
|
||||
} else {
|
||||
pageName = "标签"
|
||||
}
|
||||
}
|
||||
description := strings.TrimSpace(raw.SeoInfo.Description)
|
||||
if description == "" {
|
||||
description = "精选" + pageName + "题材相关AI成人短剧与AI成人漫剧,持续更新" + pageName + "题材作品,方便快速浏览相关短剧。"
|
||||
}
|
||||
|
||||
title := playletser.TagListPageTitle(pageName, q.PageNum, raw.SeoInfo)
|
||||
data := gin.H{
|
||||
"title": title,
|
||||
"pageKey": "tag_list",
|
||||
"pageName": pageName,
|
||||
"pageTitle": pageName,
|
||||
"tagDescription": description,
|
||||
"pageNum": q.PageNum,
|
||||
"pagePath": playletser.TagListCanonicalPath(q),
|
||||
"seoInfo": raw.SeoInfo,
|
||||
"list": raw.List,
|
||||
"totalCount": raw.TotalCount,
|
||||
"itemListOffset": (q.PageNum - 1) * q.PageSize,
|
||||
"pagination": playletser.BuildTagListPagination(q, raw.TotalCount),
|
||||
}
|
||||
if raw.TotalCount <= 0 || len(raw.List) == 0 {
|
||||
setNoIndex(data, true)
|
||||
}
|
||||
HTMLOK(c, "tagList.html", data)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ThemeIndex 专题列表页:专题数据来自 GET /api/app/theme/list?themeType=2。
|
||||
func ThemeIndex(c *gin.Context) {
|
||||
q := playletser.ParseThemeIndexQuery(c.Param("page"))
|
||||
if redirectCanonical(c, playletser.ThemeIndexCanonicalPath(q)) {
|
||||
return
|
||||
}
|
||||
themes := playletser.FetchThemesOrEmpty(c, 2)
|
||||
list, totalCount := playletser.BuildThemeIndexPage(themes, q)
|
||||
if playletser.PageOutOfRange(q.PageNum, q.PageSize, totalCount) {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"title": "AI成人短剧专题推荐" + playletser.PageTitleSuffix(q.PageNum),
|
||||
"description": "专题集中展示黄果短剧各类精品专题合集,涵盖AI成人短剧、AI色情短剧、擦边短剧、AI换脸、AI魔改等热门主题。内容丰富多样,每日持续更新。",
|
||||
"pageKey": "theme_index",
|
||||
"pageName": "专题",
|
||||
"navActive": "topic",
|
||||
"pageNum": q.PageNum,
|
||||
"pagePath": playletser.ThemeIndexCanonicalPath(q),
|
||||
"list": list,
|
||||
"totalCount": totalCount,
|
||||
"itemListOffset": (q.PageNum - 1) * q.PageSize,
|
||||
"pagination": playletser.BuildThemeIndexPagination(q, totalCount),
|
||||
}
|
||||
if totalCount <= 0 || len(list) == 0 {
|
||||
setNoIndex(data, true)
|
||||
}
|
||||
HTMLOK(c, "themeIndex.html", data)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"videoweb/app/service/playletser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TodayUpdate 今日更新页 SSR:GET /api/app/playlet/today-update,列表用接口字段渲染。
|
||||
func TodayUpdate(c *gin.Context) {
|
||||
q := playletser.ParseTodayUpdateQuery(routeOrQuery(c, "page", "page"))
|
||||
if redirectCanonical(c, playletser.TodayUpdateCanonicalPath(q)) {
|
||||
return
|
||||
}
|
||||
raw := playletser.FetchTodayUpdateOrEmpty(c, q)
|
||||
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
|
||||
NotFound(c, "页面不存在")
|
||||
return
|
||||
}
|
||||
data := gin.H{
|
||||
"title": "今日更新",
|
||||
"pageName": "今日更新",
|
||||
"description": "黄果短剧今日更新:第一时间追更最新上线短剧与连载剧集,不错过每日精彩更新。",
|
||||
"navActive": "todayUpdate",
|
||||
"seoInfo": raw.SeoInfo,
|
||||
"list": raw.List,
|
||||
"itemListOffset": (q.PageNum - 1) * q.PageSize,
|
||||
"pagination": playletser.BuildTodayUpdatePagination(q, raw.TotalCount),
|
||||
}
|
||||
if raw.TotalCount <= 0 || len(raw.List) == 0 {
|
||||
setNoIndex(data, true)
|
||||
}
|
||||
HTMLOK(c, "todayUpdate.html", data)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package appg
|
||||
|
||||
import "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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
serverAddr: "https://webhgdjapi.mfcg.me"
|
||||
listenAddr: "25119"
|
||||
dhListenAddr: "26812"
|
||||
runMod: "dev"
|
||||
tsAuthKey: "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
—Џ€И’љБCЏ„
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
echo "开始"
|
||||
rm -rf ./web_huangguo_h5 ./web_huangguo_h5.tar.gz
|
||||
GOOS=linux GOARCH=amd64 go build -o web_huangguo_h5
|
||||
tar zvcf web_huangguo_h5.tar.gz web_huangguo_h5 templates/ static/ \
|
||||
--exclude='templates/page/sitemaps/sitemap.xml' \
|
||||
--exclude='templates/page/sitemaps/sitemap-*.xml' \
|
||||
--exclude='templates/page/rss.xml' \
|
||||
--exclude='templates/page/llms.txt' \
|
||||
--exclude='templates/page/llms-full.txt'
|
||||
|
||||
|
||||
scp web_huangguo_h5.tar.gz root@172.104.166.87:/data/uzip
|
||||
ssh root@172.104.166.87 "/server/web_huangguo/h5/up_app.sh"
|
||||
|
||||
rm -rf ./web_huangguo_h5 ./web_huangguo_h5.tar.gz
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
//go:generate swag init --parseDependency --parseDepth 4
|
||||
//go:generate mtool replicate-module -m contest
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"videoweb/app/server"
|
||||
)
|
||||
|
||||
// @title 视频站APP接口
|
||||
// @version 1.0
|
||||
// @description 这是视频站APP接口服务.
|
||||
// @termsOfService
|
||||
|
||||
// @contact.name
|
||||
// @contact.url
|
||||
// @contact.email
|
||||
|
||||
// @license.name Apache 2.0
|
||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||
|
||||
// @host https://视频站-api.lcncctn.com
|
||||
// @BasePath /
|
||||
// @Schemes http https
|
||||
|
||||
// @securityDefinitions.basic BasicAuth
|
||||
|
||||
// @securityDefinitions.apikey ApiKeyAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
// @securitydefinitions.oauth2.application OAuth2Application
|
||||
// @tokenUrl https://example.com/oauth/token
|
||||
// @scope.write Grants write access
|
||||
// @scope.admin Grants read and write access to administrative information
|
||||
|
||||
// @securitydefinitions.oauth2.implicit OAuth2Implicit
|
||||
// @authorizationUrl https://example.com/oauth/authorize
|
||||
// @scope.write Grants write access
|
||||
// @scope.admin Grants read and write access to administrative information
|
||||
|
||||
// @securitydefinitions.oauth2.password OAuth2Password
|
||||
// @tokenUrl https://example.com/oauth/token
|
||||
// @scope.read Grants read access
|
||||
// @scope.write Grants write access
|
||||
// @scope.admin Grants read and write access to administrative information
|
||||
|
||||
// @securitydefinitions.oauth2.accessCode OAuth2AccessCode
|
||||
// @tokenUrl https://example.com/oauth/token
|
||||
// @authorizationUrl https://example.com/oauth/authorize
|
||||
// @scope.admin Grants read and write access to administrative information
|
||||
|
||||
func main() {
|
||||
server.Run(os.Args)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestCategoryDetailTemplateParses(t *testing.T) {
|
||||
workingDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(".."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(workingDir) })
|
||||
|
||||
engine := gin.New()
|
||||
setupEngine(engine)
|
||||
if engine.HTMLRender == nil {
|
||||
t.Fatal("HTML renderer was not initialized")
|
||||
}
|
||||
}
|
||||
@@ -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,276 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"videoweb/app/api/web"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/seoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Init() *gin.Engine {
|
||||
setGinMode()
|
||||
engine := gin.Default()
|
||||
engine.HandleMethodNotAllowed = true
|
||||
setupEngine(engine)
|
||||
engine.NoRoute(renderNotFound)
|
||||
engine.NoMethod(renderNotFound)
|
||||
|
||||
registerM3U8Routes(engine)
|
||||
registerRoutes(engine)
|
||||
return engine
|
||||
}
|
||||
|
||||
// DHInit 导航站独立端口:仅暴露官方导航页及静态资源。
|
||||
func DHInit() *gin.Engine {
|
||||
setGinMode()
|
||||
engine := gin.Default()
|
||||
engine.HandleMethodNotAllowed = true
|
||||
setupEngine(engine)
|
||||
engine.StaticFile("/favicon.ico", "./static/favicon.ico")
|
||||
engine.StaticFile("/favicon.svg", "./static/favicon.svg")
|
||||
engine.StaticFile("/favicon.png", "./static/favicon.png")
|
||||
engine.GET("/ui/dialog/:name", web.DialogFragment)
|
||||
engine.GET("/", web.Nav)
|
||||
engine.GET("/nav/", web.Nav)
|
||||
engine.GET("/dh/", web.Nav)
|
||||
engine.NoRoute(renderNotFound)
|
||||
engine.NoMethod(renderNotFound)
|
||||
return engine
|
||||
}
|
||||
|
||||
func setGinMode() {
|
||||
switch strings.ToLower(strings.TrimSpace(appg.Conf.RunMod)) {
|
||||
case "test":
|
||||
gin.SetMode(gin.TestMode)
|
||||
case "prod":
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
default:
|
||||
gin.SetMode(gin.DebugMode)
|
||||
}
|
||||
}
|
||||
|
||||
func setupEngine(engine *gin.Engine) {
|
||||
tplFuncs := playletser.TemplateFuncs()
|
||||
tplFuncs["InJSONScript"] = inJSONScript
|
||||
tplFuncs["jsonStr"] = jsonStr
|
||||
engine.SetFuncMap(tplFuncs)
|
||||
engine.LoadHTMLFiles(listHTMLTemplates("templates")...)
|
||||
engine.Use(staticCacheHeaders(), crawlerHeaders())
|
||||
registerBrowserDiscoveryRoutes(engine)
|
||||
engine.Static("/static", "./static")
|
||||
}
|
||||
|
||||
// registerBrowserDiscoveryRoutes 处理 Chrome DevTools 的本地工作区探测请求。
|
||||
// 该请求并非站点页面,返回 204 可避免进入业务 404 页面和产生误导日志。
|
||||
func registerBrowserDiscoveryRoutes(engine *gin.Engine) {
|
||||
engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Header("X-Robots-Tag", "noindex, nofollow")
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
// crawlerHeaders 防止接口、片段和健康检查等非 HTML 内容进入搜索索引。
|
||||
func crawlerHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") ||
|
||||
strings.HasPrefix(path, "/ui/") ||
|
||||
strings.HasPrefix(path, "/h5/") ||
|
||||
path == "/healthz" || path == "/enkey" || path == "/newenkey" {
|
||||
c.Header("X-Robots-Tag", "noindex, nofollow")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// listHTMLTemplates 收集 templates 下页面模板,跳过遗留广告文件。
|
||||
func listHTMLTemplates(root string) []string {
|
||||
var files []string
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
name := strings.ToLower(d.Name())
|
||||
if !strings.HasSuffix(name, ".html") {
|
||||
return nil
|
||||
}
|
||||
if name == "banners.html" {
|
||||
return nil
|
||||
}
|
||||
files = append(files, path)
|
||||
return nil
|
||||
})
|
||||
if len(files) == 0 {
|
||||
panic("no html templates found under " + root)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// jsonStr 将字符串序列化为可嵌入 application/ld+json 的 JSON 字符串字面量。
|
||||
func jsonStr(s string) template.JS {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return template.JS(`""`)
|
||||
}
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
func registerRoutes(r *gin.Engine) {
|
||||
r.GET("/", web.Index)
|
||||
r.GET("/ui/dialog/:name", web.DialogFragment)
|
||||
|
||||
r.POST("/api/feedback/add", web.AddFeedback)
|
||||
r.POST("/api/contactus/add", web.AddContactUs)
|
||||
r.GET("/api/comment/list", web.CommentList)
|
||||
r.POST("/api/playlet/forward/heat", web.PlayletForwardHeat)
|
||||
r.GET("/api/playlet/chapter/detail", web.ChapterDetail)
|
||||
r.GET("/api/playlet/chapter/list", web.ChapterList)
|
||||
r.GET("/api/playlet/operate/list", web.OperateList)
|
||||
r.GET("/api/playlet/hot-like", web.HotLike)
|
||||
r.GET("/api/app/playlet/hot-like", web.HotLike)
|
||||
|
||||
r.GET("/list/", web.List)
|
||||
r.GET("/list/:slug/", web.List)
|
||||
r.GET("/list/:slug/:sortOrder/", web.List)
|
||||
r.GET("/list/:slug/:sortOrder/page/:page/", web.List)
|
||||
|
||||
r.GET("/series/", web.Series)
|
||||
r.GET("/series/page/:page/", web.Series)
|
||||
r.GET("/series/:filter/", web.Series)
|
||||
r.GET("/series/:filter/page/:page/", web.Series)
|
||||
|
||||
// 标签页面使用 tag 路由;tag-list 仅作为历史地址保留并由 handler 301 到 canonical。
|
||||
r.GET("/tag/", web.TagList)
|
||||
r.GET("/tag/page/:page/", web.TagList)
|
||||
r.GET("/tag/:slug/", web.TagList)
|
||||
r.GET("/tag/:slug/page/:page/", web.TagList)
|
||||
r.GET("/tag-list/", web.TagList)
|
||||
r.GET("/tag-list/page/:page/", web.TagList)
|
||||
r.GET("/tag-list/:slug/", web.TagList)
|
||||
r.GET("/tag-list/:slug/page/:page/", web.TagList)
|
||||
|
||||
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("/today-update/", web.TodayUpdate)
|
||||
r.GET("/today-update/page/:page/", web.TodayUpdate)
|
||||
|
||||
// 分类详情使用语义化 category 路由,分类标识优先使用 slug、无 slug 时使用 ID。
|
||||
r.GET("/category/:slug/", web.CategoryDetail)
|
||||
r.GET("/category/:slug/page/:page/", web.CategoryDetail)
|
||||
|
||||
// 专题列表和详情统一使用 topic 路由;more 仅作为历史地址保留并由 handler 301 到 canonical。
|
||||
r.GET("/topic/", web.ThemeIndex)
|
||||
r.GET("/topic/page/:page/", web.ThemeIndex)
|
||||
r.GET("/topic/:slug/", web.More)
|
||||
r.GET("/topic/:slug/page/:page/", web.More)
|
||||
r.GET("/more/", web.More)
|
||||
r.GET("/more/page/:page/", web.More)
|
||||
r.GET("/more/:slug/", web.More)
|
||||
r.GET("/more/:slug/page/:page/", web.More)
|
||||
|
||||
r.GET("/download-app/", func(c *gin.Context) {
|
||||
web.HTMLOK(c, "downloadApp.html", gin.H{
|
||||
"title": "下载APP",
|
||||
"navActive": "downloadApp",
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/application/", web.Application)
|
||||
r.GET("/home-road/", web.HomeRoad)
|
||||
|
||||
r.GET("/detail/:slug/", web.Detail)
|
||||
|
||||
r.GET("/play/:slug/", web.Play)
|
||||
r.GET("/play/:slug/:chapterId/", web.Play)
|
||||
|
||||
r.GET("/search/", web.Search)
|
||||
r.GET("/search/:keyword/", web.Search)
|
||||
r.GET("/search/:keyword/page/:page/", web.Search)
|
||||
|
||||
r.GET("/about-us/", web.RichtextPage("关于我们"))
|
||||
r.GET("/user-agreement/", web.RichtextPage("用户协议"))
|
||||
r.GET("/privacy-policy/", web.RichtextPage("隐私政策"))
|
||||
r.GET("/help-center/", web.RichtextPage("帮助中心"))
|
||||
|
||||
r.GET("/richtext/", web.Richtext)
|
||||
r.GET("/richtext/:title/", web.Richtext)
|
||||
|
||||
r.GET("/feedback/", func(c *gin.Context) {
|
||||
web.HTMLOK(c, "feedback.html", gin.H{
|
||||
"title": "问题反馈",
|
||||
"noindex": true,
|
||||
"robots": "noindex,nofollow",
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/nav/", web.Nav)
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
})
|
||||
|
||||
registerSitemapRoutes(r)
|
||||
r.GET("/robots.txt", web.RobotsTXT)
|
||||
registerSEOVerifyRoutes(r)
|
||||
|
||||
r.GET("/favicon.ico", func(c *gin.Context) {
|
||||
c.File("./static/favicon.ico")
|
||||
})
|
||||
r.GET("/favicon.svg", func(c *gin.Context) {
|
||||
c.File("./static/favicon.svg")
|
||||
})
|
||||
r.GET("/favicon.png", func(c *gin.Context) {
|
||||
c.File("./static/favicon.png")
|
||||
})
|
||||
}
|
||||
|
||||
func renderNotFound(c *gin.Context) {
|
||||
web.HTML(c, http.StatusNotFound, "404.html", gin.H{
|
||||
"path": c.Request.URL.Path,
|
||||
"noindex": true,
|
||||
"robots": "noindex,follow",
|
||||
})
|
||||
}
|
||||
|
||||
func registerSEOVerifyRoutes(r *gin.Engine) {
|
||||
codes := seoser.VerifyFromConfig()
|
||||
if codes.BaiduHTML != "" {
|
||||
r.GET("/"+codes.BaiduHTML, web.SEOVerifyFile)
|
||||
}
|
||||
if codes.IndexNowKey != "" {
|
||||
r.GET("/"+codes.IndexNowKey+".txt", web.SEOVerifyFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestContentPagesOnlyRegisterPathParameterRoutes(t *testing.T) {
|
||||
engine := gin.New()
|
||||
registerRoutes(engine)
|
||||
routes := engine.Routes()
|
||||
registered := make(map[string]bool, len(routes))
|
||||
handlers := make(map[string]string, len(routes))
|
||||
for _, route := range routes {
|
||||
key := route.Method + " " + route.Path
|
||||
registered[key] = true
|
||||
handlers[key] = route.Handler
|
||||
}
|
||||
|
||||
for _, route := range []string{
|
||||
"GET /detail/:slug",
|
||||
"GET /play/:slug",
|
||||
"GET /play/:slug/:chapterId",
|
||||
"GET /topic",
|
||||
"GET /topic/page/:page",
|
||||
"GET /topic/:slug",
|
||||
"GET /topic/:slug/page/:page",
|
||||
"GET /tag",
|
||||
"GET /tag/page/:page",
|
||||
"GET /tag/:slug",
|
||||
"GET /tag/:slug/page/:page",
|
||||
"GET /series",
|
||||
"GET /series/page/:page",
|
||||
"GET /series/:filter",
|
||||
"GET /series/:filter/page/:page",
|
||||
"GET /category/:slug",
|
||||
"GET /category/:slug/page/:page",
|
||||
"GET /home-road",
|
||||
} {
|
||||
if !registered[route] {
|
||||
t.Fatalf("missing path parameter route %s", route)
|
||||
}
|
||||
}
|
||||
for _, route := range []string{
|
||||
"POST /api/contactus/add",
|
||||
"GET /api/playlet/chapter/list",
|
||||
"GET /api/playlet/operate/list",
|
||||
"GET /api/playlet/hot-like",
|
||||
"GET /api/app/playlet/hot-like",
|
||||
} {
|
||||
if !registered[route] {
|
||||
t.Fatalf("missing documented API route %s", route)
|
||||
}
|
||||
}
|
||||
for _, route := range []string{"GET /detail", "GET /play"} {
|
||||
if registered[route] {
|
||||
t.Fatalf("legacy query route should not be registered: %s", route)
|
||||
}
|
||||
}
|
||||
|
||||
for route, handlerSuffix := range map[string]string{
|
||||
"GET /topic": ".ThemeIndex",
|
||||
"GET /topic/page/:page": ".ThemeIndex",
|
||||
"GET /topic/:slug": ".More",
|
||||
"GET /tag": ".TagList",
|
||||
"GET /tag/:slug": ".TagList",
|
||||
"GET /category/:slug": ".CategoryDetail",
|
||||
} {
|
||||
if !strings.HasSuffix(handlers[route], handlerSuffix) {
|
||||
t.Fatalf("handler for %s = %q, want suffix %q", route, handlers[route], handlerSuffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyTagListRouteRedirectsToSemanticTagRoute(t *testing.T) {
|
||||
engine := gin.New()
|
||||
registerRoutes(engine)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/tag-list/fantasy/page/2", nil)
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusMovedPermanently)
|
||||
}
|
||||
if location := recorder.Header().Get("Location"); location != "/tag/fantasy/page/2" {
|
||||
t.Fatalf("Location = %q, want %q", location, "/tag/fantasy/page/2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyAISeriesListRedirectsToSeriesPage(t *testing.T) {
|
||||
engine := gin.New()
|
||||
registerRoutes(engine)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/list/ai-series/1/page/3", nil)
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusMovedPermanently)
|
||||
}
|
||||
if location := recorder.Header().Get("Location"); location != "/series/popular/page/3" {
|
||||
t.Fatalf("Location = %q, want %q", location, "/series/popular/page/3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrawlerHeadersNoIndexNonHTMLRoutes(t *testing.T) {
|
||||
engine := gin.New()
|
||||
engine.Use(crawlerHeaders())
|
||||
engine.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if got := recorder.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
|
||||
t.Fatalf("X-Robots-Tag = %q, want noindex, nofollow", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromeDevToolsDiscoveryReturnsNoContent(t *testing.T) {
|
||||
engine := gin.New()
|
||||
registerBrowserDiscoveryRoutes(engine)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/.well-known/appspecific/com.chrome.devtools.json", nil)
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent)
|
||||
}
|
||||
if got := recorder.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
|
||||
t.Fatalf("X-Robots-Tag = %q, want noindex, nofollow", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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
|
||||
}
|
||||
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,68 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNavigationPageUsesDedicatedVerificationAndAnalytics(t *testing.T) {
|
||||
navHTML := renderSEOCommonForTrackingTest(t, true)
|
||||
for _, want := range []string{
|
||||
"UEtS2h9mIjbFSM_VHEciNQ5iWuFPq9d-Q2WO0SFyPAw",
|
||||
"eafd02fb6517fc76",
|
||||
"112390883",
|
||||
"G-SY26Y2MSHS",
|
||||
} {
|
||||
if !strings.Contains(navHTML, want) {
|
||||
t.Fatalf("navigation SEO output does not contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"112967594", "G-B5KGM8S5J1", "6bb06ed5659790d0"} {
|
||||
if strings.Contains(navHTML, unwanted) {
|
||||
t.Fatalf("navigation SEO output unexpectedly contains main-site id %q", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainSiteKeepsMainVerificationAndAnalytics(t *testing.T) {
|
||||
mainHTML := renderSEOCommonForTrackingTest(t, false)
|
||||
for _, want := range []string{
|
||||
"r7geXn5kBugzOSLZzyw4v3NBDMQwAmzFCVkqp92odh0",
|
||||
"6bb06ed5659790d0",
|
||||
"112967594",
|
||||
"G-B5KGM8S5J1",
|
||||
} {
|
||||
if !strings.Contains(mainHTML, want) {
|
||||
t.Fatalf("main-site SEO output does not contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"112390883", "G-SY26Y2MSHS", "eafd02fb6517fc76"} {
|
||||
if strings.Contains(mainHTML, unwanted) {
|
||||
t.Fatalf("main-site SEO output unexpectedly contains navigation id %q", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderSEOCommonForTrackingTest(t *testing.T, isNavPage bool) string {
|
||||
t.Helper()
|
||||
tpl, err := template.New("tracking-test").Funcs(template.FuncMap{
|
||||
"jsonStr": func(any) template.JS { return template.JS(`""`) },
|
||||
}).ParseFiles("../templates/base/seoCommon.html", "../templates/base/analytics.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := map[string]any{
|
||||
"isNavPage": isNavPage,
|
||||
"seoVerify": map[string]any{"Baidu": ""},
|
||||
"appConfig": map[string]any{
|
||||
"SeoInfo": map[string]any{"WebSiteName": ""},
|
||||
},
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := tpl.ExecuteTemplate(&out, "seoCommon", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const sitemapDir = "./templates/page/sitemaps"
|
||||
|
||||
var sitemapLocPattern = regexp.MustCompile(`<loc>([^<]+)</loc>`)
|
||||
|
||||
// registerSitemapRoutes 暴露 hgdj_server app 生成的 sitemap 索引及分片文件。
|
||||
func registerSitemapRoutes(r *gin.Engine) {
|
||||
r.StaticFile("/sitemap.xml", filepath.Join(sitemapDir, "sitemap.xml"))
|
||||
r.GET("/sitemaps/:name", serveSitemapShard)
|
||||
}
|
||||
|
||||
func serveSitemapShard(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if !isValidSitemapShardName(name) {
|
||||
renderNotFound(c)
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(sitemapDir, name))
|
||||
if err != nil {
|
||||
renderNotFound(c)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/xml; charset=utf-8", normalizeSitemapPageURLs(raw))
|
||||
}
|
||||
|
||||
// normalizeSitemapPageURLs 让运行时生成的旧 sitemap 与当前 canonical 路由保持一致。
|
||||
// sitemap 索引和静态文件地址不经过这里,仍保留文件型 URL(无尾斜杠)。
|
||||
func normalizeSitemapPageURLs(raw []byte) []byte {
|
||||
return sitemapLocPattern.ReplaceAllFunc(raw, func(match []byte) []byte {
|
||||
parts := sitemapLocPattern.FindSubmatch(match)
|
||||
if len(parts) != 2 {
|
||||
return match
|
||||
}
|
||||
loc := html.UnescapeString(strings.TrimSpace(string(parts[1])))
|
||||
u, err := url.Parse(loc)
|
||||
if err != nil || u.Path == "" {
|
||||
return match
|
||||
}
|
||||
u.Path = canonicalSitemapPagePath(u.Path)
|
||||
return []byte("<loc>" + html.EscapeString(u.String()) + "</loc>")
|
||||
})
|
||||
}
|
||||
|
||||
func canonicalSitemapPagePath(path string) string {
|
||||
switch {
|
||||
case path == "/more":
|
||||
path = "/topic"
|
||||
case strings.HasPrefix(path, "/more/"):
|
||||
path = "/topic/" + strings.TrimPrefix(path, "/more/")
|
||||
case path == "/tag-list":
|
||||
path = "/tag"
|
||||
case strings.HasPrefix(path, "/tag-list/"):
|
||||
path = "/tag/" + strings.TrimPrefix(path, "/tag-list/")
|
||||
}
|
||||
if path != "/" && !strings.HasSuffix(path, "/") {
|
||||
path += "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// isValidSitemapShardName 校验分片文件名,防止路径遍历。
|
||||
// 合法示例:sitemap-pages.xml、sitemap-playletdetail-0.xml
|
||||
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,161 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"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) {
|
||||
showVersion()
|
||||
if err := initConfig(); err != nil {
|
||||
log.Error("init config failed", log.E(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
initHttpServer()
|
||||
initDHServer()
|
||||
//tgbot.SendToProgram("启动", "app启动完毕")
|
||||
shutdownHandler()
|
||||
}
|
||||
|
||||
func initConfig() error {
|
||||
|
||||
buf, err := ioutil.ReadFile("conf/app.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = yaml.Unmarshal(buf, &appg.Conf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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,113 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
guestPath = "/api/app/auth/guest"
|
||||
cookieMaxAge = 7 * 24 * 3600
|
||||
)
|
||||
|
||||
// GuestLogin 调用上游游客登录,站点不提供注册/登录,上游内容接口依赖游客会话。
|
||||
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
|
||||
}
|
||||
|
||||
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 写入游客会话 cookie。
|
||||
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)
|
||||
}
|
||||
if sid := useragent.SIDFromRequest(c.Request); sid != "" {
|
||||
setCookie(c, "sid", sid, secure)
|
||||
}
|
||||
}
|
||||
|
||||
func setCookie(c *gin.Context, name, value string, secure bool) {
|
||||
// Cookie Value 仅允许 ASCII;使用 PathEscape(空格→%20),与前端 encodeURIComponent 一致。
|
||||
http.SetCookie(c.Writer, &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),
|
||||
})
|
||||
}
|
||||
|
||||
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,同时清理旧版账号体系遗留的用户信息 cookie。
|
||||
func ClearAuthCookies(c *gin.Context) {
|
||||
for _, name := range authCookieNames() {
|
||||
http.SetCookie(c.Writer, &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", "guest_skip_auto",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const guestDevIDCookie = "guest_dev_id"
|
||||
|
||||
// EnsureGuestOnEnter 页面进入时:无 token 则走游客登录并写 cookie。
|
||||
// 对应上游 POST /api/app/auth/guest。成功后会把会话补进当前 Request,便于同一次 SSR 直接使用。
|
||||
func EnsureGuestOnEnter(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
// 已有会话则跳过
|
||||
if useragent.TokenFromRequest(c.Request) != "" {
|
||||
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)
|
||||
patchRequestCookie(c, "token", user.Token)
|
||||
return user
|
||||
}
|
||||
|
||||
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, "; "))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// guestChannel 为首屏服务端自动游客登录补渠道;cookie 用于复用前端首次归因结果。
|
||||
func guestChannel(c *gin.Context) string {
|
||||
if c == nil || c.Request == nil {
|
||||
return ""
|
||||
}
|
||||
if channel := cookieValue(c, "channel"); channel != "" {
|
||||
return channel
|
||||
}
|
||||
for _, key := range []string{"dc", "ch", "pc"} {
|
||||
if channel := strings.TrimSpace(c.Query(key)); channel != "" {
|
||||
secure := c.Request.TLS != nil
|
||||
setCookie(c, "channel", channel, secure)
|
||||
patchRequestCookie(c, "channel", channel)
|
||||
return channel
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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,28 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// RecoverContentSession 会话失效后清理旧 cookie 并重新建立游客会话,调用方随后重试一次原请求。
|
||||
func RecoverContentSession(c *gin.Context, code int) bool {
|
||||
if c == nil || !IsSessionExpiredCode(code) {
|
||||
return false
|
||||
}
|
||||
ClearAuthCookies(c)
|
||||
for _, name := range authCookieNames() {
|
||||
dropRequestCookie(c, name)
|
||||
}
|
||||
return EnsureGuestOnEnter(c) != nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package authser
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// LoginResp 游客登录成功 data(只取会话所需字段)。
|
||||
type LoginResp struct {
|
||||
Token string `json:"token"`
|
||||
UserID int64 `json:"userId"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳。Data 在失败时可能是 string/null。
|
||||
type APIEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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
|
||||
}
|
||||
if q.ObjectType != ObjectTypeChapter {
|
||||
q.ObjectType = ObjectTypePlaylet
|
||||
}
|
||||
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/avatar.png"
|
||||
}
|
||||
return avatar
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package commentser
|
||||
|
||||
import "html/template"
|
||||
|
||||
// TemplateFuncs 评论展示辅助。
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatCommentTime": FormatCreatedAt,
|
||||
"commentAvatar": AvatarOrDefault,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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
|
||||
|
||||
SortHot = 1 // 热门
|
||||
SortLatest = 2 // 最新
|
||||
)
|
||||
@@ -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,40 @@
|
||||
package configser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildHomeRoadPageVMUsesDynamicConfig(t *testing.T) {
|
||||
cfg := AppConfigVM{
|
||||
NewURLAddr: "https://main.example.com",
|
||||
NewURLAddr1: "https://relay.example.com",
|
||||
NewURLAddr2: "https://latest.example.com",
|
||||
ForeverURLAddr: "https://forever.example.com",
|
||||
PDFAddr: "https://publish.example.com",
|
||||
GmailAddr: "home@example.com",
|
||||
TGGroup: "https://t.me/example",
|
||||
Telegram: "https://t.me/business-example",
|
||||
CustomerServiceHost: "https://service.example.com",
|
||||
TwitterAddr: "https://x.com/example",
|
||||
NavPageList: []NavPageItem{
|
||||
{Line: 1, URL: "https://line1.example.com"},
|
||||
{Line: 2, URL: "https://line2.example.com"},
|
||||
{Line: 3, URL: "https://line3.example.com"},
|
||||
},
|
||||
}
|
||||
vm := BuildHomeRoadPageVM(cfg)
|
||||
if vm.MainURL != cfg.NewURLAddr || len(vm.Routes) != 3 || vm.Routes[1].URL != "https://line2.example.com" {
|
||||
t.Fatalf("unexpected route VM: %#v", vm)
|
||||
}
|
||||
if len(vm.Permanent) != 3 || vm.Permanent[1].URL != cfg.NewURLAddr || vm.Permanent[2].URL != cfg.NewURLAddr1 {
|
||||
t.Fatalf("unexpected permanent links: %#v", vm.Permanent)
|
||||
}
|
||||
if len(vm.Contacts) != 4 || vm.Contacts[0].URL != "mailto:home@example.com" || vm.Contacts[2].URL != cfg.Telegram {
|
||||
t.Fatalf("unexpected contacts: %#v", vm.Contacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHomeRoadPageVMSkipsEmptyPermanentAddresses(t *testing.T) {
|
||||
vm := BuildHomeRoadPageVM(AppConfigVM{NewURLAddr: "https://relay.example.com"})
|
||||
if len(vm.Permanent) != 1 || vm.Permanent[0].Label != "永久中转页" {
|
||||
t.Fatalf("unexpected permanent links: %#v", vm.Permanent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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))
|
||||
return AppConfigVM{}
|
||||
}
|
||||
vm := BuildAppConfigVM(info)
|
||||
configCache = &vm
|
||||
configAt = time.Now()
|
||||
return vm
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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 页脚导航跳转一律进入富文本页。
|
||||
func RichTextHref(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return "/richtext/"
|
||||
}
|
||||
return "/richtext/" + title + "/"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package configser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRichTextHrefUsesRichtextRoutes(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"关于我们": "/richtext/关于我们/",
|
||||
"用户协议": "/richtext/用户协议/",
|
||||
"隐私政策": "/richtext/隐私政策/",
|
||||
"帮助中心": "/richtext/帮助中心/",
|
||||
"问题反馈": "/richtext/问题反馈/",
|
||||
"自定义内容": "/richtext/自定义内容/",
|
||||
}
|
||||
for title, want := range tests {
|
||||
if got := RichTextHref(title); got != want {
|
||||
t.Fatalf("RichTextHref(%q) = %q, want %q", title, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,162 @@
|
||||
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
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// ConfigInfo 对应上游 data.config(proto.ConfigInfo 子集,按页面需要取用)。
|
||||
type ConfigInfo struct {
|
||||
PotaoGroup string `json:"potaoGroup"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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,157 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AppConfigVM 注入全站页面的公共配置(字段名与接口 newAddressInfo / seoInfo 对齐)。
|
||||
type AppConfigVM struct {
|
||||
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"`
|
||||
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 SeoInfo `json:"seoInfo,omitempty"`
|
||||
SeoNavInfo SeoNavInfo `json:"seoNavInfo,omitempty"`
|
||||
NavPageList []NavPageItem `json:"navPageList,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 从上游配置组装前端可用字段。
|
||||
func BuildAppConfigVM(info *ConfigInfoResp) AppConfigVM {
|
||||
if info == nil {
|
||||
return AppConfigVM{}
|
||||
}
|
||||
addr := info.NewAddressInfo
|
||||
return AppConfigVM{
|
||||
ForeverURLAddr: sanitizeURL(addr.ForeverURLAddr),
|
||||
NewURLAddr: sanitizeURL(addr.NewURLAddr),
|
||||
NewURLAddr1: sanitizeURL(addr.NewURLAddr1),
|
||||
NewURLAddr2: sanitizeURL(addr.NewURLAddr2),
|
||||
GmailAddr: strings.TrimSpace(addr.GmailAddr),
|
||||
QQ: strings.TrimSpace(addr.QQ),
|
||||
TGGroup: sanitizeURL(addr.TGGroup),
|
||||
Telegram: sanitizeURL(addr.Telegram),
|
||||
PotaoGroup: sanitizeURL(firstNonEmptyURL(info.Config.PotaoGroup, addr.PotaoGroup)),
|
||||
CustomerServiceHost: sanitizeURL(addr.CustomerServiceHost),
|
||||
AppAddr: sanitizeURL(addr.AppAddr),
|
||||
OfficialAppAddr: sanitizeURL(addr.OfficialAppAddr),
|
||||
PDFAddr: sanitizeURL(addr.PDFAddr),
|
||||
TwitterAddr: sanitizeURL(addr.TwitterAddr),
|
||||
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 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.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)
|
||||
}
|
||||
|
||||
func firstNonEmptyURL(values ...string) string {
|
||||
for _, v := range values {
|
||||
if sanitizeURL(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package configser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAppConfigVMQRTextUsesAppAddr(t *testing.T) {
|
||||
cfg := AppConfigVM{
|
||||
AppAddr: " https://download.example.com/app ",
|
||||
ForeverURLAddr: "https://www.example.com/",
|
||||
}
|
||||
|
||||
if got, want := cfg.QRText(), "https://download.example.com/app"; got != want {
|
||||
t.Fatalf("QRText() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppConfigVMIncludesValidAdvertisements(t *testing.T) {
|
||||
vm := BuildAppConfigVM(&ConfigInfoResp{
|
||||
NewAddressInfo: NewAddressInfo{Telegram: " https://t.me/business ", QQ: " 2168045324 ", OfficialAppAddr: " https://app.example.com/download\\ "},
|
||||
Config: ConfigInfo{PotaoGroup: " https://potato.example.com/group\\ "},
|
||||
Advitise: AdvitiseData{
|
||||
HomeList: []Advertisement{{ID: "home", Images: "https://cdn.example.com/home.gif"}},
|
||||
DetailTopList: []Advertisement{{ID: "top", Images: " https://cdn.example.com/top.gif "}},
|
||||
NineGridList: []Advertisement{
|
||||
{ID: "banner-1", Images: "https://cdn.example.com/banner-1.gif"},
|
||||
{ID: "banner-empty", Images: " "},
|
||||
{ID: "banner-2", Images: "https://cdn.example.com/banner-2.gif"},
|
||||
{ID: "banner-3", Images: "https://cdn.example.com/banner-3.gif"},
|
||||
},
|
||||
DetailBannerList: []Advertisement{{ID: "detail", Images: "https://cdn.example.com/detail.gif"}},
|
||||
BottomBanner: []Advertisement{
|
||||
{ID: " bottom ", Images: " https://cdn.example.com/bottom.gif ", Link: " https://example.com/bottom "},
|
||||
{ID: "bottom-empty", Images: " "},
|
||||
},
|
||||
DialogList: []Advertisement{
|
||||
{ID: " 1 ", Images: " https://cdn.example.com/ad.webp\\ ", Link: " https://example.com ", Title: " 广告一 "},
|
||||
{ID: "2", Images: " ", Title: "无图广告"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if vm.Telegram != "https://t.me/business" {
|
||||
t.Fatalf("Telegram = %q, want sanitized business Telegram URL", vm.Telegram)
|
||||
}
|
||||
if vm.PotaoGroup != "https://potato.example.com/group" {
|
||||
t.Fatalf("PotaoGroup = %q, want sanitized config.potaoGroup", vm.PotaoGroup)
|
||||
}
|
||||
if vm.QQ != "2168045324" {
|
||||
t.Fatalf("QQ = %q, want trimmed QQ group", vm.QQ)
|
||||
}
|
||||
if vm.OfficialAppAddr != "https://app.example.com/download" {
|
||||
t.Fatalf("OfficialAppAddr = %q, want sanitized official app URL", vm.OfficialAppAddr)
|
||||
}
|
||||
|
||||
if len(vm.Advitise.HomeList) != 1 || vm.Advitise.HomeList[0].ID != "home" {
|
||||
t.Fatalf("Advitise.HomeList = %#v", vm.Advitise.HomeList)
|
||||
}
|
||||
if len(vm.Advitise.NineGridList) != 4 {
|
||||
t.Fatalf("Advitise.NineGridList length = %d, want raw length 4", len(vm.Advitise.NineGridList))
|
||||
}
|
||||
if len(vm.DetailTopList) != 1 || vm.DetailTopList[0].ID != "top" {
|
||||
t.Fatalf("DetailTopList = %#v", vm.DetailTopList)
|
||||
}
|
||||
if len(vm.NineGridList) != 3 {
|
||||
t.Fatalf("NineGridList length = %d, want 3", len(vm.NineGridList))
|
||||
}
|
||||
if len(vm.DetailBannerList) != 1 || vm.DetailBannerList[0].ID != "detail" {
|
||||
t.Fatalf("DetailBannerList = %#v", vm.DetailBannerList)
|
||||
}
|
||||
if len(vm.BottomBanner) != 1 || vm.BottomBanner[0].ID != "bottom" || vm.BottomBanner[0].Images != "https://cdn.example.com/bottom.gif" {
|
||||
t.Fatalf("BottomBanner = %#v", vm.BottomBanner)
|
||||
}
|
||||
if len(vm.DialogList) != 1 {
|
||||
t.Fatalf("DialogList length = %d, want 1", len(vm.DialogList))
|
||||
}
|
||||
got := vm.DialogList[0]
|
||||
if got.ID != "1" || got.Images != "https://cdn.example.com/ad.webp" || got.Link != "https://example.com" || got.Title != "广告一" {
|
||||
t.Fatalf("DialogList[0] = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNineGridRowReturnsTwoItemsPerRow(t *testing.T) {
|
||||
cfg := AppConfigVM{NineGridList: []Advertisement{{ID: "1"}, {ID: "2"}, {ID: "3"}}}
|
||||
if got := cfg.NineGridRow(0); len(got) != 2 || got[0].ID != "1" || got[1].ID != "2" {
|
||||
t.Fatalf("NineGridRow(0) = %#v", got)
|
||||
}
|
||||
if got := cfg.NineGridRow(1); len(got) != 1 || got[0].ID != "3" {
|
||||
t.Fatalf("NineGridRow(1) = %#v", got)
|
||||
}
|
||||
if got := cfg.NineGridRow(2); len(got) != 0 {
|
||||
t.Fatalf("NineGridRow(2) = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -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-其他(与接口 description 一致)
|
||||
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,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,49 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCategoryDetailListURLUsesSlugBeforeID(t *testing.T) {
|
||||
got := categoryDetailListURL(CategoryDetailQuery{
|
||||
PageNum: 2,
|
||||
CategoryID: 18,
|
||||
CategorySlug: "latest-video",
|
||||
})
|
||||
for _, want := range []string{"/api/app/playlet/recommend/list?", "categorySlug=latest-video", "pageNum=2", "pageSize=25"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("categoryDetailListURL() = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "categoryId=") {
|
||||
t.Fatalf("categoryDetailListURL() = %q, categoryId must be omitted when slug exists", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCategoryDetailQueryAndCanonicalPath(t *testing.T) {
|
||||
slugQuery := ParseCategoryDetailQuery("latest-video", "3")
|
||||
if slugQuery.CategorySlug != "latest-video" || slugQuery.PageNum != 3 || slugQuery.PageSize != 25 {
|
||||
t.Fatalf("ParseCategoryDetailQuery(slug) = %#v", slugQuery)
|
||||
}
|
||||
if got := CategoryDetailCanonicalPath(slugQuery); got != "/category/latest-video/page/3/" {
|
||||
t.Fatalf("CategoryDetailCanonicalPath(slug) = %q", got)
|
||||
}
|
||||
|
||||
idQuery := ParseCategoryDetailQuery("42", "")
|
||||
if idQuery.CategoryID != 42 || idQuery.CategorySlug != "" {
|
||||
t.Fatalf("ParseCategoryDetailQuery(id) = %#v", idQuery)
|
||||
}
|
||||
if got := CategoryDetailCanonicalPath(idQuery); got != "/category/42/" {
|
||||
t.Fatalf("CategoryDetailCanonicalPath(id) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryDetailURLPrefersSlugAndFallsBackToID(t *testing.T) {
|
||||
if got := categoryDetailURL("latest-video", 42); got != "/category/latest-video/" {
|
||||
t.Fatalf("categoryDetailURL(slug) = %q", got)
|
||||
}
|
||||
if got := categoryDetailURL("", 42); got != "/category/42/" {
|
||||
t.Fatalf("categoryDetailURL(id) = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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,154 @@
|
||||
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 时取短剧第一章。
|
||||
func ResolvePlayChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
|
||||
if chapterID > 0 {
|
||||
return chapterID
|
||||
}
|
||||
if playlet == nil {
|
||||
return 0
|
||||
}
|
||||
for _, ch := range playlet.PlayletChapterInfo {
|
||||
if ch.ID > 0 {
|
||||
return ch.ID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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 (
|
||||
detailPath = "/api/app/playlet/detail"
|
||||
defaultEpisodeRange = 30
|
||||
)
|
||||
|
||||
// EpisodeRange 剧集分段(仅 UI 分页展示,非接口字段映射)。
|
||||
type EpisodeRange struct {
|
||||
Start int
|
||||
End int
|
||||
Label string
|
||||
}
|
||||
|
||||
// FetchDetail 根据短剧 ID 查询详情。
|
||||
func FetchDetail(c *gin.Context, id int64) (*PlayletInfoResp, error) {
|
||||
if id <= 0 {
|
||||
return nil, fmt.Errorf("playlet detail invalid id")
|
||||
}
|
||||
return fetchDetail(c, "id", strconv.FormatInt(id, 10))
|
||||
}
|
||||
|
||||
// FetchDetailByKey 优先按 slug 查询;纯数字路由段按 ID 查询。
|
||||
func FetchDetailByKey(c *gin.Context, key string) (*PlayletInfoResp, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("playlet detail missing route key")
|
||||
}
|
||||
if id := ParseDetailID(key); id > 0 {
|
||||
return FetchDetail(c, id)
|
||||
}
|
||||
return fetchDetail(c, "slug", key)
|
||||
}
|
||||
|
||||
func fetchDetail(c *gin.Context, queryKey, value string) (*PlayletInfoResp, error) {
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
env, err := fetchDetailOnce(c, queryKey, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
env, err = fetchDetailOnce(c, queryKey, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
env.Data.SeoInfo.Normalize()
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
func fetchDetailOnce(c *gin.Context, queryKey, value string) (*APIEnvelope[PlayletInfoResp], error) {
|
||||
params := url.Values{}
|
||||
params.Set(queryKey, value)
|
||||
var env APIEnvelope[PlayletInfoResp]
|
||||
if err := upstream.GetJSON(c, detailPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchDetailOrNilByKey 失败或无效时返回 nil。
|
||||
func FetchDetailOrNilByKey(c *gin.Context, key string) *PlayletInfoResp {
|
||||
data, err := FetchDetailByKey(c, key)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet detail by route key failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
if data == nil || data.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ParseDetailID 解析纯数字路由段。
|
||||
func ParseDetailID(raw string) int64 {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// BuildEpisodeRanges 按集数分段(默认每段 30 集)。
|
||||
func BuildEpisodeRanges(chapters []PlayletChapterData, totalEpisode, size int) []EpisodeRange {
|
||||
if size <= 0 {
|
||||
size = defaultEpisodeRange
|
||||
}
|
||||
maxEp := 0
|
||||
for _, ch := range chapters {
|
||||
if ch.CurrentEpisode > maxEp {
|
||||
maxEp = ch.CurrentEpisode
|
||||
}
|
||||
}
|
||||
if totalEpisode > maxEp {
|
||||
maxEp = totalEpisode
|
||||
}
|
||||
if maxEp <= 0 {
|
||||
maxEp = len(chapters)
|
||||
}
|
||||
if maxEp <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]EpisodeRange, 0, (maxEp+size-1)/size)
|
||||
for start := 1; start <= maxEp; start += size {
|
||||
end := start + size - 1
|
||||
if end > maxEp {
|
||||
end = maxEp
|
||||
}
|
||||
out = append(out, EpisodeRange{
|
||||
Start: start,
|
||||
End: end,
|
||||
Label: fmt.Sprintf("%d–%d", start, end),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnmarshalJSON 兼容上游 data 为对象、null、空字符串,或二次 JSON 编码的字符串。
|
||||
func (e *APIEnvelope[T]) UnmarshalJSON(raw []byte) error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
var aux struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Code = aux.Code
|
||||
e.Msg = aux.Msg
|
||||
e.Tip = aux.Tip
|
||||
data, err := unmarshalEnvelopeData[T](aux.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Data = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmarshalEnvelopeData[T any](raw json.RawMessage) (T, error) {
|
||||
var zero T
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return zero, nil
|
||||
}
|
||||
if raw[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return zero, nil
|
||||
}
|
||||
inner := bytes.TrimSpace([]byte(s))
|
||||
if len(inner) == 0 {
|
||||
return zero, nil
|
||||
}
|
||||
if inner[0] != '{' && inner[0] != '[' {
|
||||
return zero, nil
|
||||
}
|
||||
if err := json.Unmarshal(inner, &zero); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return zero, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &zero); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return zero, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package playletser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFooterLinkURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "关于我们", raw: "/richtext/关于我们", want: "/richtext/关于我们/"},
|
||||
{name: "帮助中心", raw: "/richtext/帮助中心", want: "/richtext/帮助中心/"},
|
||||
{name: "隐私政策", raw: "/richtext/privacy", want: "/richtext/privacy/"},
|
||||
{name: "问题反馈", raw: "/feedback", want: "/feedback/"},
|
||||
{name: "用户中心", raw: "/user-center/", want: "/user-center/"},
|
||||
{name: "合作伙伴", raw: "https://example.com", want: "https://example.com"},
|
||||
{name: "HTTPS外链", raw: "HTTPS://Example.COM", want: "HTTPS://Example.COM"},
|
||||
{name: "关于我们", raw: "", want: ""},
|
||||
{name: "关于我们", raw: "/", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+" "+tt.raw, func(t *testing.T) {
|
||||
if got := footerLinkURL(tt.name, tt.raw); got != tt.want {
|
||||
t.Fatalf("footerLinkURL(%q, %q) = %q, want %q", tt.name, tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterLinkIsExternal(t *testing.T) {
|
||||
if !footerLinkIsExternal("http://a.com") || !footerLinkIsExternal("HTTPS://A.COM") {
|
||||
t.Fatal("http(s) URLs should be external")
|
||||
}
|
||||
if footerLinkIsExternal("/richtext/关于我们/") || footerLinkIsExternal("/feedback/") {
|
||||
t.Fatal("internal paths should not be external")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterContactURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
gmail string
|
||||
telegram string
|
||||
customer string
|
||||
want string
|
||||
}{
|
||||
{name: "E-mail", value: "hello@example.com", want: "mailto:hello@example.com"},
|
||||
{name: "邮箱", gmail: "support@example.com", want: "mailto:support@example.com"},
|
||||
{name: "商务合作", value: "立即联系", customer: "service.example.com", want: "https://service.example.com"},
|
||||
{name: "商务合作", value: "business@example.com", want: "mailto:business@example.com"},
|
||||
{name: "官方群", value: "立即加入", telegram: "https://t.me/example", want: "https://t.me/example"},
|
||||
{name: "官方群", value: "t.me/example", want: "https://t.me/example"},
|
||||
{name: "工作时间", value: "10:00-18:00", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+tt.value, func(t *testing.T) {
|
||||
got := footerContactURL(tt.name, tt.value, tt.gmail, tt.telegram, tt.customer)
|
||||
if got != tt.want {
|
||||
t.Fatalf("footerContactURL(%q, %q) = %q, want %q", tt.name, tt.value, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterContactValue(t *testing.T) {
|
||||
for _, value := range []string{"立即联系", "立即加入", " 立即加入 "} {
|
||||
if got := footerContactValue(value); got != "" {
|
||||
t.Fatalf("footerContactValue(%q) = %q, want empty", value, got)
|
||||
}
|
||||
}
|
||||
if got := footerContactValue("hello@example.com"); got != "hello@example.com" {
|
||||
t.Fatalf("footerContactValue(email) = %q", got)
|
||||
}
|
||||
if got := footerContactValue("立即联系:https://t1t1.com"); got != "https://t1t1.com" {
|
||||
t.Fatalf("footerContactValue(business URL) = %q", got)
|
||||
}
|
||||
if got := footerContactValue("立即加入: https://t.me/fuliqun_91sp"); got != "https://t.me/fuliqun_91sp" {
|
||||
t.Fatalf("footerContactValue(group URL) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterContactName(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"商务合作:立即联系": "商务合作",
|
||||
"官方群:立即加入": "官方群",
|
||||
"立即联系:商务合作": "商务合作",
|
||||
" E-mail ": "E-mail",
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := footerContactName(input); got != want {
|
||||
t.Fatalf("footerContactName(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavDisplayURL(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"https://91lvmao0000.com/": "91lvmao0000.com",
|
||||
"http://example.com/path/": "example.com/path",
|
||||
"example.com": "example.com",
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := navDisplayURL(input); got != want {
|
||||
t.Fatalf("navDisplayURL(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const forwardHeatPath = "/api/app/playlet/forward/heat"
|
||||
|
||||
// PlayletForwardHeatReq 对应 swagger proto.PlayletForwardHeatReq
|
||||
type PlayletForwardHeatReq struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// PlayletForwardHeatResp 对应 swagger proto.PlayletForwardHeatResp
|
||||
type PlayletForwardHeatResp struct {
|
||||
ID int64 `json:"id"`
|
||||
HeatValue int64 `json:"heatValue"`
|
||||
}
|
||||
|
||||
// ForwardHeat 短剧转发增加热度。
|
||||
func ForwardHeat(c *gin.Context, req PlayletForwardHeatReq) (*APIEnvelope[PlayletForwardHeatResp], error) {
|
||||
if req.ID <= 0 {
|
||||
return nil, fmt.Errorf("playlet forward heat invalid id")
|
||||
}
|
||||
var env APIEnvelope[PlayletForwardHeatResp]
|
||||
if _, err := upstream.PostJSON(c, forwardHeatPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const homePath = "/api/app/playlet/home"
|
||||
const homeCacheTTL = 30 * time.Second
|
||||
|
||||
var (
|
||||
homeMu sync.Mutex
|
||||
homeCache *HomePlayletResponse
|
||||
homeAt time.Time
|
||||
)
|
||||
|
||||
// FetchHome 拉取首页短剧聚合数据。
|
||||
func FetchHome(c *gin.Context) (*HomePlayletResponse, error) {
|
||||
var env APIEnvelope[HomePlayletResponse]
|
||||
if err := upstream.GetPublicJSON(c, homePath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet home code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchHomeOrEmpty 带短缓存;失败时打日志并返回空结构,保证 SSR 页面可渲染。
|
||||
func FetchHomeOrEmpty(c *gin.Context) *HomePlayletResponse {
|
||||
homeMu.Lock()
|
||||
defer homeMu.Unlock()
|
||||
if homeCache != nil && time.Since(homeAt) < homeCacheTTL {
|
||||
return cloneHome(homeCache)
|
||||
}
|
||||
|
||||
data, err := FetchHome(c)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet home failed", log.E(err))
|
||||
if homeCache != nil {
|
||||
return cloneHome(homeCache)
|
||||
}
|
||||
return &HomePlayletResponse{}
|
||||
}
|
||||
if data == nil {
|
||||
if homeCache != nil {
|
||||
return cloneHome(homeCache)
|
||||
}
|
||||
return &HomePlayletResponse{}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
homeCache = data
|
||||
homeAt = time.Now()
|
||||
return cloneHome(homeCache)
|
||||
}
|
||||
|
||||
func cloneHome(src *HomePlayletResponse) *HomePlayletResponse {
|
||||
if src == nil {
|
||||
return &HomePlayletResponse{}
|
||||
}
|
||||
out := *src
|
||||
if src.Announcements != nil {
|
||||
out.Announcements = append([]AnnouncementItem(nil), src.Announcements...)
|
||||
}
|
||||
if src.HomeBannerData != nil {
|
||||
out.HomeBannerData = append([]HomeBannerData(nil), src.HomeBannerData...)
|
||||
}
|
||||
if src.HomeTodayUpdateData != nil {
|
||||
out.HomeTodayUpdateData = append([]CommonData(nil), src.HomeTodayUpdateData...)
|
||||
}
|
||||
if src.HomeCategoryData != nil {
|
||||
out.HomeCategoryData = make([]HomeCategoryData, len(src.HomeCategoryData))
|
||||
for i, cat := range src.HomeCategoryData {
|
||||
out.HomeCategoryData[i] = cat
|
||||
if cat.List != nil {
|
||||
out.HomeCategoryData[i].List = append([]CommonData(nil), cat.List...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if src.HomeRecommendThemes != nil {
|
||||
out.HomeRecommendThemes = append([]HomeRecommendTheme(nil), src.HomeRecommendThemes...)
|
||||
}
|
||||
if src.SeoInfo.Keywords != nil {
|
||||
out.SeoInfo.Keywords = append([]string(nil), src.SeoInfo.Keywords...)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// ActiveHomeBanners 过滤未启用项,保留接口返回顺序。
|
||||
func ActiveHomeBanners(list []HomeBannerData) []HomeBannerData {
|
||||
if len(list) == 0 {
|
||||
return list
|
||||
}
|
||||
out := make([]HomeBannerData, 0, len(list))
|
||||
for _, item := range list {
|
||||
if !item.Status {
|
||||
continue
|
||||
}
|
||||
item.URL = normalizeBannerURL(item.URL)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeBannerURL 站内页面链接补尾斜杠,避免后台漏填 "/" 时每次点击都多一次 301。
|
||||
// 外链、协议相对地址、带扩展名的文件路径保持原样。
|
||||
func normalizeBannerURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||
return raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Path == "" || strings.HasSuffix(u.Path, "/") {
|
||||
return raw
|
||||
}
|
||||
if strings.Contains(path.Base(u.Path), ".") {
|
||||
return raw
|
||||
}
|
||||
u.Path += "/"
|
||||
return u.String()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package playletser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeBannerURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/detail/hqtqxdqe": "/detail/hqtqxdqe/",
|
||||
"/detail/hqtqxdqe/": "/detail/hqtqxdqe/",
|
||||
" /play/abc ": "/play/abc/",
|
||||
"/detail/abc?from=banner": "/detail/abc/?from=banner",
|
||||
"/": "/",
|
||||
"": "",
|
||||
"https://example.com/a": "https://example.com/a",
|
||||
"//cdn.example.com/a": "//cdn.example.com/a",
|
||||
"/static/images/a.png": "/static/images/a.png",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeBannerURL(in); got != want {
|
||||
t.Errorf("normalizeBannerURL(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveHomeBannersNormalizesURL(t *testing.T) {
|
||||
got := ActiveHomeBanners([]HomeBannerData{
|
||||
{ID: 1, URL: "/detail/abc", Status: true},
|
||||
{ID: 2, URL: "/detail/off", Status: false},
|
||||
})
|
||||
if len(got) != 1 || got[0].URL != "/detail/abc/" {
|
||||
t.Fatalf("unexpected banners: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const hotLikePath = "/api/app/playlet/hot-like"
|
||||
|
||||
// PlayletHotSearchItem 对应 swagger proto.PlayletHotSearchItem。
|
||||
type PlayletHotSearchItem struct {
|
||||
Keyword string `json:"keyword"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// PlayletHotLikeResp 对应 swagger proto.PlayletHotLikeResp。
|
||||
type PlayletHotLikeResp struct {
|
||||
HotSearch []PlayletHotSearchItem `json:"hotSearch"`
|
||||
GuessLike []CommonData `json:"guessLike"`
|
||||
}
|
||||
|
||||
// FetchHotLike 获取前 10 个热搜词及按收藏量排序的 15 部猜你喜欢短剧。
|
||||
func FetchHotLike(c *gin.Context) (*PlayletHotLikeResp, error) {
|
||||
var env APIEnvelope[PlayletHotLikeResp]
|
||||
if err := upstream.GetPublicJSON(c, hotLikePath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet hot-like code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlayletHotLikeRespMatchesSwaggerFields(t *testing.T) {
|
||||
const raw = `{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"hotSearch": [{"keyword": "都市", "count": 18}],
|
||||
"guessLike": [{"id": 12, "title": "示例短剧", "slug": "example"}]
|
||||
}
|
||||
}`
|
||||
|
||||
var env APIEnvelope[PlayletHotLikeResp]
|
||||
if err := json.Unmarshal([]byte(raw), &env); err != nil {
|
||||
t.Fatalf("unmarshal hot-like response: %v", err)
|
||||
}
|
||||
if env.Code != 200 || len(env.Data.HotSearch) != 1 || env.Data.HotSearch[0].Keyword != "都市" || env.Data.HotSearch[0].Count != 18 {
|
||||
t.Fatalf("unexpected hot search data: %#v", env)
|
||||
}
|
||||
if len(env.Data.GuessLike) != 1 || env.Data.GuessLike[0].ID != 12 || env.Data.GuessLike[0].Slug != "example" {
|
||||
t.Fatalf("unexpected guess-like data: %#v", env.Data.GuessLike)
|
||||
}
|
||||
if hotLikePath != "/api/app/playlet/hot-like" {
|
||||
t.Fatalf("hotLikePath = %q", hotLikePath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
listPath = "/api/app/playlet/list"
|
||||
themePath = "/api/app/theme/list"
|
||||
|
||||
defaultListPageSize = 25
|
||||
sortHeat = 1 // 热度优先
|
||||
sortTodayUpdate = 2 // 今日更新
|
||||
// 站点统一类型:1-非成人 2-成人(与 themeType 一致)。
|
||||
contentTypeNonAdult = 1
|
||||
contentTypeAdult = 2
|
||||
// playlet/list 上游 type 与站点定义相反:1-成人 2-非成人。
|
||||
upstreamListTypeAdult = 1
|
||||
upstreamListTypeNonAdult = 2
|
||||
)
|
||||
|
||||
// ListQuery 短剧列表查询参数。
|
||||
type ListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortOrder int
|
||||
ThemeID int64
|
||||
ThemeSlug string
|
||||
Type int
|
||||
}
|
||||
|
||||
// Normalize 校正分页与排序默认值。
|
||||
func (q *ListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultListPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.SortOrder != sortTodayUpdate {
|
||||
q.SortOrder = sortHeat
|
||||
}
|
||||
if q.ThemeID < 0 {
|
||||
q.ThemeID = 0
|
||||
}
|
||||
q.Type = normalizeContentType(q.Type)
|
||||
}
|
||||
|
||||
// FetchList 分页查询已上架短剧。
|
||||
func FetchList(c *gin.Context, q ListQuery) (*PlayletVideoResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
|
||||
if strings.TrimSpace(q.ThemeSlug) != "" {
|
||||
params.Set("themeSlug", strings.TrimSpace(q.ThemeSlug))
|
||||
} else if q.ThemeID > 0 {
|
||||
params.Set("themeId", strconv.FormatInt(q.ThemeID, 10))
|
||||
} else {
|
||||
params.Set("type", strconv.Itoa(listTypeForUpstream(q.Type)))
|
||||
}
|
||||
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetPublicJSON(c, listPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchListOrEmpty 失败时返回空列表。
|
||||
func FetchListOrEmpty(c *gin.Context, q ListQuery) *PlayletVideoResp {
|
||||
data, err := FetchList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet list failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchThemes 拉取题材列表。
|
||||
func FetchThemes(c *gin.Context, contentType int) ([]PlayletThemeItem, error) {
|
||||
contentType = normalizeContentType(contentType)
|
||||
params := url.Values{}
|
||||
params.Set("themeType", strconv.Itoa(contentType))
|
||||
var env APIEnvelope[PlayletThemeListResponse]
|
||||
if err := upstream.GetPublicJSON(c, themePath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("theme list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return env.Data.List, nil
|
||||
}
|
||||
|
||||
// FetchThemesOrEmpty 失败时返回空题材。
|
||||
func FetchThemesOrEmpty(c *gin.Context, contentType int) []PlayletThemeItem {
|
||||
list, err := FetchThemes(c, contentType)
|
||||
if err != nil {
|
||||
log.Error("fetch theme list failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func normalizeContentType(contentType int) int {
|
||||
if contentType == contentTypeAdult {
|
||||
return contentTypeAdult
|
||||
}
|
||||
return contentTypeNonAdult
|
||||
}
|
||||
|
||||
// listTypeForUpstream 将站点统一类型转为 playlet/list 的 type(该接口 1=成人 2=非成人)。
|
||||
func listTypeForUpstream(contentType int) int {
|
||||
if normalizeContentType(contentType) == contentTypeAdult {
|
||||
return upstreamListTypeAdult
|
||||
}
|
||||
return upstreamListTypeNonAdult
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PaginationVM 分页条数据(页面 UI,非接口字段映射)。
|
||||
type PaginationVM struct {
|
||||
Visible bool
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevURL string
|
||||
NextURL string
|
||||
Page int
|
||||
TotalPages int
|
||||
Items []PaginationItemVM
|
||||
}
|
||||
|
||||
// PaginationItemVM 页码或省略号。
|
||||
type PaginationItemVM struct {
|
||||
Label string
|
||||
URL string
|
||||
Active bool
|
||||
Dots bool
|
||||
}
|
||||
|
||||
// FilterChipVM 筛选 / Tab UI。
|
||||
type FilterChipVM struct {
|
||||
Label string
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ThemeChipVM 题材筛选芯片(ID/Name 来自接口,URL/Active 为页面状态)。
|
||||
type ThemeChipVM struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// FindThemeSlugByID 查询 ID 对应的 slug;无 slug 时继续使用 ID 路由。
|
||||
func FindThemeSlugByID(themes []PlayletThemeItem, id int64) string {
|
||||
if id <= 0 {
|
||||
return ""
|
||||
}
|
||||
for _, theme := range themes {
|
||||
if theme.ID == id {
|
||||
return strings.TrimSpace(theme.Slug)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func listPageURL(themeID int64, themeSlug string, sortOrder, pageNum, contentType int) string {
|
||||
if sortOrder != sortTodayUpdate {
|
||||
sortOrder = sortHeat
|
||||
}
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cleanSlug := strings.TrimSpace(themeSlug)
|
||||
defaultSort := sortHeat
|
||||
if cleanSlug == "ai-short-play" || cleanSlug == "ai-series" {
|
||||
defaultSort = sortTodayUpdate
|
||||
}
|
||||
if themeID <= 0 && cleanSlug == "" && sortOrder == sortHeat && pageNum == 1 {
|
||||
return "/list/"
|
||||
}
|
||||
path := "/list"
|
||||
segment := pathSegment(themeSlug, themeID)
|
||||
if segment == "" {
|
||||
segment = "all"
|
||||
}
|
||||
path += "/" + segment + "/" + strconv.Itoa(sortOrder)
|
||||
if themeID <= 0 && cleanSlug != "" && sortOrder == defaultSort && pageNum == 1 {
|
||||
return "/list/" + segment + "/"
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
// ListCanonicalPath 返回列表页唯一的无 query 规范路径。
|
||||
func ListCanonicalPath(q ListQuery) string {
|
||||
q.Normalize()
|
||||
return listPageURL(q.ThemeID, q.ThemeSlug, q.SortOrder, q.PageNum, q.Type)
|
||||
}
|
||||
|
||||
// ListResetURL 重置筛选并生成无 query 的默认排序路径。
|
||||
func ListResetURL(q ListQuery) string {
|
||||
q.Normalize()
|
||||
return ListCanonicalPath(ListQuery{Type: q.Type})
|
||||
}
|
||||
|
||||
// BuildListThemes 题材芯片;Name/ID 保持接口字段语义。
|
||||
func BuildListThemes(themes []PlayletThemeItem, q ListQuery) []ThemeChipVM {
|
||||
q.Normalize()
|
||||
out := make([]ThemeChipVM, 0, len(themes)+1)
|
||||
out = append(out, ThemeChipVM{
|
||||
ID: 0,
|
||||
Name: "全部",
|
||||
URL: listPageURL(0, "", q.SortOrder, 1, q.Type),
|
||||
Active: q.ThemeID == 0 && strings.TrimSpace(q.ThemeSlug) == "",
|
||||
})
|
||||
for _, t := range themes {
|
||||
if !t.Status {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(t.Name)
|
||||
if name == "" || t.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, ThemeChipVM{
|
||||
ID: t.ID,
|
||||
Name: name,
|
||||
URL: listPageURL(t.ID, t.Slug, q.SortOrder, 1, q.Type),
|
||||
Active: q.ThemeID == t.ID || (strings.TrimSpace(q.ThemeSlug) != "" && strings.TrimSpace(q.ThemeSlug) == strings.TrimSpace(t.Slug)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildListSortChips 排序芯片。
|
||||
func BuildListSortChips(q ListQuery) (heat, update FilterChipVM) {
|
||||
q.Normalize()
|
||||
heat = FilterChipVM{
|
||||
Label: "热度优先",
|
||||
URL: listPageURL(q.ThemeID, q.ThemeSlug, sortHeat, 1, q.Type),
|
||||
Active: q.SortOrder == sortHeat,
|
||||
}
|
||||
update = FilterChipVM{
|
||||
Label: "今日更新",
|
||||
URL: listPageURL(q.ThemeID, q.ThemeSlug, sortTodayUpdate, 1, q.Type),
|
||||
Active: q.SortOrder == sortTodayUpdate,
|
||||
}
|
||||
return heat, update
|
||||
}
|
||||
|
||||
// BuildListPagination 列表分页。
|
||||
func BuildListPagination(q ListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return listPageURL(q.ThemeID, q.ThemeSlug, q.SortOrder, p, q.Type)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// BuildPagination 生成分页 VM;pageURL 生成指定页链接。
|
||||
func BuildPagination(pageURL func(int) string, pageNum, pageSize int, totalCount int64) PaginationVM {
|
||||
if pageURL == nil {
|
||||
pageURL = func(p int) string {
|
||||
if p <= 1 {
|
||||
return "#"
|
||||
}
|
||||
return "?page=" + strconv.Itoa(p)
|
||||
}
|
||||
}
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultListPageSize
|
||||
}
|
||||
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||
if totalPages <= 1 {
|
||||
return PaginationVM{Visible: false}
|
||||
}
|
||||
if pageNum > totalPages {
|
||||
pageNum = totalPages
|
||||
}
|
||||
|
||||
return PaginationVM{
|
||||
Visible: true,
|
||||
HasPrev: pageNum > 1,
|
||||
HasNext: pageNum < totalPages,
|
||||
PrevURL: pageURL(pageNum - 1),
|
||||
NextURL: pageURL(pageNum + 1),
|
||||
Page: pageNum,
|
||||
TotalPages: totalPages,
|
||||
Items: buildPageItems(pageNum, totalPages, pageURL),
|
||||
}
|
||||
}
|
||||
|
||||
func buildPageItems(current, total int, pageURL func(int) string) []PaginationItemVM {
|
||||
if total <= 0 {
|
||||
return nil
|
||||
}
|
||||
if total <= 7 {
|
||||
out := make([]PaginationItemVM, 0, total)
|
||||
for i := 1; i <= total; i++ {
|
||||
out = append(out, PaginationItemVM{
|
||||
Label: strconv.Itoa(i),
|
||||
URL: pageURL(i),
|
||||
Active: i == current,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
addPage := func(out *[]PaginationItemVM, p int) {
|
||||
*out = append(*out, PaginationItemVM{
|
||||
Label: strconv.Itoa(p),
|
||||
URL: pageURL(p),
|
||||
Active: p == current,
|
||||
})
|
||||
}
|
||||
addDots := func(out *[]PaginationItemVM) {
|
||||
*out = append(*out, PaginationItemVM{Dots: true, Label: "…"})
|
||||
}
|
||||
|
||||
var out []PaginationItemVM
|
||||
addPage(&out, 1)
|
||||
if current > 3 {
|
||||
addDots(&out)
|
||||
}
|
||||
start := current - 1
|
||||
end := current + 1
|
||||
if start < 2 {
|
||||
start = 2
|
||||
}
|
||||
if end > total-1 {
|
||||
end = total - 1
|
||||
}
|
||||
for p := start; p <= end; p++ {
|
||||
addPage(&out, p)
|
||||
}
|
||||
if current < total-2 {
|
||||
addDots(&out)
|
||||
}
|
||||
addPage(&out, total)
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseListQuery 从 gin query 解析列表参数。
|
||||
func ParseListQuery(pageStr, sortStr, typeStr string) ListQuery {
|
||||
q := ListQuery{PageSize: defaultListPageSize, SortOrder: sortHeat, Type: contentTypeNonAdult}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(sortStr)); err == nil {
|
||||
q.SortOrder = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(typeStr)); err == nil {
|
||||
q.Type = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const operateListPath = "/api/app/playlet/operate/list"
|
||||
|
||||
type OperateListQuery struct {
|
||||
OperateType int
|
||||
FilterType int
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (q *OperateListQuery) Normalize() {
|
||||
if q.OperateType < 1 || q.OperateType > 2 {
|
||||
q.OperateType = 1
|
||||
}
|
||||
if q.FilterType < 1 || q.FilterType > 3 {
|
||||
q.FilterType = 1
|
||||
}
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = 40
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
}
|
||||
|
||||
// FetchOperateList 按运营类型和筛选类型查询已上架短剧。
|
||||
func FetchOperateList(c *gin.Context, q OperateListQuery) (*PlayletOperateListResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("operateType", strconv.Itoa(q.OperateType))
|
||||
params.Set("filterType", strconv.Itoa(q.FilterType))
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[PlayletOperateListResp]
|
||||
if err := upstream.GetPublicJSON(c, operateListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet operate list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchOperateListOrEmpty 为 SSR 页面提供容错,接口异常时仍渲染完整页面骨架。
|
||||
func FetchOperateListOrEmpty(c *gin.Context, q OperateListQuery) *PlayletOperateListResp {
|
||||
q.Normalize()
|
||||
data, err := FetchOperateList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet operate list failed", log.E(err))
|
||||
return &PlayletOperateListResp{
|
||||
OperateType: q.OperateType,
|
||||
FilterType: q.FilterType,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletOperateListResp{
|
||||
OperateType: q.OperateType,
|
||||
FilterType: q.FilterType,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
rankPath = "/api/app/playlet/rank"
|
||||
|
||||
defaultRankPageSize = 30
|
||||
|
||||
// 排行榜 sortOrder:1-热播榜,2-新剧榜,3-追剧榜
|
||||
rankSortHot = 1
|
||||
rankSortNew = 2
|
||||
rankSortBinge = 3
|
||||
)
|
||||
|
||||
// RankQuery 排行榜查询参数。
|
||||
type RankQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// Normalize 校正分页与榜单类型。
|
||||
func (q *RankQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultRankPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
switch q.SortOrder {
|
||||
case rankSortNew, rankSortBinge:
|
||||
default:
|
||||
q.SortOrder = rankSortHot
|
||||
}
|
||||
}
|
||||
|
||||
// FetchRank 分页查询短剧排行榜。
|
||||
func FetchRank(c *gin.Context, q RankQuery) (*PlayletVideoResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
|
||||
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetPublicJSON(c, rankPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet rank code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchRankOrEmpty 失败时返回空榜单。
|
||||
func FetchRankOrEmpty(c *gin.Context, q RankQuery) *PlayletVideoResp {
|
||||
data, err := FetchRank(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet rank failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func formatCollectHeat(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)
|
||||
}
|
||||
|
||||
func rankPageURL(sortOrder, pageNum int) string {
|
||||
switch sortOrder {
|
||||
case rankSortNew, rankSortBinge:
|
||||
default:
|
||||
sortOrder = rankSortHot
|
||||
}
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/rank"
|
||||
if sortOrder != rankSortHot {
|
||||
path += "/" + strconv.Itoa(sortOrder)
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
// BuildRankTabs 排行榜 Tab。
|
||||
func BuildRankTabs(q RankQuery) []FilterChipVM {
|
||||
q.Normalize()
|
||||
return []FilterChipVM{
|
||||
{Label: "热播榜", URL: rankPageURL(rankSortHot, 1), Active: q.SortOrder == rankSortHot},
|
||||
{Label: "新剧榜", URL: rankPageURL(rankSortNew, 1), Active: q.SortOrder == rankSortNew},
|
||||
{Label: "追剧榜", URL: rankPageURL(rankSortBinge, 1), Active: q.SortOrder == rankSortBinge},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRankPagination 排行榜分页。
|
||||
func BuildRankPagination(q RankQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return rankPageURL(q.SortOrder, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// RankCanonicalPath 返回排行榜筛选和分页对应的唯一规范路径。
|
||||
func RankCanonicalPath(q RankQuery) string {
|
||||
q.Normalize()
|
||||
return rankPageURL(q.SortOrder, q.PageNum)
|
||||
}
|
||||
|
||||
// RankOffset 当前页起始排名偏移(0-based)。
|
||||
func RankOffset(q RankQuery) int {
|
||||
q.Normalize()
|
||||
return (q.PageNum - 1) * q.PageSize
|
||||
}
|
||||
|
||||
// ParseRankQuery 从 query 解析排行榜参数。
|
||||
func ParseRankQuery(pageStr, sortStr string) RankQuery {
|
||||
q := RankQuery{PageSize: defaultRankPageSize, SortOrder: rankSortHot}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(sortStr)); err == nil {
|
||||
q.SortOrder = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// IsRankBinge 是否追剧榜(热度展示用 countCollect)。
|
||||
func IsRankBinge(sortOrder int) bool {
|
||||
return sortOrder == rankSortBinge
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package playletser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRankCanonicalPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
q RankQuery
|
||||
want string
|
||||
}{
|
||||
{name: "default", q: RankQuery{}, want: "/rank/"},
|
||||
{name: "new", q: RankQuery{SortOrder: rankSortNew}, want: "/rank/2/"},
|
||||
{name: "paged", q: RankQuery{SortOrder: rankSortBinge, PageNum: 3}, want: "/rank/3/page/3/"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := RankCanonicalPath(tt.q); got != tt.want {
|
||||
t.Fatalf("RankCanonicalPath(%#v) = %q, want %q", tt.q, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
recommendListPath = "/api/app/playlet/list"
|
||||
defaultRecommendSize = 25
|
||||
)
|
||||
|
||||
// RecommendQuery 专题详情分页参数。
|
||||
type RecommendQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortOrder int
|
||||
ThemeID int64
|
||||
ThemeSlug string
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *RecommendQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultRecommendSize
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
if q.SortOrder != sortTodayUpdate {
|
||||
q.SortOrder = sortHeat
|
||||
}
|
||||
}
|
||||
|
||||
func recommendListURL(q RecommendQuery) string {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
|
||||
if slug := strings.TrimSpace(q.ThemeSlug); slug != "" {
|
||||
params.Set("themeSlug", slug)
|
||||
} else if q.ThemeID > 0 {
|
||||
params.Set("themeId", strconv.FormatInt(q.ThemeID, 10))
|
||||
}
|
||||
return recommendListPath + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// FetchRecommendList 按 Swagger 的题材 ID 或 slug 参数分页查询专题短剧。
|
||||
func FetchRecommendList(c *gin.Context, q RecommendQuery) (*PlayletVideoResp, error) {
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetPublicJSON(c, recommendListURL(q), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet recommend list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchRecommendListOrEmpty 失败时返回空列表。
|
||||
func FetchRecommendListOrEmpty(c *gin.Context, q RecommendQuery) *PlayletVideoResp {
|
||||
q.Normalize()
|
||||
data, err := FetchRecommendList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet recommend list failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func recommendPageURL(themeID int64, themeSlug string, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/topic"
|
||||
if segment := pathSegment(themeSlug, themeID); segment != "" {
|
||||
path += "/" + segment
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
func RecommendPagePath(q RecommendQuery) string {
|
||||
q.Normalize()
|
||||
return recommendPageURL(q.ThemeID, q.ThemeSlug, q.PageNum)
|
||||
}
|
||||
|
||||
// RecommendThemeTitle 专题页标题:优先题材名称,其次使用列表接口 SEO 标题。
|
||||
func RecommendThemeTitle(name, seoTitle string, themeID int64) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
seoTitle = strings.TrimSpace(seoTitle)
|
||||
if seoTitle != "" {
|
||||
return seoTitle
|
||||
}
|
||||
if themeID > 0 {
|
||||
return "专题短剧"
|
||||
}
|
||||
return "专题推荐"
|
||||
}
|
||||
|
||||
// RecommendCanonicalPath 推荐页 canonical(不含 page)。
|
||||
func RecommendCanonicalPath(themeID int64, themeSlug string) string {
|
||||
return recommendPageURL(themeID, themeSlug, 1)
|
||||
}
|
||||
|
||||
// BuildRecommendPagination 推荐列表分页。
|
||||
func BuildRecommendPagination(q RecommendQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return recommendPageURL(q.ThemeID, q.ThemeSlug, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// ParseRecommendQuery 从路由解析分页。
|
||||
func ParseRecommendQuery(pageStr string) RecommendQuery {
|
||||
q := RecommendQuery{PageSize: defaultRecommendSize, SortOrder: sortHeat}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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 (
|
||||
searchPath = "/api/app/playlet/search"
|
||||
defaultSearchPageSize = 25
|
||||
)
|
||||
|
||||
// SearchQuery 搜索分页参数(keyword 对应上游 query)。
|
||||
type SearchQuery struct {
|
||||
Keyword string
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *SearchQuery) Normalize() {
|
||||
q.Keyword = strings.TrimSpace(q.Keyword)
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultSearchPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
}
|
||||
|
||||
// FetchSearch ES 分页搜索短剧。
|
||||
func FetchSearch(c *gin.Context, q SearchQuery) (*PlayletVideoResp, error) {
|
||||
q.Normalize()
|
||||
if q.Keyword == "" {
|
||||
return nil, fmt.Errorf("playlet search keyword is empty")
|
||||
}
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
env, err := fetchSearchOnce(c, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
env, err = fetchSearchOnce(c, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet search code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
func fetchSearchOnce(c *gin.Context, q SearchQuery) (*APIEnvelope[PlayletVideoResp], error) {
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("keyword", q.Keyword)
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetJSON(c, searchPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchSearchOrEmpty 失败时返回空列表。
|
||||
func FetchSearchOrEmpty(c *gin.Context, q SearchQuery) *PlayletVideoResp {
|
||||
data, err := FetchSearch(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet search failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func searchPageURL(keyword string, pageNum int) string {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return "/search/"
|
||||
}
|
||||
path := "/search/" + url.PathEscape(keyword)
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
func SearchCanonicalPath(q SearchQuery) string {
|
||||
q.Normalize()
|
||||
return searchPageURL(q.Keyword, q.PageNum)
|
||||
}
|
||||
|
||||
// BuildSearchPagination 搜索分页。
|
||||
func BuildSearchPagination(q SearchQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return searchPageURL(q.Keyword, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// ParseSearchQuery 从站点 query 解析(q → keyword)。
|
||||
func ParseSearchQuery(qStr, pageStr string) SearchQuery {
|
||||
q := SearchQuery{PageSize: defaultSearchPageSize, Keyword: strings.TrimSpace(qStr)}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PageOutOfRange 判断分页是否越界(第 1 页始终合法,即使列表为空)。
|
||||
func PageOutOfRange(pageNum, pageSize int, totalCount int64) bool {
|
||||
if pageNum <= 1 {
|
||||
return false
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultListPageSize
|
||||
}
|
||||
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||
if totalPages < 1 {
|
||||
return true
|
||||
}
|
||||
return pageNum > totalPages
|
||||
}
|
||||
|
||||
// PageTitleSuffix 分页标题后缀:第 1 页为空,其余为「 - 第N页」。
|
||||
func PageTitleSuffix(pageNum int) string {
|
||||
if pageNum <= 1 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" - 第%d页", pageNum)
|
||||
}
|
||||
|
||||
// PaginatedTitle 为第 2 页及以后追加页码,并避免重复追加。
|
||||
func PaginatedTitle(title string, pageNum int) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || pageNum <= 1 {
|
||||
return title
|
||||
}
|
||||
marker := fmt.Sprintf("第%d页", pageNum)
|
||||
if strings.Contains(title, marker) {
|
||||
return title
|
||||
}
|
||||
return title + PageTitleSuffix(pageNum)
|
||||
}
|
||||
|
||||
// PaginateSeoInfo 统一生成分页页的唯一 title 与 description。
|
||||
// fallbackDescription 仅在上游未提供 description 时使用。
|
||||
func PaginateSeoInfo(base SeoInfo, fallbackDescription string, pageNum int) SeoInfo {
|
||||
base.Normalize()
|
||||
if pageNum <= 1 {
|
||||
return base
|
||||
}
|
||||
base.Title = PaginatedTitle(base.Title, pageNum)
|
||||
if base.Description == "" {
|
||||
base.Description = strings.TrimSpace(fallbackDescription)
|
||||
}
|
||||
if base.Description != "" {
|
||||
marker := fmt.Sprintf("第%d页", pageNum)
|
||||
if !strings.Contains(base.Description, marker) {
|
||||
base.Description = strings.TrimRight(base.Description, "。;; ") + " - " + marker
|
||||
}
|
||||
}
|
||||
base.Normalize()
|
||||
return base
|
||||
}
|
||||
|
||||
// UnmarshalJSON 兼容 swagger proto.SeoInfo,并容忍上游别名 / 字符串 keywords。
|
||||
// 主字段:title、description、keywords[]、canonicalUrl。
|
||||
func (s *SeoInfo) 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 {
|
||||
Title string `json:"title"`
|
||||
WebTitle string `json:"webTitle"`
|
||||
Description string `json:"description"`
|
||||
WebDescription string `json:"webDescription"`
|
||||
CanonicalURL string `json:"canonicalUrl"`
|
||||
Canonical string `json:"canonical"`
|
||||
WebSiteName string `json:"webSiteName"`
|
||||
Keywords json.RawMessage `json:"keywords"`
|
||||
Keyworld json.RawMessage `json:"keyworld"`
|
||||
WebKeyworld json.RawMessage `json:"webKeyworld"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Title = firstNonEmpty(aux.Title, aux.WebTitle)
|
||||
s.Description = firstNonEmpty(aux.Description, aux.WebDescription)
|
||||
s.CanonicalURL = firstNonEmpty(aux.CanonicalURL, aux.Canonical)
|
||||
s.WebSiteName = strings.TrimSpace(aux.WebSiteName)
|
||||
s.Keywords = decodeKeywordList(aux.Keywords, aux.Keyworld, aux.WebKeyworld)
|
||||
s.Normalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize 去掉 seoInfo 空白项,保证模板可直接渲染。
|
||||
func (s *SeoInfo) Normalize() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.Title = strings.TrimSpace(s.Title)
|
||||
s.Description = strings.TrimSpace(s.Description)
|
||||
s.CanonicalURL = strings.TrimSpace(s.CanonicalURL)
|
||||
s.WebSiteName = strings.TrimSpace(s.WebSiteName)
|
||||
if len(s.Keywords) == 0 {
|
||||
return
|
||||
}
|
||||
out := make([]string, 0, len(s.Keywords))
|
||||
seen := make(map[string]struct{}, len(s.Keywords))
|
||||
for _, k := range s.Keywords {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, k)
|
||||
}
|
||||
s.Keywords = out
|
||||
}
|
||||
|
||||
func decodeKeywordList(vals ...json.RawMessage) []string {
|
||||
for _, raw := range vals {
|
||||
if items := parseKeywordsRaw(raw); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseKeywordsRaw(raw json.RawMessage) []string {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
switch raw[0] {
|
||||
case '"':
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return nil
|
||||
}
|
||||
return splitKeywordText(s)
|
||||
case '[':
|
||||
var anyItems []any
|
||||
if err := json.Unmarshal(raw, &anyItems); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(anyItems))
|
||||
for _, it := range anyItems {
|
||||
s, ok := it.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func splitKeywordText(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
s = strings.NewReplacer(",", ",", "、", ",", ";", ",", ";", ",").Replace(s)
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinStrings(items []string, sep string) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, s := range items {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, sep)
|
||||
}
|
||||
|
||||
// MergeSeoInfo 按字段合并 SEO:优先 primary,缺失时回退 fallback。
|
||||
func MergeSeoInfo(primary, fallback SeoInfo) SeoInfo {
|
||||
out := SeoInfo{
|
||||
Title: firstNonEmpty(primary.Title, fallback.Title),
|
||||
Description: firstNonEmpty(primary.Description, fallback.Description),
|
||||
CanonicalURL: firstNonEmpty(primary.CanonicalURL, fallback.CanonicalURL),
|
||||
WebSiteName: firstNonEmpty(primary.WebSiteName, fallback.WebSiteName),
|
||||
}
|
||||
if len(primary.Keywords) > 0 {
|
||||
out.Keywords = append([]string(nil), primary.Keywords...)
|
||||
} else {
|
||||
out.Keywords = append([]string(nil), fallback.Keywords...)
|
||||
}
|
||||
out.Normalize()
|
||||
return out
|
||||
}
|
||||
|
||||
// SEOSelection 描述页面当前选中的筛选维度,用于生成差异化 TDK。
|
||||
type SEOSelection struct {
|
||||
Dimension string
|
||||
Value string
|
||||
}
|
||||
|
||||
// DifferentiateSeoInfo 按基础 TDK - 选项1 - 选项2 的顺序拼接筛选条件。
|
||||
func DifferentiateSeoInfo(base SeoInfo, selections []SEOSelection) SeoInfo {
|
||||
base.Normalize()
|
||||
normalized := make([]SEOSelection, 0, len(selections))
|
||||
seen := make(map[string]struct{}, len(selections))
|
||||
for _, selection := range selections {
|
||||
dimension := strings.TrimSpace(selection.Dimension)
|
||||
value := strings.TrimSpace(selection.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
key := dimension + "\x00" + value
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, SEOSelection{Dimension: dimension, Value: value})
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(normalized))
|
||||
keywords := append([]string(nil), base.Keywords...)
|
||||
for _, selection := range normalized {
|
||||
values = append(values, selection.Value)
|
||||
keywords = append(keywords, selection.Value)
|
||||
}
|
||||
base.Title = joinSEOParts(base.Title, values)
|
||||
base.Description = joinSEOParts(base.Description, values)
|
||||
base.Keywords = keywords
|
||||
base.Normalize()
|
||||
return base
|
||||
}
|
||||
|
||||
func joinSEOParts(base string, values []string) string {
|
||||
parts := make([]string, 0, len(values)+1)
|
||||
if base = strings.TrimSpace(base); base != "" {
|
||||
parts = append(parts, base)
|
||||
}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
parts = append(parts, value)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " - ")
|
||||
}
|
||||
|
||||
// ResolvePlayPageSEO 播放页 SEO:章节详情 seoInfo 优先,短剧详情作字段级回退。
|
||||
func ResolvePlayPageSEO(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) SeoInfo {
|
||||
var chapterSEO, playletSEO SeoInfo
|
||||
if chapter != nil {
|
||||
chapterSEO = chapter.SeoInfo
|
||||
}
|
||||
if playlet != nil {
|
||||
playletSEO = playlet.SeoInfo
|
||||
}
|
||||
return MergeSeoInfo(chapterSEO, playletSEO)
|
||||
}
|
||||
|
||||
// ResolvePlayPageTitle 播放页标题:优先章节 seoInfo.title,否则短剧名 + 集数。
|
||||
func ResolvePlayPageTitle(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) string {
|
||||
seo := ResolvePlayPageSEO(chapter, playlet)
|
||||
if seo.Title != "" {
|
||||
return seo.Title
|
||||
}
|
||||
title := "播放"
|
||||
if playlet != nil && playlet.Title != "" {
|
||||
title = playlet.Title
|
||||
}
|
||||
if chapter != nil && chapter.CurrentEpisode > 0 {
|
||||
return fmt.Sprintf("%s - 第%d集", title, chapter.CurrentEpisode)
|
||||
}
|
||||
return title
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSeoInfo_UnmarshalJSON_SwaggerShape(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"title":"灵气复苏,我成为了万古医圣",
|
||||
"keywords":["灵气复苏,我成为了万古医圣","职场"],
|
||||
"description":"灵气复苏,我成为了万古医圣",
|
||||
"canonicalUrl":"/detail/example-show"
|
||||
}`)
|
||||
var seo SeoInfo
|
||||
if err := json.Unmarshal(raw, &seo); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if seo.Title != "灵气复苏,我成为了万古医圣" {
|
||||
t.Fatalf("title=%q", seo.Title)
|
||||
}
|
||||
if seo.Description != "灵气复苏,我成为了万古医圣" {
|
||||
t.Fatalf("description=%q", seo.Description)
|
||||
}
|
||||
if seo.CanonicalURL != "/detail/example-show" {
|
||||
t.Fatalf("canonicalUrl=%q", seo.CanonicalURL)
|
||||
}
|
||||
want := []string{"灵气复苏,我成为了万古医圣", "职场"}
|
||||
if !reflect.DeepEqual(seo.Keywords, want) {
|
||||
t.Fatalf("keywords=%v", seo.Keywords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeoInfo_UnmarshalJSON_AliasesAndStringKeywords(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"webTitle":"镇龙",
|
||||
"webDescription":"短剧简介",
|
||||
"keyworld":"镇龙,玄幻",
|
||||
"canonical":"/detail/example-show"
|
||||
}`)
|
||||
var seo SeoInfo
|
||||
if err := json.Unmarshal(raw, &seo); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if seo.Title != "镇龙" || seo.Description != "短剧简介" {
|
||||
t.Fatalf("got title=%q desc=%q", seo.Title, seo.Description)
|
||||
}
|
||||
if seo.CanonicalURL != "/detail/example-show" {
|
||||
t.Fatalf("canonicalUrl=%q", seo.CanonicalURL)
|
||||
}
|
||||
want := []string{"镇龙", "玄幻"}
|
||||
if !reflect.DeepEqual(seo.Keywords, want) {
|
||||
t.Fatalf("keywords=%v", seo.Keywords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEnvelope_UnmarshalJSON_ObjectData(t *testing.T) {
|
||||
raw := []byte(`{"code":200,"msg":"ok","data":{"id":10741,"title":"都市逍遥神医","seoInfo":""}}`)
|
||||
var env APIEnvelope[PlayletInfoResp]
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Code != 200 || env.Data.ID != 10741 || env.Data.Title != "都市逍遥神医" {
|
||||
t.Fatalf("env=%+v data=%+v", env, env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEnvelope_UnmarshalJSON_StringEncodedData(t *testing.T) {
|
||||
raw := []byte(`{"code":200,"msg":"ok","data":"{\"id\":10741,\"title\":\"都市逍遥神医\",\"seoInfo\":\"\"}"}`)
|
||||
var env APIEnvelope[PlayletInfoResp]
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Data.ID != 10741 || env.Data.Title != "都市逍遥神医" {
|
||||
t.Fatalf("data=%+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEnvelope_UnmarshalJSON_EmptyStringData(t *testing.T) {
|
||||
raw := []byte(`{"code":200,"msg":"ok","data":""}`)
|
||||
var env APIEnvelope[PlayletInfoResp]
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Data.ID != 0 {
|
||||
t.Fatalf("data=%+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletInfoResp_SeoInfoEmptyString(t *testing.T) {
|
||||
raw := []byte(`{"id":10741,"title":"都市逍遥神医","seoInfo":""}`)
|
||||
var info PlayletInfoResp
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if info.ID != 10741 || info.Title != "都市逍遥神医" {
|
||||
t.Fatalf("info=%+v", info)
|
||||
}
|
||||
if info.SeoInfo.Title != "" {
|
||||
t.Fatalf("seoInfo=%+v", info.SeoInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletInfoResp_KeepsSeoInfo(t *testing.T) {
|
||||
raw := []byte(`{"id":10619,"slug":"example-show","title":"剧名","seoInfo":{"title":"SEO标题","description":"SEO描述","keywords":["a","b"],"canonicalUrl":"/detail/example-show"}}`)
|
||||
var info PlayletInfoResp
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if info.SeoInfo.Title != "SEO标题" || info.SeoInfo.CanonicalURL != "/detail/example-show" {
|
||||
t.Fatalf("seoInfo=%+v", info.SeoInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletChapterInfoResp_KeepsSeoInfo(t *testing.T) {
|
||||
raw := []byte(`{"id":12743,"playletId":10619,"currentEpisode":1,"title":"1","seoInfo":{"title":"剧名 1","description":"剧名","keywords":["剧名","短剧"],"canonicalUrl":"/play/example-show/12743"}}`)
|
||||
var info PlayletChapterInfoResp
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if info.SeoInfo.Title != "剧名 1" || info.SeoInfo.CanonicalURL != "/play/example-show/12743" {
|
||||
t.Fatalf("seoInfo=%+v", info.SeoInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlayPageSEO_PrefersChapter(t *testing.T) {
|
||||
chapter := &PlayletChapterInfoResp{
|
||||
CurrentEpisode: 1,
|
||||
SeoInfo: SeoInfo{
|
||||
Title: "章节标题",
|
||||
Description: "章节描述",
|
||||
Keywords: []string{"章节"},
|
||||
CanonicalURL: "/play/example-show/2",
|
||||
},
|
||||
}
|
||||
playlet := &PlayletInfoResp{
|
||||
Title: "短剧名",
|
||||
SeoInfo: SeoInfo{
|
||||
Title: "短剧SEO",
|
||||
Description: "短剧描述",
|
||||
Keywords: []string{"短剧"},
|
||||
CanonicalURL: "/detail/example-show",
|
||||
},
|
||||
}
|
||||
seo := ResolvePlayPageSEO(chapter, playlet)
|
||||
if seo.Title != "章节标题" || seo.CanonicalURL != "/play/example-show/2" {
|
||||
t.Fatalf("seo=%+v", seo)
|
||||
}
|
||||
if !reflect.DeepEqual(seo.Keywords, []string{"章节"}) {
|
||||
t.Fatalf("keywords=%v", seo.Keywords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlayPageTitle_UsesChapterSeoTitle(t *testing.T) {
|
||||
chapter := &PlayletChapterInfoResp{
|
||||
CurrentEpisode: 1,
|
||||
SeoInfo: SeoInfo{Title: "灵气复苏,我成为了万古医圣 1"},
|
||||
}
|
||||
playlet := &PlayletInfoResp{Title: "灵气复苏,我成为了万古医圣"}
|
||||
if got := ResolvePlayPageTitle(chapter, playlet); got != "灵气复苏,我成为了万古医圣 1" {
|
||||
t.Fatalf("title=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
seriesOperateType = 2
|
||||
seriesPageSize = 25
|
||||
seriesFilterLatest = 1
|
||||
seriesFilterHot = 2
|
||||
seriesFilterOrigin = 3
|
||||
)
|
||||
|
||||
var seriesFilters = []struct {
|
||||
Type int
|
||||
Slug string
|
||||
Label string
|
||||
}{
|
||||
{Type: seriesFilterLatest, Slug: "latest", Label: "最新更新"},
|
||||
{Type: seriesFilterHot, Slug: "popular", Label: "当前热播"},
|
||||
{Type: seriesFilterOrigin, Slug: "original", Label: "独家原创"},
|
||||
}
|
||||
|
||||
// ParseSeriesQuery 将语义化筛选路由转换为 Swagger 的 operateType/filterType 参数。
|
||||
func ParseSeriesQuery(filterSlug, pageStr string) (OperateListQuery, bool) {
|
||||
filterType, ok := seriesFilterType(filterSlug)
|
||||
if !ok {
|
||||
return OperateListQuery{}, false
|
||||
}
|
||||
q := OperateListQuery{
|
||||
OperateType: seriesOperateType,
|
||||
FilterType: filterType,
|
||||
PageSize: seriesPageSize,
|
||||
}
|
||||
if page, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = page
|
||||
}
|
||||
q.Normalize()
|
||||
return q, true
|
||||
}
|
||||
|
||||
func seriesFilterType(slug string) (int, bool) {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug == "" || slug == "latest" {
|
||||
return seriesFilterLatest, true
|
||||
}
|
||||
for _, filter := range seriesFilters {
|
||||
if filter.Slug == slug {
|
||||
return filter.Type, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func seriesFilterSlug(filterType int) string {
|
||||
for _, filter := range seriesFilters {
|
||||
if filter.Type == filterType {
|
||||
return filter.Slug
|
||||
}
|
||||
}
|
||||
return "latest"
|
||||
}
|
||||
|
||||
func seriesPageURL(filterType, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/series"
|
||||
if filterType != seriesFilterLatest {
|
||||
path += "/" + seriesFilterSlug(filterType)
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
// SeriesCanonicalPath 返回 AI 成人剧集页唯一规范路径。
|
||||
func SeriesCanonicalPath(q OperateListQuery) string {
|
||||
q.Normalize()
|
||||
return seriesPageURL(q.FilterType, q.PageNum)
|
||||
}
|
||||
|
||||
// BuildSeriesFilterTabs 构建 Figma 中的三个运营筛选项。
|
||||
func BuildSeriesFilterTabs(q OperateListQuery) []FilterChipVM {
|
||||
q.Normalize()
|
||||
out := make([]FilterChipVM, 0, len(seriesFilters))
|
||||
for _, filter := range seriesFilters {
|
||||
out = append(out, FilterChipVM{
|
||||
Label: filter.Label,
|
||||
URL: seriesPageURL(filter.Type, 1),
|
||||
Active: q.FilterType == filter.Type,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func SeriesSectionTitle(filterType int) string {
|
||||
for _, filter := range seriesFilters {
|
||||
if filter.Type == filterType {
|
||||
return filter.Label + "的AI成人剧集"
|
||||
}
|
||||
}
|
||||
return "最新更新的AI成人剧集"
|
||||
}
|
||||
|
||||
func BuildSeriesPagination(q OperateListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(page int) string {
|
||||
return seriesPageURL(q.FilterType, page)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// SeriesPathFromLegacyList 将原 /list/ai-series 排序地址映射到新页面。
|
||||
func SeriesPathFromLegacyList(sortOrder, pageNum int) string {
|
||||
filterType := seriesFilterLatest
|
||||
if sortOrder == sortHeat {
|
||||
filterType = seriesFilterHot
|
||||
}
|
||||
return seriesPageURL(filterType, pageNum)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package playletser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseSeriesQueryMapsSwaggerOperateFilters(t *testing.T) {
|
||||
tests := []struct {
|
||||
slug string
|
||||
wantFilter int
|
||||
}{
|
||||
{slug: "", wantFilter: seriesFilterLatest},
|
||||
{slug: "latest", wantFilter: seriesFilterLatest},
|
||||
{slug: "popular", wantFilter: seriesFilterHot},
|
||||
{slug: "original", wantFilter: seriesFilterOrigin},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
q, ok := ParseSeriesQuery(tt.slug, "2")
|
||||
if !ok {
|
||||
t.Fatalf("ParseSeriesQuery(%q) rejected valid filter", tt.slug)
|
||||
}
|
||||
if q.OperateType != 2 || q.FilterType != tt.wantFilter || q.PageNum != 2 || q.PageSize != 25 {
|
||||
t.Fatalf("ParseSeriesQuery(%q) = %#v", tt.slug, q)
|
||||
}
|
||||
}
|
||||
if _, ok := ParseSeriesQuery("unknown", "1"); ok {
|
||||
t.Fatal("unknown filter should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesCanonicalPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
filter int
|
||||
page int
|
||||
want string
|
||||
}{
|
||||
{filter: seriesFilterLatest, page: 1, want: "/series"},
|
||||
{filter: seriesFilterLatest, page: 3, want: "/series/page/3"},
|
||||
{filter: seriesFilterHot, page: 1, want: "/series/popular"},
|
||||
{filter: seriesFilterOrigin, page: 4, want: "/series/original/page/4"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := seriesPageURL(tt.filter, tt.page)
|
||||
if got != tt.want {
|
||||
t.Fatalf("seriesPageURL(%d, %d) = %q, want %q", tt.filter, tt.page, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSeriesFilterTabs(t *testing.T) {
|
||||
tabs := BuildSeriesFilterTabs(OperateListQuery{OperateType: 2, FilterType: 2, PageNum: 1, PageSize: 25})
|
||||
if len(tabs) != 3 || tabs[0].URL != "/series" || tabs[1].URL != "/series/popular" || !tabs[1].Active || tabs[2].URL != "/series/original" {
|
||||
t.Fatalf("unexpected series tabs: %#v", tabs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
shortPlayOperateType = 1
|
||||
shortPlayPageSize = 25
|
||||
)
|
||||
|
||||
var shortPlayFilters = []struct {
|
||||
Type int
|
||||
Slug string
|
||||
Label string
|
||||
}{
|
||||
{Type: 1, Slug: "latest", Label: "最新更新"},
|
||||
{Type: 2, Slug: "popular", Label: "当前热播"},
|
||||
{Type: 3, Slug: "original", Label: "独家原创"},
|
||||
}
|
||||
|
||||
// ParseShortPlayQuery 将短剧页路由转换为 operate/list 的 Swagger 参数。
|
||||
func ParseShortPlayQuery(filter, page string) (OperateListQuery, bool) {
|
||||
filterType, ok := shortPlayFilterType(filter)
|
||||
if !ok {
|
||||
return OperateListQuery{}, false
|
||||
}
|
||||
q := OperateListQuery{
|
||||
OperateType: shortPlayOperateType,
|
||||
FilterType: filterType,
|
||||
PageSize: shortPlayPageSize,
|
||||
}
|
||||
if pageNum, err := strconv.Atoi(strings.TrimSpace(page)); err == nil {
|
||||
q.PageNum = pageNum
|
||||
}
|
||||
q.Normalize()
|
||||
return q, true
|
||||
}
|
||||
|
||||
func shortPlayFilterType(filter string) (int, bool) {
|
||||
filter = strings.TrimSpace(filter)
|
||||
switch filter {
|
||||
case "", "latest", "2":
|
||||
return 1, true
|
||||
case "popular", "1":
|
||||
return 2, true
|
||||
case "original", "3":
|
||||
return 3, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func shortPlayFilterSlug(filterType int) string {
|
||||
for _, filter := range shortPlayFilters {
|
||||
if filter.Type == filterType {
|
||||
return filter.Slug
|
||||
}
|
||||
}
|
||||
return "latest"
|
||||
}
|
||||
|
||||
func shortPlayPageURL(filterType, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/list/ai-short-play"
|
||||
if filterType != 1 || pageNum > 1 {
|
||||
path += "/" + shortPlayFilterSlug(filterType)
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
func ShortPlayCanonicalPath(q OperateListQuery) string {
|
||||
q.Normalize()
|
||||
return shortPlayPageURL(q.FilterType, q.PageNum)
|
||||
}
|
||||
|
||||
func BuildShortPlayFilterTabs(q OperateListQuery) []FilterChipVM {
|
||||
q.Normalize()
|
||||
out := make([]FilterChipVM, 0, len(shortPlayFilters))
|
||||
for _, filter := range shortPlayFilters {
|
||||
out = append(out, FilterChipVM{
|
||||
Label: filter.Label,
|
||||
URL: shortPlayPageURL(filter.Type, 1),
|
||||
Active: q.FilterType == filter.Type,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ShortPlaySectionTitle(filterType int) string {
|
||||
for _, filter := range shortPlayFilters {
|
||||
if filter.Type == filterType {
|
||||
return filter.Label + "的AI成人短剧"
|
||||
}
|
||||
}
|
||||
return "最新更新的AI成人短剧"
|
||||
}
|
||||
|
||||
func BuildShortPlayPagination(q OperateListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(page int) string {
|
||||
return shortPlayPageURL(q.FilterType, page)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHomeResponseDecodesDocumentedFields(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"announcements":[{"id":1,"title":"公告","content":"内容"}],
|
||||
"homeRecommendThemes":[{"id":2,"name":"都市","slug":"urban","playletCount":8}],
|
||||
"homeBannerData":[{"id":3,"name":"轮播A","image":"https://cdn.example.com/a.png","verticalImage":"https://cdn.example.com/a-v.png","url":"https://example.com","rank":1,"status":true}]
|
||||
}`)
|
||||
var got HomePlayletResponse
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Announcements) != 1 || got.Announcements[0].Content != "内容" {
|
||||
t.Fatalf("announcements not decoded: %#v", got.Announcements)
|
||||
}
|
||||
if len(got.HomeRecommendThemes) != 1 || got.HomeRecommendThemes[0].Slug != "urban" {
|
||||
t.Fatalf("recommended themes not decoded: %#v", got.HomeRecommendThemes)
|
||||
}
|
||||
if len(got.HomeBannerData) != 1 || got.HomeBannerData[0].Name != "轮播A" || got.HomeBannerData[0].Image == "" || got.HomeBannerData[0].VerticalImage == "" || !got.HomeBannerData[0].Status {
|
||||
t.Fatalf("banner additions not decoded: %#v", got.HomeBannerData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperateListQueryNormalize(t *testing.T) {
|
||||
q := OperateListQuery{OperateType: 9, FilterType: -1, PageNum: 0, PageSize: 999}
|
||||
q.Normalize()
|
||||
if q.OperateType != 1 || q.FilterType != 1 || q.PageNum != 1 || q.PageSize != 100 {
|
||||
t.Fatalf("unexpected normalized query: %#v", q)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
tagListPath = "/api/app/playlet/tag/list"
|
||||
defaultTagSize = 30
|
||||
)
|
||||
|
||||
type TagListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
TagID int64
|
||||
TagSlug string
|
||||
}
|
||||
|
||||
func (q *TagListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultTagSize
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
if q.TagID < 0 {
|
||||
q.TagID = 0
|
||||
}
|
||||
}
|
||||
|
||||
func FetchTagList(c *gin.Context, q TagListQuery) (*PlayletVideoResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
if slug := strings.TrimSpace(q.TagSlug); slug != "" {
|
||||
params.Set("tagSlug", slug)
|
||||
} else if q.TagID > 0 {
|
||||
params.Set("tagId", strconv.FormatInt(q.TagID, 10))
|
||||
}
|
||||
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetPublicJSON(c, tagListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet tag list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
func FetchTagListOrEmpty(c *gin.Context, q TagListQuery) *PlayletVideoResp {
|
||||
data, err := FetchTagList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet tag list failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func tagListPageURL(tagID int64, tagSlug string, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/tag"
|
||||
if segment := pathSegment(tagSlug, tagID); segment != "" {
|
||||
path += "/" + segment
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
func tagListURL(item CommonData) string {
|
||||
return cardTag(item).URL
|
||||
}
|
||||
|
||||
func tagListURLByKey(slug string, id int64) string {
|
||||
if segment := pathSegment(slug, id); segment != "" {
|
||||
return "/tag/" + segment + "/"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func themeListURLByKey(slug string, id int64) string {
|
||||
if segment := pathSegment(slug, id); segment != "" {
|
||||
return "/list/" + segment + "/"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type NamedTag struct {
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
func cardTag(item CommonData) NamedTag {
|
||||
tags := cardTags(item)
|
||||
if len(tags) > 0 {
|
||||
return tags[0]
|
||||
}
|
||||
return NamedTag{}
|
||||
}
|
||||
|
||||
// cardTags returns at most three distinct labels for a poster card. Regular
|
||||
// tags keep their API order, followed by theme labels when space remains.
|
||||
func cardTags(item CommonData) []NamedTag {
|
||||
out := make([]NamedTag, 0, 3)
|
||||
seen := make(map[string]struct{}, 3)
|
||||
appendUnique := func(tags []NamedTag) {
|
||||
for _, tag := range tags {
|
||||
name := strings.TrimSpace(tag.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
tag.Name = name
|
||||
out = append(out, tag)
|
||||
seen[key] = struct{}{}
|
||||
if len(out) == 3 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendUnique(namedTags(item.TagIDs, item.TagNames, item.TagSlugs))
|
||||
if len(out) < 3 {
|
||||
appendUnique(namedThemeTags(item.ThemeIDs, item.ThemeNames, item.ThemeSlugs))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func namedTags(ids []int64, names, slugs []string) []NamedTag {
|
||||
out := make([]NamedTag, 0, len(names))
|
||||
for i, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
item := NamedTag{Name: name}
|
||||
id := int64(0)
|
||||
slug := ""
|
||||
if i < len(ids) {
|
||||
id = ids[i]
|
||||
}
|
||||
if i < len(slugs) {
|
||||
slug = slugs[i]
|
||||
}
|
||||
item.URL = tagListURLByKey(slug, id)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func namedThemeTags(ids []int64, names, slugs []string) []NamedTag {
|
||||
out := make([]NamedTag, 0, len(names))
|
||||
for i, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
item := NamedTag{Name: name}
|
||||
if i < len(ids) {
|
||||
slug := ""
|
||||
if i < len(slugs) {
|
||||
slug = slugs[i]
|
||||
}
|
||||
item.URL = themeListURLByKey(slug, ids[i])
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TagListCanonicalPath(q TagListQuery) string {
|
||||
q.Normalize()
|
||||
return tagListPageURL(q.TagID, q.TagSlug, q.PageNum)
|
||||
}
|
||||
|
||||
func BuildTagListPagination(q TagListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return tagListPageURL(q.TagID, q.TagSlug, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
func ParseTagListQuery(pageStr string) TagListQuery {
|
||||
q := TagListQuery{PageSize: defaultTagSize}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
func FindTagNameBySlug(list []CommonData, slug string) string {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug == "" {
|
||||
return ""
|
||||
}
|
||||
for _, item := range list {
|
||||
for i, itemSlug := range item.TagSlugs {
|
||||
if strings.TrimSpace(itemSlug) == slug && i < len(item.TagNames) {
|
||||
return strings.TrimSpace(item.TagNames[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func FindTagName(list []CommonData, tagID int64) string {
|
||||
if tagID <= 0 {
|
||||
return ""
|
||||
}
|
||||
for _, item := range list {
|
||||
for i, id := range item.TagIDs {
|
||||
if id == tagID && i < len(item.TagNames) {
|
||||
return strings.TrimSpace(item.TagNames[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func FindTagSlug(list []CommonData, tagID int64) string {
|
||||
if tagID <= 0 {
|
||||
return ""
|
||||
}
|
||||
for _, item := range list {
|
||||
for i, id := range item.TagIDs {
|
||||
if id == tagID && i < len(item.TagSlugs) {
|
||||
return strings.TrimSpace(item.TagSlugs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TagListPageTitle(tagName string, pageNum int, seo SeoInfo) string {
|
||||
if title := strings.TrimSpace(seo.Title); title != "" {
|
||||
return title
|
||||
}
|
||||
base := "标签短剧"
|
||||
if tagName != "" {
|
||||
base = tagName + "短剧"
|
||||
}
|
||||
return base + PageTitleSuffix(pageNum)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user