Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-22 14:47:17 +07:00
co-authored by Claude Opus 5.5
commit 3639d2184a
574 changed files with 61690 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.DS_Store
~$*
outputs/
.codex/
app/docs/
*.tar.gz
+36
View File
@@ -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
View File
@@ -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
View File
+23
View File
@@ -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)
}
+72
View File
@@ -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)
}
+45
View File
@@ -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,
})
}
+54
View File
@@ -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,
})
}
+32
View File
@@ -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),
})
}
+32
View File
@@ -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)
}
+198
View File
@@ -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,
})
}
+28
View File
@@ -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: "只从本页、站内导航或黄果社区提示进入,不要从陌生弹窗、未知短链跳转。"},
},
})
}
+30
View File
@@ -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,
})
}
+23
View File
@@ -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)
}
+61
View File
@@ -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)
}
+83
View File
@@ -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)
}
+15
View File
@@ -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": "官方导航",
})
}
+27
View File
@@ -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
}
+26
View File
@@ -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)
}
})
}
}
+70
View File
@@ -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)
}
+54
View File
@@ -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
}
+72
View File
@@ -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,
})
}
+83
View File
@@ -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)
}
+250
View File
@@ -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 ""
}
+64
View File
@@ -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)
}
}
+90
View File
@@ -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)
}
}
+43
View File
@@ -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
}
+45
View File
@@ -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)
}
+38
View File
@@ -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)
}
+46
View File
@@ -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)
}
+12
View File
@@ -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)
}
+75
View File
@@ -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)
}
+39
View File
@@ -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)
}
+34
View File
@@ -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)
}
+26
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
serverAddr: "https://webhgdjapi.mfcg.me"
listenAddr: "25119"
dhListenAddr: "26812"
runMod: "dev"
tsAuthKey: "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN"
+1
View File
@@ -0,0 +1 @@
—Џ€И’ љ БCЏ„
+16
View File
@@ -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
View File
+55
View File
@@ -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)
}
View File
@@ -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")
}
}
+16
View File
@@ -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)
}
+276
View File
@@ -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)
}
}
+139
View File
@@ -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)
}
}
+183
View File
@@ -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
}
+68
View File
@@ -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()
}
+90
View File
@@ -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
}
+161
View File
@@ -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")
}
+37
View File
@@ -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
}
+38
View File
@@ -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"`
}
+83
View File
@@ -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
}
+113
View File
@@ -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",
}
}
+137
View File
@@ -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 ""
}
+28
View File
@@ -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
}
+26
View File
@@ -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"`
}
+145
View File
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
package commentser
import "html/template"
// TemplateFuncs 评论展示辅助。
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"formatCommentTime": FormatCreatedAt,
"commentAvatar": AvatarOrDefault,
}
}
+71
View File
@@ -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 // 最新
)
+51
View File
@@ -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
}
+88
View File
@@ -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}
}
+40
View File
@@ -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)
}
}
+53
View File
@@ -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
}
+104
View File
@@ -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 + "/"
}
+19
View File
@@ -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)
}
}
}
+94
View File
@@ -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
}
+162
View File
@@ -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"`
}
+157
View File
@@ -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 ""
}
+91
View File
@@ -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)
}
}
+30
View File
@@ -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
}
+23
View File
@@ -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"`
}
+81
View File
@@ -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)
}
+154
View File
@@ -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
}
+133
View File
@@ -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
}
+69
View File
@@ -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
}
+110
View File
@@ -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)
}
}
}
+33
View File
@@ -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
}
+129
View File
@@ -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)
}
}
+34
View File
@@ -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
}
+31
View File
@@ -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)
}
}
+136
View File
@@ -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
}
+250
View File
@@ -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
}
+81
View File
@@ -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
}
+79
View File
@@ -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
}
+88
View File
@@ -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
}
+23
View File
@@ -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)
}
})
}
}
+83
View File
@@ -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
}
+64
View File
@@ -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
}
+124
View File
@@ -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
}
+312
View File
@@ -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
}
+164
View File
@@ -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)
}
}
+123
View File
@@ -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)
}
}
+110
View File
@@ -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)
}
}
+72
View File
@@ -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
}
+199
View File
@@ -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