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
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)
}