Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-09-29 11:47:47 +07:00
co-authored by Claude Opus 5.5
commit 0fbdba5ccf
622 changed files with 72105 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
package web
import "github.com/gin-gonic/gin"
// About renders the static About page.
func About(c *gin.Context) {
HTMLOK(c, "about.html", gin.H{})
}
+52
View File
@@ -0,0 +1,52 @@
package web
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
const maxAccountPage = 10000
type accountPage struct {
Current int
Total int
Count int64
PrevURL string
NextURL string
}
func accountPageNum(c *gin.Context) (int, bool) {
if c.Request.URL.Query().Has("page") {
return 0, false
}
raw := strings.TrimSpace(c.Param("page"))
if raw == "" {
return 1, true
}
page, err := strconv.Atoi(raw)
if err != nil || page < 1 || page > maxAccountPage {
return 0, false
}
return page, true
}
func makeAccountPage(path string, page, size int, count int64) accountPage {
total := int((count + int64(size) - 1) / int64(size))
p := accountPage{Current: page, Total: total, Count: count}
link := func(n int) string {
path = strings.TrimRight(path, "/")
if n <= 1 {
return path
}
return path + "/page/" + strconv.Itoa(n)
}
if page > 1 {
p.PrevURL = link(page - 1)
}
if page < total {
p.NextURL = link(page + 1)
}
return p
}
+290
View File
@@ -0,0 +1,290 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/authser"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
type registerBody struct {
AccountNo string `json:"accountNo"`
Email string `json:"email"`
Password string `json:"password"`
AgentInviteCode string `json:"agentInviteCode"`
DevID string `json:"devID"`
Channel string `json:"channel"`
}
type loginBody struct {
Account string `json:"account"`
Password string `json:"password"`
DevID string `json:"devID"`
Channel string `json:"channel"`
}
// Register 代理上游注册(POST /api/app/auth/register),成功后写入登录 cookie。
// 请求头带游客 Authorization 时升级该游客;邮箱缺省时用账号生成占位邮箱。
func Register(c *gin.Context) {
var body registerBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.AccountNo = strings.TrimSpace(body.AccountNo)
body.Email = strings.TrimSpace(body.Email)
body.AgentInviteCode = strings.TrimSpace(body.AgentInviteCode)
body.DevID = strings.TrimSpace(body.DevID)
body.Channel = authser.ResolveChannel(c, body.Channel)
if body.AccountNo == "" || body.Password == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请填写账号和密码",
"data": nil,
})
return
}
// 上游 registerReq 要求 email;前端无邮箱时用账号生成唯一占位邮箱。
if body.Email == "" {
body.Email = body.AccountNo + "@hgdj.local"
}
env, user, err := authser.Register(c, authser.RegisterReq{
AccountNo: body.AccountNo,
Email: body.Email,
Password: body.Password,
AgentInviteCode: body.AgentInviteCode,
DevID: body.DevID,
Channel: body.Channel,
})
if err != nil {
log.Error("register upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "register failed",
"tip": "注册失败,请稍后重试",
"data": nil,
})
return
}
if env == nil || env.Code != 200 || user == nil {
tip := "注册失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
if t := strings.TrimSpace(env.Tip); t != "" {
tip = t
} else if m := strings.TrimSpace(env.Msg); m != "" {
tip = m
}
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmptyAuth(env.Msg, "success"),
"tip": firstNonEmptyAuth(env.Tip, "注册成功"),
"data": user,
})
}
// Login 代理上游登录(POST /api/app/auth/login),成功后写入登录 cookie。
func Login(c *gin.Context) {
var body loginBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.Account = strings.TrimSpace(body.Account)
body.DevID = strings.TrimSpace(body.DevID)
body.Channel = authser.ResolveChannel(c, body.Channel)
if body.Account == "" || body.Password == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请填写账号和密码",
"data": nil,
})
return
}
env, user, err := authser.Login(c, authser.LoginReq{
Account: body.Account,
Password: body.Password,
DevID: body.DevID,
Channel: body.Channel,
})
if err != nil {
log.Error("login upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "login failed",
"tip": "登录失败,请稍后重试",
"data": nil,
})
return
}
if env == nil || env.Code != 200 || user == nil {
tip := "登录失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
if t := strings.TrimSpace(env.Tip); t != "" {
tip = t
} else if m := strings.TrimSpace(env.Msg); m != "" {
tip = m
}
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmptyAuth(env.Msg, "success"),
"tip": firstNonEmptyAuth(env.Tip, "登录成功"),
"data": user,
})
}
// Guest 代理上游游客登录,成功后写入登录 cookie。
// 对应 POST /api/app/auth/guest;需传 devID,上游要求 X-User-Agent / X-Real-IP(由 BFF 组装)。
func Guest(c *gin.Context) {
var body struct {
DevID string `json:"devID"`
DeviceID string `json:"deviceId"`
Mac string `json:"mac"`
Channel string `json:"channel"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.DevID = strings.TrimSpace(body.DevID)
body.DeviceID = strings.TrimSpace(body.DeviceID)
body.Mac = strings.TrimSpace(body.Mac)
body.Channel = authser.ResolveChannel(c, body.Channel)
if body.DevID == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少设备标识",
"data": nil,
})
return
}
if body.DeviceID == "" {
body.DeviceID = body.DevID
}
env, user, err := authser.GuestLogin(c, authser.GuestLoginReq{
DevID: body.DevID,
DeviceID: body.DeviceID,
Mac: body.Mac,
Channel: body.Channel,
})
if err != nil {
log.Error("guest login upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "guest login failed",
"tip": "游客登录失败,请稍后重试",
"data": nil,
})
return
}
if env == nil || env.Code != 200 || user == nil {
tip := "游客登录失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
if t := strings.TrimSpace(env.Tip); t != "" {
tip = t
} else if m := strings.TrimSpace(env.Msg); m != "" {
tip = m
}
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmptyAuth(env.Msg, "success"),
"tip": firstNonEmptyAuth(env.Tip, "游客登录成功"),
"data": user,
})
}
// Logout 清除登录 cookie,并标记跳过紧接着的一次自动游客登录。
func Logout(c *gin.Context) {
authser.ClearAuthCookies(c)
authser.MarkGuestSkipAuto(c)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "success",
"tip": "已退出登录",
"data": nil,
})
}
func firstNonEmptyAuth(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+120
View File
@@ -0,0 +1,120 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// ChapterLikeSet BFF:代理 POST /api/app/playlet/chapter/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_like_set
func ChapterLikeSet(c *gin.Context) {
var body struct {
ChapterID int64 `json:"chapterId"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId/isLike required",
"tip": "缺少剧集 ID 或点赞状态",
"data": nil,
})
return
}
env, err := playletser.ChapterLikeSet(c, playletser.PlayletChapterLikeSetReq{
ChapterID: body.ChapterID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
// ChapterCollectSet BFF:代理 POST /api/app/playlet/chapter/collect/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_collect_set
func ChapterCollectSet(c *gin.Context) {
var body struct {
ChapterID int64 `json:"chapterId"`
IsCollect *bool `json:"isCollect"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId/isCollect required",
"tip": "缺少剧集 ID 或收藏状态",
"data": nil,
})
return
}
env, err := playletser.ChapterCollectSet(c, playletser.PlayletChapterCollectSetReq{
ChapterID: body.ChapterID,
IsCollect: *body.IsCollect,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// ChapterUnlock BFF:代理 POST /api/app/playlet/chapter/unlock。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_unlock
func ChapterUnlock(c *gin.Context) {
var body struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.PlayletID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId/playletId required",
"tip": "缺少剧集或短剧 ID",
"data": nil,
})
return
}
env, err := playletser.ChapterUnlock(c, playletser.PlayletChapterUnlockReq{
ChapterID: body.ChapterID,
PlayletID: body.PlayletID,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "解锁失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "解锁失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+75
View File
@@ -0,0 +1,75 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// ChapterWatchReport BFF:代理 POST /api/app/playlet/chapter/watch/report。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_watch_report
func ChapterWatchReport(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录",
"data": nil,
})
return
}
var body struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId required",
"tip": "缺少剧集 ID",
"data": nil,
})
return
}
env, err := playletser.ChapterWatchReport(c, playletser.PlayletChapterWatchReportReq{
ChapterID: body.ChapterID,
PlayletID: body.PlayletID,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "上报失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "上报失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+155
View File
@@ -0,0 +1,155 @@
package web
import (
"net/http"
"strconv"
"strings"
"videoweb/app/service/commentser"
"github.com/gin-gonic/gin"
)
// CommentList BFF:代理 GET /api/app/comment/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/get_api_app_comment_list
func CommentList(c *gin.Context) {
objectID, _ := strconv.ParseInt(c.Query("objectId"), 10, 64)
objectType, _ := strconv.Atoi(c.Query("objectType"))
pageNum, _ := strconv.Atoi(c.Query("pageNum"))
pageSize, _ := strconv.Atoi(c.Query("pageSize"))
sortOrder, _ := strconv.Atoi(c.Query("sortOrder"))
if objectID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "objectId required",
"tip": "缺少评论对象",
"data": nil,
})
return
}
data := commentser.FetchListOrEmpty(c, commentser.ListQuery{
ObjectID: objectID,
ObjectType: objectType,
PageNum: pageNum,
PageSize: pageSize,
SortOrder: sortOrder,
})
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "ok",
"tip": "成功",
"data": data,
})
}
// CommentSend BFF:代理 POST /api/app/comment/send。
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/post_api_app_comment_send
func CommentSend(c *gin.Context) {
var req commentser.CommentSendReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
env, err := commentser.Send(c, req)
if err != nil {
tip := "发布失败"
msg := err.Error()
switch {
case strings.Contains(msg, "empty text"):
tip = "请输入评论内容"
case strings.Contains(msg, "too long"):
tip = "评论不能超过 200 字"
case strings.Contains(msg, "invalid objectId"):
tip = "缺少评论对象"
}
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "发布失败",
"data": nil,
})
return
}
tip := env.Tip
if tip == "" {
tip = env.Msg
}
// 上游参数错误兜底提示(例如 objectType 不被支持)
if env.Code == 1001 && (tip == "" || tip == "参数错误") {
tip = "评论参数错误,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": tip,
"data": env.Data,
})
}
// CommentLikeSet BFF:代理 POST /api/app/comment/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/post_api_app_comment_like_set
func CommentLikeSet(c *gin.Context) {
var body struct {
CommentID int64 `json:"commentId"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.CommentID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "commentId/isLike required",
"tip": "缺少评论 ID 或点赞状态",
"data": nil,
})
return
}
env, err := commentser.LikeSet(c, commentser.CommentLikeSetReq{
CommentID: body.CommentID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+8
View File
@@ -0,0 +1,8 @@
package web
import "github.com/gin-gonic/gin"
// Compliance2257 renders the static 18 U.S.C. 2257 compliance statement page.
func Compliance2257(c *gin.Context) {
HTMLOK(c, "2257.html", gin.H{})
}
+8
View File
@@ -0,0 +1,8 @@
package web
import "github.com/gin-gonic/gin"
// DMCA renders the static DMCA copyright notice page.
func DMCA(c *gin.Context) {
HTMLOK(c, "dmca.html", gin.H{})
}
+338
View File
@@ -0,0 +1,338 @@
package web
import (
"fmt"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type favoriteWork struct {
ID int64
Title string
Genre string
DesktopMeta string
Episodes string
Badge string
Image string
Kind string
URL string
DramaID int64
Episode int
VideoID int64
TotalEpisode int
}
// Favorites 我的收藏:短剧 / 剧集(视频) / 章节收藏。
// 文档:
// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_collect_list
// http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_collect_list
func Favorites(c *gin.Context) {
filter, page, validRoute := favoritesRoute(c)
if !validRoute {
NotFound(c)
return
}
data := gin.H{"title": "我的收藏 - 黄果短剧", "navActive": "favorites", "accountTab": "favorites"}
responses := gin.H{}
loadAccountSummary(c, data, responses)
status := 0
switch filter {
case "short", "chapter", "series":
case "following":
status = 1
case "finished":
status = 2
default:
filter = "all"
}
data["filter"] = filter
pageSize := 12
query := playletser.CollectListQuery{PageNum: page, PageSize: pageSize, WatchStatus: status}
if useragent.TokenFromRequest(c.Request) == "" {
responses["playlet/collect/list"] = gin.H{"error": "暂无登录会话"}
responses["video/collect/list"] = gin.H{"error": "暂无登录会话"}
data["favoritesResponse"] = responses["playlet/collect/list"]
data["playletCollectResponse"] = responses["playlet/collect/list"]
data["videoCollectResponse"] = responses["video/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
return
}
if filter == "chapter" {
list, code, err := playletser.FetchChapterCollectList(c, query)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, _, err = playletser.FetchChapterCollectList(c, query)
}
if err != nil {
log.Error("fetch chapter favorites page failed", log.E(err))
responses["playlet/chapter/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
data["favoritesUnavailable"] = true
} else if list != nil {
responses["playlet/chapter/collect/list"] = gin.H{"code": 200, "data": list}
data["paging"] = makeFavoritesPage(query.PageNum, query.PageSize, list.TotalCount, filter)
data["favorites"] = chapterFavoriteWorks(list.List)
} else {
responses["playlet/chapter/collect/list"] = gin.H{"code": code, "data": list}
}
data["favoritesResponse"] = responses["playlet/chapter/collect/list"]
data["playletCollectResponse"] = responses["playlet/chapter/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
return
}
if filter == "series" {
loadVideoFavorites(c, data, responses, page, pageSize)
data["favoritesResponse"] = responses["video/collect/list"]
data["playletCollectResponse"] = gin.H{}
data["videoCollectResponse"] = responses["video/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
return
}
var works []favoriteWork
var totalCount int64
playletFailed := false
videoFailed := false
if filter == "all" || filter == "short" || filter == "following" || filter == "finished" {
list, code, err := playletser.FetchCollectList(c, query)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, code, err = playletser.FetchCollectList(c, query)
}
if err == nil && list != nil {
responses["playlet/collect/list"] = gin.H{"code": 200, "data": list}
works = append(works, playletFavoriteWorks(list.List)...)
totalCount += list.TotalCount
} else if err != nil {
responses["playlet/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
log.Error("fetch favorites page failed", log.E(err))
playletFailed = true
} else {
responses["playlet/collect/list"] = gin.H{"code": code, "data": list}
}
}
if filter == "all" {
videoQuery := videoser.CollectListQuery{PageNum: page, PageSize: pageSize}
videoList, videoCode, videoErr := videoser.FetchCollectList(c, videoQuery)
if retry, _ := authser.OnSessionExpired(c, videoCode); retry {
videoList, videoCode, videoErr = videoser.FetchCollectList(c, videoQuery)
}
if videoErr == nil && videoList != nil {
responses["video/collect/list"] = gin.H{"code": 200, "data": videoList}
works = append(works, videoFavoriteWorks(videoList.List)...)
totalCount += videoList.TotalCount
} else if videoErr != nil {
responses["video/collect/list"] = gin.H{"error": "加载失败", "msg": videoErr.Error()}
log.Error("fetch video favorites page failed", log.E(videoErr))
videoFailed = true
} else {
responses["video/collect/list"] = gin.H{"code": videoCode, "data": videoList}
}
}
if playletFailed && (filter != "all" || videoFailed) {
data["favoritesUnavailable"] = true
}
if filter == "all" && playletFailed && videoFailed {
data["favoritesUnavailable"] = true
}
data["paging"] = makeFavoritesPage(page, pageSize, totalCount, filter)
data["favorites"] = works
data["favoritesResponse"] = responses["playlet/collect/list"]
data["playletCollectResponse"] = responses["playlet/collect/list"]
data["videoCollectResponse"] = responses["video/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
}
// renderFavorites 收藏为空时附带「大家都在看」后渲染。
func renderFavorites(c *gin.Context, data gin.H) {
attachFavoritesRecommended(c, data)
HTMLOK(c, "favorites.html", data)
}
// attachFavoritesRecommended 列表为空时拉取最热短剧(与搜索无结果一致)。
// 上游:GET /api/app/playlet/all/list sortType=2
func attachFavoritesRecommended(c *gin.Context, data gin.H) {
if works, ok := data["favorites"].([]favoriteWork); ok && len(works) > 0 {
return
}
watchingQ := playletser.AllListQuery{
PageNum: 1,
PageSize: 6,
SortType: 2, // 最热
UpdateStatus: 0,
}
watching := playletser.FetchAllListOrEmpty(c, watchingQ)
if watching == nil || len(watching.List) == 0 {
return
}
list := watching.List
if len(list) > 6 {
list = list[:6]
}
data["recommended"] = list
}
func loadVideoFavorites(c *gin.Context, data gin.H, responses gin.H, page, pageSize int) {
videoQuery := videoser.CollectListQuery{PageNum: page, PageSize: pageSize}
list, code, err := videoser.FetchCollectList(c, videoQuery)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, code, err = videoser.FetchCollectList(c, videoQuery)
}
if err != nil {
log.Error("fetch video favorites page failed", log.E(err))
responses["video/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
data["favoritesUnavailable"] = true
return
}
if list == nil {
responses["video/collect/list"] = gin.H{"code": code, "data": list}
return
}
responses["video/collect/list"] = gin.H{"code": 200, "data": list}
data["paging"] = makeFavoritesPage(page, pageSize, list.TotalCount, "series")
data["favorites"] = videoFavoriteWorks(list.List)
}
func favoritesRoute(c *gin.Context) (filter string, page int, valid bool) {
if c.Request.URL.Query().Has("filter") {
return "", 0, false
}
filter = strings.TrimSpace(c.Param("filter"))
switch filter {
case "":
filter = "all"
case "short", "chapter", "series", "following", "finished":
default:
return "", 0, false
}
page, valid = accountPageNum(c)
if !valid {
return "", 0, false
}
return filter, page, true
}
func favoritesPagePath(filter string, page int) string {
path := "/favorites"
if filter != "" && filter != "all" {
path += "/" + filter
}
if page > 1 {
path += "/page/" + strconv.Itoa(page)
}
return path
}
func makeFavoritesPage(page, size int, count int64, filter string) accountPage {
total := int((count + int64(size) - 1) / int64(size))
pagination := accountPage{Current: page, Total: total, Count: count}
if page > 1 {
pagination.PrevURL = favoritesPagePath(filter, page-1)
}
if page < total {
pagination.NextURL = favoritesPagePath(filter, page+1)
}
return pagination
}
func playletFavoriteWorks(items []playletser.PlayletCollectData) []favoriteWork {
works := make([]favoriteWork, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.PlayName)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
continue
}
work := favoriteWork{
ID: item.ID, Title: title, Genre: strings.Join(item.ThemeNames, " "),
Image: profileCover(item.Cover), Kind: "short",
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
DramaID: item.ID, Episode: item.LastEpisode, VideoID: item.LastChapterID,
TotalEpisode: item.TotalEpisode,
}
if work.Episode <= 0 {
work.Episode = 1
}
if item.TotalEpisode > 0 {
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
work.DesktopMeta = work.Episodes
}
if item.IsFeatured {
work.Badge = "爆剧"
}
works = append(works, work)
}
return works
}
func chapterFavoriteWorks(items []playletser.PlayletChapterCollectData) []favoriteWork {
works := make([]favoriteWork, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.PlayletTitle)
if title == "" {
continue
}
work := favoriteWork{
ID: item.ChapterID, Title: title, Image: profileCover(item.PlayletCover), Kind: "chapter",
URL: playletser.ChapterPlayPath(item.PlayletSlug, item.PlayletID, item.ChapterID), DesktopMeta: item.ChapterTitle,
DramaID: item.PlayletID, Episode: item.CurrentEpisode, VideoID: item.ChapterID,
TotalEpisode: item.TotalEpisode,
}
if work.Episode <= 0 {
work.Episode = 1
}
if item.CurrentEpisode > 0 {
work.Genre = fmt.Sprintf("第 %d 集", item.CurrentEpisode)
}
if item.TotalEpisode > 0 {
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
}
works = append(works, work)
}
return works
}
func videoFavoriteWorks(items []videoser.VideoCollectData) []favoriteWork {
works := make([]favoriteWork, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.Title)
if title == "" {
continue
}
work := favoriteWork{
ID: item.ID, Title: title, Genre: strings.Join(item.Keyworld, " "),
Image: profileCover(item.CoverImg), Kind: "series",
URL: videoser.VideoPlayURL(item.Slug, item.ID),
}
if item.VideoTime > 0 {
work.Episodes = formatVideoDuration(item.VideoTime)
work.DesktopMeta = work.Episodes
} else {
work.Episodes = "剧集"
work.DesktopMeta = "剧集"
}
works = append(works, work)
}
return works
}
+154
View File
@@ -0,0 +1,154 @@
package web
import (
"net/http"
"path/filepath"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/feedbackser"
"github.com/gin-gonic/gin"
)
// Feedback 意见反馈页:渲染 template/pages/feedback.html。
func Feedback(c *gin.Context) {
HTMLOK(c, "feedback.html", gin.H{
"title": "意见反馈 - 黄果短剧",
})
}
// FeedbackAdd BFF:代理 POST /api/app/feedback/add。
// 文档:http://172.104.181.99:25319/swagger/index.html#/意见反馈相关接口/post_api_app_feedback_add
func FeedbackAdd(c *gin.Context) {
var body feedbackser.FeedbackReq
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
body.Content = strings.TrimSpace(body.Content)
body.Contact = strings.TrimSpace(body.Contact)
if body.Type < 1 || body.Type > 4 {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "invalid type",
"tip": "请选择问题类型",
"data": nil,
})
return
}
if body.Content == "" {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "content required",
"tip": "请填写问题描述",
"data": nil,
})
return
}
if runes := []rune(body.Content); len(runes) > 500 {
body.Content = string(runes[:500])
}
if len(body.Screenshots) > 3 {
body.Screenshots = body.Screenshots[:3]
}
env, err := feedbackser.Add(c, body)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "提交失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "提交失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
// UploadImage BFF:代理 POST /api/app/upload/image。
// 文档:http://172.104.181.99:25319/swagger/index.html#/上传/post_api_app_upload_image
func UploadImage(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil || fileHeader == nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "file required",
"tip": "请选择图片文件",
"data": nil,
})
return
}
f, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "读取图片失败",
"data": nil,
})
return
}
defer f.Close()
name := filepath.Base(fileHeader.Filename)
if name == "" || name == "." || name == "/" {
name = "image.jpg"
}
resp, err := authser.UploadImage(c, name, f)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "上传失败,请稍后重试",
"data": nil,
})
return
}
if resp == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "上传失败,请稍后重试",
"data": nil,
})
return
}
var data gin.H
if resp.Data != nil {
url := strings.TrimSpace(resp.Data.SourceNameFs)
if url == "" {
url = strings.TrimSpace(resp.Data.ID)
}
data = gin.H{
"id": resp.Data.ID,
"sourceNameFs": resp.Data.SourceNameFs,
"url": url,
}
}
c.JSON(http.StatusOK, gin.H{
"code": resp.Code,
"msg": resp.Msg,
"tip": resp.Tip,
"data": data,
})
}
+35
View File
@@ -0,0 +1,35 @@
package web
import (
"net/http"
"strconv"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// GuessLikeList BFF:代理 GET /api/app/playlet/guess-like/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_guess_like_list
func GuessLikeList(c *gin.Context) {
playletID, _ := strconv.ParseInt(c.Query("playletId"), 10, 64)
pageNum, _ := strconv.Atoi(c.Query("pageNum"))
pageSize, _ := strconv.Atoi(c.Query("pageSize"))
if playletID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "playletId required",
"data": nil,
})
return
}
data := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
PlayletID: playletID,
PageNum: pageNum,
PageSize: pageSize,
})
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "ok",
"data": data,
})
}
+23
View File
@@ -0,0 +1,23 @@
package web
import (
"videoweb/app/service/seoser"
"github.com/gin-gonic/gin"
)
// Help renders the static Help page.
func Help(c *gin.Context) {
HTMLOK(c, "help.html", gin.H{
"faqItems": []seoser.FAQItem{
{Question: "黄果短剧网页版可以看全集吗?", Answer: "可以。免费集直接观看,付费集可用金币按集解锁,解锁后永久有效。"},
{Question: "解锁过的剧集还需要再付费吗?", Answer: "不需要。已解锁的剧集可在登录后继续观看,无需重复付费。"},
{Question: "如何充值金币?", Answer: "登录后进入个人中心,选择金币充值,即可查看可用的充值方式。"},
{Question: "支持哪些支付方式?", Answer: "可用的支付方式会在充值页面列出,请以页面显示为准。"},
{Question: "忘记密码怎么办?", Answer: "请通过意见反馈联系我们,提交账号信息后由客服协助处理。"},
{Question: "内容多久更新一次?", Answer: "黄果短剧持续更新内容,欢迎关注首页的新剧和最近上新。"},
{Question: "如何联系客服或洽谈合作?", Answer: "请发送邮件至 support@huangguo.com,我们会尽快回复。"},
{Question: "如何注销账号?", Answer: "请通过意见反馈或客服邮箱提交注销申请。"},
},
})
}
+375
View File
@@ -0,0 +1,375 @@
package web
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type historyEntry struct {
ID int64
Title string
Current int
Total int
EpisodeText string
Genre string
Time string
Image string
Progress string
Kind string
URL string
sortAt time.Time
}
type historyGroup struct {
Title string
Entries []historyEntry
}
// History 观看历史页:短剧 GET /api/app/playlet/history/list + 剧集 GET /api/app/video/history/list。
// 文档:
// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_history_list
// http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_history_list
func History(c *gin.Context) {
page, validPage := accountPageNum(c)
if !validPage {
NotFound(c)
return
}
data := gin.H{"title": "观看历史 - 黄果短剧", "navActive": "history", "accountTab": "history"}
responses := gin.H{}
loadAccountSummary(c, data, responses)
pageSize := 20
var entries []historyEntry
var totalCount int64
if useragent.TokenFromRequest(c.Request) == "" {
responses["playlet/history/list"] = gin.H{"error": "暂无登录会话"}
responses["video/history/list"] = gin.H{"error": "暂无登录会话"}
} else {
playletQuery := playletser.HistoryListQuery{PageNum: page, PageSize: pageSize}
playletList, playletCode, playletErr := playletser.FetchHistoryList(c, playletQuery)
if retry, _ := authser.OnSessionExpired(c, playletCode); retry {
playletList, playletCode, playletErr = playletser.FetchHistoryList(c, playletQuery)
}
if playletErr == nil && playletList != nil {
responses["playlet/history/list"] = gin.H{"code": 200, "data": playletList}
entries = append(entries, playletHistoryEntries(playletList.List)...)
totalCount += playletList.TotalCount
} else if playletErr != nil {
responses["playlet/history/list"] = gin.H{"error": "加载失败", "msg": playletErr.Error()}
log.Error("fetch playlet history page failed", log.E(playletErr))
} else {
responses["playlet/history/list"] = gin.H{"code": playletCode, "data": playletList}
}
videoQuery := videoser.HistoryListQuery{PageNum: page, PageSize: pageSize}
videoList, videoCode, videoErr := videoser.FetchHistoryList(c, videoQuery)
if retry, _ := authser.OnSessionExpired(c, videoCode); retry {
videoList, videoCode, videoErr = videoser.FetchHistoryList(c, videoQuery)
}
if videoErr == nil && videoList != nil {
responses["video/history/list"] = gin.H{"code": 200, "data": videoList}
entries = append(entries, videoHistoryEntries(videoList.List)...)
totalCount += videoList.TotalCount
} else if videoErr != nil {
responses["video/history/list"] = gin.H{"error": "加载失败", "msg": videoErr.Error()}
log.Error("fetch video history page failed", log.E(videoErr))
} else {
responses["video/history/list"] = gin.H{"code": videoCode, "data": videoList}
}
if playletErr != nil && videoErr != nil {
data["historyUnavailable"] = true
}
}
data["paging"] = makeAccountPage("/history", page, pageSize, totalCount)
data["groups"] = groupHistoryEntries(entries)
data["historyResponse"] = responses["playlet/history/list"]
data["playletHistoryResponse"] = responses["playlet/history/list"]
data["videoHistoryResponse"] = responses["video/history/list"]
data["historyDebug"] = responses
HTMLOK(c, "history.html", data)
}
// HistoryClear BFF:代理 POST /api/app/playlet/history/clear。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_clear
func HistoryClear(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
env, err := playletser.HistoryClear(c)
if err != nil {
log.Error("clear playlet history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "清空失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
// VideoHistoryClear BFF:代理 POST /api/app/video/history/clear。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_clear
func VideoHistoryClear(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
env, err := videoser.HistoryClear(c)
if err != nil {
log.Error("clear video history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "清空失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
// HistoryDelete BFF:代理 POST /api/app/playlet/history/delete。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_delete
func HistoryDelete(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
var body playletser.PlayletHistoryDeleteReq
if err := c.ShouldBindJSON(&body); err != nil || body.PlayletID <= 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "playletId required", "tip": "参数错误", "data": nil})
return
}
env, err := playletser.HistoryDelete(c, body)
if err != nil {
log.Error("delete playlet history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "删除失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
// VideoHistoryDelete BFF:代理 POST /api/app/video/history/delete。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_delete
func VideoHistoryDelete(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
var body videoser.VideoHistoryDeleteReq
if err := c.ShouldBindJSON(&body); err != nil || body.VideoID <= 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "videoId required", "tip": "参数错误", "data": nil})
return
}
env, err := videoser.HistoryDelete(c, body)
if err != nil {
log.Error("delete video history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "删除失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
func playletHistoryEntries(items []playletser.PlayletHistoryData) []historyEntry {
out := make([]historyEntry, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.PlayName)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
continue
}
progress := 0
if item.TotalEpisode > 0 {
progress = item.WatchedCount * 100 / item.TotalEpisode
}
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
out = append(out, historyEntry{
ID: item.ID,
Title: title,
Current: item.LastEpisode,
Total: item.TotalEpisode,
Genre: strings.Join(item.ThemeNames, " "),
Time: playletser.FormatHistoryListTime(item.HistoryAt),
Image: profileCover(item.Cover),
Progress: fmt.Sprintf("%d%%", progress),
Kind: "short",
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
sortAt: historySortAt(item.HistoryAt),
})
}
return out
}
func videoHistoryEntries(items []videoser.VideoHistoryData) []historyEntry {
out := make([]historyEntry, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.Title)
if title == "" {
continue
}
out = append(out, historyEntry{
ID: item.ID,
Title: title,
EpisodeText: videoHistoryEpisodeText(item),
Genre: strings.Join(item.Keyworld, " "),
Time: playletser.FormatHistoryListTime(item.HistoryAt),
Image: profileCover(item.CoverImg),
Progress: "",
Kind: "series",
URL: videoser.VideoPlayURL(item.Slug, item.ID),
sortAt: historySortAt(item.HistoryAt),
})
}
return out
}
func videoHistoryEpisodeText(item videoser.VideoHistoryData) string {
if item.VideoTime > 0 {
return formatVideoDuration(item.VideoTime)
}
return "剧集"
}
func formatVideoDuration(sec int) string {
if sec <= 0 {
return ""
}
h := sec / 3600
m := (sec % 3600) / 60
s := sec % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}
func historySortAt(raw string) time.Time {
t, ok := playletser.ParseHistoryTime(raw)
if !ok {
return time.Time{}
}
return t
}
func groupHistoryEntries(entries []historyEntry) []historyGroup {
if len(entries) == 0 {
return nil
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].sortAt.Equal(entries[j].sortAt) {
return entries[i].ID > entries[j].ID
}
return entries[i].sortAt.After(entries[j].sortAt)
})
order := []string{"今天", "昨天", "更早"}
buckets := map[string][]historyEntry{
"今天": {},
"昨天": {},
"更早": {},
}
for _, entry := range entries {
bucket := historyBucketFromTime(entry.sortAt)
buckets[bucket] = append(buckets[bucket], entry)
}
groups := make([]historyGroup, 0, 3)
for _, title := range order {
if len(buckets[title]) == 0 {
continue
}
groups = append(groups, historyGroup{Title: title, Entries: buckets[title]})
}
return groups
}
func historyBucketFromTime(t time.Time) string {
if t.IsZero() {
return "更早"
}
now := time.Now()
startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
dayDiff := int(startToday.Sub(startThat).Hours() / 24)
switch {
case dayDiff <= 0:
return "今天"
case dayDiff == 1:
return "昨天"
default:
return "更早"
}
}
+59
View File
@@ -0,0 +1,59 @@
package web
import (
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// Index 首页 SSR:拉取 GET /api/app/playlet/home,按接口字段注入模板。
// 接口文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home
// 继续观看:GET /api/app/playlet/history/list?pageNum=1&pageSize=6
// 热播榜:短剧 hot/rank/list;剧集 video/all/list sortType=最热
func Index(c *gin.Context) {
raw := playletser.FetchHomeOrEmpty(c)
title := "黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
history := playletser.FetchHistoryListOrEmpty(c, playletser.HistoryListQuery{PageNum: 1, PageSize: 6})
continueWatch := history.List
if len(continueWatch) > 6 {
continueWatch = continueWatch[:6]
}
hotPlaylets := playletser.FetchHotRankListOrEmpty(c, playletser.HotRankQuery{
PageNum: 1,
PageSize: 10,
RankType: 1,
}).List
if len(hotPlaylets) > 10 {
hotPlaylets = hotPlaylets[:10]
}
hotVideos := videoser.FetchVideoAllListOrEmpty(c, videoser.SeriesListQuery{
PageNum: 1,
PageSize: 10,
SortType: videoser.SortHeat,
MediaType: videoser.MediaAll,
}).List
if len(hotVideos) > 10 {
hotVideos = hotVideos[:10]
}
HTMLOK(c, "index.html", gin.H{
"title": title,
"navActive": "home",
"seoInfo": raw.SeoInfo,
"announcements": raw.Announcements,
"advitise": raw.Advitise,
"homeBannerData": playletser.ActiveHomeBanners(raw.HomeBannerData),
"homeConfigData": playletser.ActiveHomeConfigs(raw.HomeConfigData),
// GET /api/app/playlet/tag/options → list(已上架标签)
"tagOptions": playletser.FetchTagOptionsOrEmpty(c),
"continueWatch": continueWatch,
"hotRankPlaylets": hotPlaylets,
"hotRankVideos": hotVideos,
})
}
+100
View File
@@ -0,0 +1,100 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// List 短剧列表页:题材 + GET /api/app/playlet/all/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_all_list
func List(c *gin.Context) {
q := playletser.ParseAllListQuery(c)
themes := playletser.FetchThemesOrEmpty(c, 0)
playletser.ResolveAllListTheme(&q, themes)
themeResolved := listThemeResolved(q, themes)
raw := playletser.FetchAllListOrEmpty(c, q)
pageHeading, seoSelections := listSEOContext(q, themes)
title := pageHeading + " - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
var advitise any
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
advitise = raw.Advitise
}
HTMLOK(c, "list.html", gin.H{
"title": title,
"navActive": "list",
"seoInfo": seo,
"seoSelections": seoSelections,
"pageHeading": pageHeading,
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"advitise": advitise,
"themeList": themes,
"themeSlug": q.ThemeSlug,
"themeId": q.ThemeID,
"updateStatus": q.UpdateStatus,
"sortType": q.SortType,
"seoEntityValid": themeResolved || total > 0,
"pagination": playletser.BuildAllListPagination(q, total),
"canonicalPath": q.URL(),
})
}
func listThemeResolved(q playletser.AllListQuery, themes []playletser.PlayletThemeItem) bool {
if q.ThemeID <= 0 && q.ThemeSlug == "" {
return true
}
for _, theme := range themes {
if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) {
return true
}
}
return false
}
func listSEOContext(q playletser.AllListQuery, themes []playletser.PlayletThemeItem) (string, []playletser.SEOSelection) {
heading := "全部短剧"
selections := make([]playletser.SEOSelection, 0, 3)
themeName := q.ThemeSlug
for _, theme := range themes {
if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) {
themeName = theme.Name
break
}
}
if themeName != "" {
heading = themeName + "短剧"
selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: themeName})
}
if q.UpdateStatus == 1 {
selections = append(selections, playletser.SEOSelection{Dimension: "状态", Value: "连载中"})
} else if q.UpdateStatus == 2 {
selections = append(selections, playletser.SEOSelection{Dimension: "状态", Value: "已完结"})
}
if q.SortType == 2 {
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "最热"})
} else if q.SortType == 3 {
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"})
}
return heading, selections
}
+11
View File
@@ -0,0 +1,11 @@
package web
import "github.com/gin-gonic/gin"
// LoginPage H5 登录页:渲染 template/pages/login.html。
// PC 端由页面内脚本回首页并打开 loginPop;H5 直接展示本页。
func LoginPage(c *gin.Context) {
HTMLOK(c, "login.html", gin.H{
"title": "登录 - 黄果短剧",
})
}
+96
View File
@@ -0,0 +1,96 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// MoreList 首页配置更多页:GET /api/app/playlet/home/config/detail/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home_config_detail_list
func MoreList(c *gin.Context) {
q := playletser.ParseMoreListQuery(c)
themes := playletser.FetchThemesOrEmpty(c, 0)
playletser.ResolveMoreListTheme(&q, themes)
raw := playletser.FetchHomeConfigDetailListOrEmpty(c, q)
if raw != nil {
playletser.ApplyHomeConfigDetailIdentity(&q, raw)
}
home := playletser.FetchHomeOrEmpty(c)
homeConfigData := playletser.ActiveHomeConfigs(home.HomeConfigData)
name := "更多短剧"
desc := ""
entityName := ""
var seo playletser.SeoInfo
if raw != nil {
if raw.Name != "" {
name = raw.Name
entityName = raw.Name
}
desc = raw.Desc
seo = raw.SeoInfo
}
if config, ok := playletser.FindHomeConfigByIdentity(homeConfigData, q.HomeConfigID, q.HomeConfigSlug); ok {
seo = playletser.MergeSeoInfo(config.SeoInfo, seo)
if raw == nil || raw.Name == "" {
if config.Name != "" {
name = config.Name
entityName = config.Name
}
}
if desc == "" {
desc = config.Desc
}
}
title := name + " - 黄果短剧"
if seo.Title != "" {
title = seo.Title
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var advitise any
entityValid := false
if raw != nil {
list = raw.List
total = raw.TotalCount
advitise = raw.Advitise
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
entityValid = playletser.MoreListEntityValid(q, entityName, total)
}
HTMLOK(c, "moreList.html", gin.H{
"title": title,
"seoInfo": seo,
"routeConfigSEO": true,
"homeConfigData": homeConfigData,
"seoSelections": moreListSEOSelections(q, themes),
"navActive": "home",
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"configId": q.HomeConfigID,
"configSlug": q.HomeConfigSlug,
"configName": name,
"configDesc": desc,
"timeRange": q.TimeRange,
"themeSlug": q.ThemeSlug,
"themeId": q.ThemeID,
"seoEntityValid": entityValid,
"themeList": themes,
"timeTabs": playletser.BuildMoreTimeTabs(q),
"advitise": advitise,
"pagination": playletser.BuildMoreListPagination(q, total),
"canonicalPath": playletser.MoreListCanonicalPath(q),
})
}
+70
View File
@@ -0,0 +1,70 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// MoreSeriesList 首页影集配置更多页(type=2):GET /api/app/playlet/home/config/detail/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home_config_detail_list
func MoreSeriesList(c *gin.Context) {
q := playletser.ParseMoreListQuery(c)
themes := playletser.FetchThemesOrEmpty(c, 0)
playletser.ResolveMoreListTheme(&q, themes)
raw := playletser.FetchHomeConfigDetailListOrEmpty(c, q)
name := "更多视频"
desc := ""
if raw != nil {
if raw.Name != "" {
name = raw.Name
}
desc = raw.Desc
}
title := name + " - 黄果短剧"
list := []playletser.VideoItemResp(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var advitise any
entityValid := false
if raw != nil {
list = raw.VideoList
total = raw.TotalCount
advitise = raw.Advitise
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
playletser.ApplyHomeConfigDetailIdentity(&q, raw)
entityValid = playletser.MoreListEntityValid(q, raw.Name, total)
}
HTMLOK(c, "moreSeriesList.html", gin.H{
"title": title,
"seoSelections": moreListSEOSelections(q, themes),
"navActive": "home",
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"configId": q.HomeConfigID,
"configSlug": q.HomeConfigSlug,
"configName": name,
"configDesc": desc,
"timeRange": q.TimeRange,
"themeSlug": q.ThemeSlug,
"themeId": q.ThemeID,
"seoEntityValid": entityValid,
"themeList": themes,
"timeTabs": playletser.BuildMoreSeriesTimeTabs(q),
"advitise": advitise,
"pagination": playletser.BuildMoreSeriesListPagination(q, total),
"canonicalPath": playletser.MoreSeriesListCanonicalPath(q),
})
}
+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": "官方导航",
})
}
+14
View File
@@ -0,0 +1,14 @@
package web
import (
"net/http"
"github.com/gin-gonic/gin"
)
// NotFound 404 页面:渲染 template/pages/404.html。
func NotFound(c *gin.Context) {
HTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "页面不存在 - 黄果短剧",
})
}
+8
View File
@@ -0,0 +1,8 @@
package web
import "videoweb/app/service/playletser"
// mockPagination 为静态稿页生成分页数据;pageURL 暂统一为 #。
func mockPagination(page, pageSize int, total int64) playletser.PaginationVM {
return playletser.BuildPagination(func(int) string { return "#" }, page, pageSize, total)
}
+388
View File
@@ -0,0 +1,388 @@
package web
import (
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/commentser"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// Play 播放页:GET /api/app/playlet/detail + GET /api/app/playlet/chapter/detail。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_detail
func Play(c *gin.Context) {
if c.Request.URL.Query().Has("guessPage") {
NotFound(c)
return
}
q := playletser.ParsePlayQuery(c.Param("slug"), c.Param("ep"))
if strings.TrimSpace(c.Param("ep")) != "" && q.ChapterID <= 0 {
NotFound(c)
return
}
playletKey := c.Param("slug")
playlet := playletser.FetchDetailOrNilByKey(c, playletKey)
if playlet == nil {
NotFound(c)
return
}
chapterID := playletser.ResolvePlayChapterID(playlet, q.ChapterID)
listedChapter := playletser.FindChapter(playlet, chapterID)
if chapterID <= 0 || (q.ChapterID > 0 && listedChapter == nil) {
NotFound(c)
return
}
chapter := playletser.FetchChapterDetailOrNil(c, chapterID, playlet.ID, playlet.Slug)
if chapter == nil {
if listedChapter != nil {
chapter = playletser.ChapterFromList(*listedChapter)
}
}
if chapter == nil {
NotFound(c)
return
}
seo := playletser.ResolvePlayPageSEO(chapter, playlet)
title := playletser.ResolvePlayPageTitle(chapter, playlet)
if title == "" {
title = "在线播放 - 黄果短剧"
}
hot := playletser.FetchAllListOrEmpty(c, playletser.AllListQuery{
PageNum: 1,
PageSize: 8,
SortType: 2, // 最热
UpdateStatus: 0, // 全部
})
hotList := []playletser.CommonData(nil)
if hot != nil {
hotList = hot.List
}
guessPage := playletser.ParseGuessLikePage(c)
guessLike := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
PlayletID: playlet.ID,
PageNum: guessPage,
PageSize: 6,
})
if len(guessLike.List) == 0 && guessPage > 1 {
guessLike = playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
PlayletID: playlet.ID,
PageNum: 1,
PageSize: 6,
})
}
commentObjectID := playlet.ID
commentObjectType := commentser.ObjectTypePlaylet
if chapterID > 0 {
commentObjectID = chapterID
commentObjectType = commentser.ObjectTypeChapter
}
comments := commentser.FetchListOrEmpty(c, commentser.ListQuery{
ObjectID: commentObjectID,
ObjectType: commentObjectType,
PageNum: 1,
PageSize: 4,
SortOrder: commentser.SortHot,
})
unlockCost := int64(0)
balance := int64(0)
canWatch := false
episode := 0
if chapter != nil {
unlockCost = chapter.UnlockCost()
balance = chapter.Balance
canWatch = chapter.AllowWatch()
episode = chapter.CurrentEpisode
}
if episode <= 0 {
for i, item := range playlet.PlayletChapterInfo {
if item.ID != chapterID {
continue
}
episode = item.CurrentEpisode
if episode <= 0 {
episode = i + 1
}
break
}
}
if episode <= 0 {
episode = 1
}
if unlockCost <= 0 {
unlockCost = int64(playletser.FirstUnlockCost(playlet.PlayletChapterInfo))
}
previewURL := strings.TrimSpace(playlet.Preview)
mainURL := ""
if canWatch && chapter != nil {
mainURL = strings.TrimSpace(chapter.VideoURL)
}
data := gin.H{
"title": title,
"navActive": "list",
"seoInfo": seo,
"playlet": playlet,
"chapter": chapter,
"chapterID": chapterID,
"episode": episode,
"canWatch": canWatch,
"mainURL": mainURL,
"previewURL": previewURL,
"hasPreview": previewURL != "",
"unlockCost": unlockCost,
"balance": balance,
"balanceText": authser.FormatRecharge(balance),
"nextChapterID": playletser.NextChapterID(playlet, chapterID),
"advitise": playlet.Advitise,
"related": playlet.RelatedRecommendData,
"guessLike": guessLike.List,
"guessPage": guessLike.PageNum,
"guessPageSize": guessLike.PageSize,
"guessTotal": guessLike.TotalCount,
"hotList": hotList,
"hotMoreURL": playletser.AllListURL("all", 0, 0, 2, 1),
"comments": comments.List,
"commentTotal": comments.TotalCount,
"commentPage": comments.PageNum,
"commentSize": comments.PageSize,
"commentObjectID": commentObjectID,
"commentObjectType": commentObjectType,
"trackerDrama": dramaTrackingData(playlet, chapter, chapterID, episode),
"playResponse": gin.H{
"playlet": playlet,
"chapter": chapter,
"chapterID": chapterID,
"canWatch": canWatch,
"mainURL": mainURL,
"previewURL": previewURL,
"hasPreview": previewURL != "",
"guessLike": guessLike,
"hotList": hotList,
"comments": comments,
"commentObjectID": commentObjectID,
"commentObjectType": commentObjectType,
},
}
data["canonicalPath"] = playletser.PlayPath(playlet.Slug, playlet.ID)
attachRechargeModal(c, data)
HTMLOK(c, "play.html", data)
}
func dramaTrackingData(playlet *playletser.PlayletInfoResp, chapter *playletser.PlayletChapterInfoResp, chapterID int64, episode int) gin.H {
name := strings.TrimSpace(playlet.PlayName)
if name == "" {
name = strings.TrimSpace(playlet.Title)
}
totalEpisode := playlet.TotalEpisode
if totalEpisode <= 0 {
totalEpisode = len(playlet.PlayletChapterInfo)
}
if totalEpisode <= 0 {
totalEpisode = 1
}
data := gin.H{
"MediaID": "",
"DramaID": playlet.ID,
"DramaTitle": name,
"Episode": max(1, episode),
"TotalEpisode": totalEpisode,
"VideoID": chapterID,
"DramaTypeID": "default",
"DramaTypeName": "默认分类",
"DramaTagKey": "default",
"DramaTagName": "默认标签",
"DramaDuration": 0,
"RecommendTraceID": "",
}
if chapter != nil {
if chapter.ID > 0 {
data["VideoID"] = chapter.ID
}
if chapter.MediaTime > 0 {
data["DramaDuration"] = chapter.MediaTime
}
}
for i, id := range playlet.ThemeIDs {
if id <= 0 || i >= len(playlet.ThemeNames) {
continue
}
name := strings.TrimSpace(playlet.ThemeNames[i])
if name == "" {
continue
}
data["DramaTypeID"] = strconv.FormatInt(id, 10)
data["DramaTypeName"] = name
break
}
tagIDs := make([]string, 0, len(playlet.TagIDs))
tagNames := make([]string, 0, len(playlet.TagIDs))
for i, id := range playlet.TagIDs {
if id <= 0 || i >= len(playlet.TagNames) {
continue
}
name := strings.TrimSpace(playlet.TagNames[i])
if name == "" {
continue
}
tagIDs = append(tagIDs, strconv.FormatInt(id, 10))
tagNames = append(tagNames, name)
}
if len(tagIDs) > 0 {
data["DramaTagKey"] = strings.Join(tagIDs, ",")
data["DramaTagName"] = strings.Join(tagNames, ",")
}
return data
}
// SeriesPlay 视频播放页:GET /api/app/video/detail。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_detail
func SeriesPlay(c *gin.Context) {
if c.Request.URL.Query().Has("guessPage") {
NotFound(c)
return
}
key := c.Param("slug")
video := videoser.FetchVideoDetailOrNilByKey(c, key)
if video == nil {
NotFound(c)
return
}
title := videoser.ResolveVideoPageTitle(video)
if title == "" {
title = "在线播放 - 黄果短剧"
} else {
title = title + " - 黄果短剧"
}
cover := video.CoverImg
if cover == "" {
cover = video.Video.Cover
}
videoURL := strings.TrimSpace(video.Video.URL)
canWatch := videoURL != ""
commentObjectID := video.ID
// 视频评论:objectType=20(swagger:10-短剧 11-短剧剧集 20-视频)
commentObjectType := commentser.ObjectTypeVideo
comments := commentser.FetchListOrEmpty(c, commentser.ListQuery{
ObjectID: commentObjectID,
ObjectType: commentObjectType,
PageNum: 1,
PageSize: 4,
SortOrder: commentser.SortHot,
})
if comments.PageNum <= 0 {
comments.PageNum = 1
}
if comments.PageSize <= 0 {
comments.PageSize = 4
}
commentTotal := comments.TotalCount
if commentTotal <= 0 && video.CountComment > 0 {
commentTotal = video.CountComment
}
hot := playletser.FetchAllListOrEmpty(c, playletser.AllListQuery{
PageNum: 1,
PageSize: 8,
SortType: 2, // 最热
UpdateStatus: 0, // 全部
})
hotList := []playletser.CommonData(nil)
if hot != nil {
hotList = hot.List
}
// 视频详情无 recommendVideos:猜你喜欢与 play.html 一致,走短剧同题材推荐。
// 以热榜首部短剧为种子 playletId(排除该剧本身)。
guessPlayletID := int64(0)
if len(hotList) > 0 {
guessPlayletID = hotList[0].ID
}
guessPage := playletser.ParseGuessLikePage(c)
guessLike := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
PlayletID: guessPlayletID,
PageNum: guessPage,
PageSize: 6,
})
if len(guessLike.List) == 0 && guessPage > 1 {
guessLike = playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
PlayletID: guessPlayletID,
PageNum: 1,
PageSize: 6,
})
}
data := gin.H{
"title": title,
"navActive": "aiSeries",
"seoInfo": video.SeoInfo,
"video": video,
"videoID": video.ID,
"videoSlug": video.Slug,
"cover": cover,
"videoURL": videoURL,
"canWatch": canWatch,
"guessLike": guessLike.List,
"guessPage": guessLike.PageNum,
"guessPageSize": guessLike.PageSize,
"guessTotal": guessLike.TotalCount,
"guessPlayletID": guessPlayletID,
"advitise": video.Advitise,
"comments": comments.List,
"commentTotal": commentTotal,
"commentPage": comments.PageNum,
"commentSize": comments.PageSize,
"commentObjectID": commentObjectID,
"commentObjectType": commentObjectType,
"hotList": hotList,
"hotMoreURL": playletser.AllListURL("all", 0, 0, 2, 1),
"videoResponse": gin.H{
"video": video,
"videoURL": videoURL,
"canWatch": canWatch,
"comments": comments,
"hotList": hotList,
"guessLike": guessLike,
"guessPlayletID": guessPlayletID,
"commentObjectID": commentObjectID,
"commentObjectType": commentObjectType,
},
"commentDebug": gin.H{
"path": "/api/app/comment/list",
"objectId": commentObjectID,
"objectType": commentObjectType, // 20-视频
"pageNum": comments.PageNum,
"pageSize": comments.PageSize,
"totalCount": commentTotal,
"list": comments.List,
"apis": []string{
"GET /api/app/comment/list",
"POST /api/app/comment/send",
"POST /api/app/comment/like/set",
},
},
"playDebug": gin.H{
"canWatch": canWatch,
"videoURL": videoURL,
"cover": cover,
"videoTime": video.Video.VideoTime,
},
}
if strings.TrimSpace(c.Param("guessPage")) != "" {
data["canonicalPath"] = videoser.VideoPlayURL(video.Slug, video.ID)
}
attachRechargeModal(c, data)
HTMLOK(c, "seriesPlay.html", data)
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// PlayletLikeSet BFF:代理 POST /api/app/playlet/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_like_set
func PlayletLikeSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isLike required",
"tip": "缺少短剧 ID 或点赞状态",
"data": nil,
})
return
}
env, err := playletser.LikeSet(c, playletser.PlayletLikeSetReq{
ID: body.ID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// PlayletCollectSet BFF:代理 POST /api/app/playlet/collect/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_collect_set
func PlayletCollectSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsCollect *bool `json:"isCollect"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isCollect required",
"tip": "缺少短剧 ID 或收藏状态",
"data": nil,
})
return
}
env, err := playletser.CollectSet(c, playletser.PlayletCollectSetReq{
ID: body.ID,
IsCollect: *body.IsCollect,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// PopularRecommend 热播榜页:GET /api/app/playlet/hot/rank/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_hot_rank_list
func PopularRecommend(c *gin.Context) {
q := playletser.ParseHotRankQuery(c)
raw := playletser.FetchHotRankListOrEmpty(c, q)
tabTitle := playletser.HotRankTabTitle(q.RankType)
title := tabTitle + " - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
HTMLOK(c, "popularRecommend.html", gin.H{
"title": title,
"navActive": "popular",
"seoInfo": seo,
"seoSelections": rankingSEOSelections(tabTitle),
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"rankType": q.RankType,
"tabTitle": tabTitle,
"rankTabs": playletser.BuildHotRankTabs(q),
"listCount": fmt.Sprintf("共 %d 部", total),
"pagination": playletser.BuildHotRankPagination(q, total),
"canonicalPath": playletser.HotRankCanonicalPath(q),
})
}
+8
View File
@@ -0,0 +1,8 @@
package web
import "github.com/gin-gonic/gin"
// Privacy renders the static Privacy Policy page.
func Privacy(c *gin.Context) {
HTMLOK(c, "privacy.html", gin.H{})
}
+229
View File
@@ -0,0 +1,229 @@
package web
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/app/service/usercenterser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type profileWork struct {
Title, Meta, Episodes, Badge, Image, Progress, Remaining, URL string
}
// Profile 从当前会话拉取用户信息、观看历史和收藏,不展示演示账号数据。
func Profile(c *gin.Context) {
data := gin.H{
"title": "个人中心 - 黄果短剧", "navActive": "profile", "accountTab": "profile",
}
responses := gin.H{}
loadAccountSummary(c, data, responses)
if useragent.TokenFromRequest(c.Request) != "" {
fetchProfileLists(c, data, responses)
}
if pretty, err := json.MarshalIndent(responses, "", " "); err == nil {
data["responseJSON"] = string(pretty)
}
HTMLOK(c, "profile.html", data)
}
func loadAccountSummary(c *gin.Context, data, responses gin.H) {
data["userName"] = "未登录"
data["balance"] = "—"
data["historyCount"] = "—"
data["favoriteCount"] = "—"
data["chasingCount"] = "—"
data["boughtChapterCount"] = "—"
user := authser.SyncUserOnEnter(c)
if user == nil {
if useragent.TokenFromRequest(c.Request) != "" {
responses["auth/getInfo"] = gin.H{"error": "加载失败"}
fetchProfileStats(c, data, responses)
return
}
responses["auth/getInfo"] = gin.H{"error": "暂无登录会话"}
return
}
applyProfileInfo(data, responses, user)
if useragent.TokenFromRequest(c.Request) != "" {
fetchProfileStats(c, data, responses)
}
}
func fetchProfileStats(c *gin.Context, data, responses gin.H) {
stats, code, err := usercenterser.Fetch(c)
if retry, _ := authser.OnSessionExpired(c, code); retry {
stats, code, err = usercenterser.Fetch(c)
}
if err != nil || stats == nil {
responses["user/center/data"] = gin.H{"error": "加载失败"}
if err != nil {
log.Error("fetch user center data failed", log.E(err))
}
return
}
responses["user/center/data"] = gin.H{"code": code, "data": stats}
data["historyCount"] = fmt.Sprint(stats.WatchCount)
data["favoriteCount"] = fmt.Sprint(stats.CollectCount)
data["chasingCount"] = fmt.Sprint(stats.ChasingCount)
data["boughtChapterCount"] = fmt.Sprint(stats.BoughtChapterCount)
}
func fetchProfileLists(c *gin.Context, data, responses gin.H) {
query := playletser.HistoryListQuery{PageNum: 1, PageSize: 6}
history, code, err := playletser.FetchHistoryList(c, query)
if retry, _ := authser.OnSessionExpired(c, code); retry {
history, _, err = playletser.FetchHistoryList(c, query)
}
if err == nil && history != nil {
responses["playlet/history/list"] = gin.H{"code": 200, "data": history}
data["history"] = profileHistoryWorks(history.List)
} else if err != nil {
responses["playlet/history/list"] = gin.H{"error": "加载失败"}
log.Error("fetch profile history failed", log.E(err))
data["historyUnavailable"] = true
}
collectQuery := playletser.CollectListQuery{PageNum: 1, PageSize: 6, WatchStatus: 0}
favorites, code, err := playletser.FetchCollectList(c, collectQuery)
if retry, _ := authser.OnSessionExpired(c, code); retry {
favorites, _, err = playletser.FetchCollectList(c, collectQuery)
}
if err == nil && favorites != nil {
responses["playlet/collect/list"] = gin.H{"code": 200, "data": favorites}
data["favorites"] = profileFavoriteWorks(favorites.List)
} else if err != nil {
responses["playlet/collect/list"] = gin.H{"error": "加载失败"}
log.Error("fetch profile favorites failed", log.E(err))
data["favoritesUnavailable"] = true
}
}
func applyProfileInfo(data, responses gin.H, user *authser.LoginResp) {
if user == nil {
return
}
// 登录响应可能包含 token;调试区域只展示非凭证字段。
rawUser, _ := json.Marshal(user)
var safeUser map[string]any
_ = json.Unmarshal(rawUser, &safeUser)
delete(safeUser, "token")
responses["auth/getInfo"] = gin.H{"code": 200, "data": safeUser}
name := strings.TrimSpace(user.NickName)
if name == "" {
name = strings.TrimSpace(user.AccountNo)
}
if name == "" {
name = "用户"
}
data["userName"] = name
data["setting"] = authser.BuildSettingVM(user)
data["userID"] = user.UserID
data["userId"] = fmt.Sprint(user.UserID)
data["balance"] = fmt.Sprint(user.Recharge)
data["recharge"] = user.Recharge
data["rechargeText"] = authser.FormatRecharge(user.Recharge)
if avatar := safeProfileImage(user.Avatar); avatar != "" {
data["avatar"] = avatar
}
}
func safeProfileImage(raw string) string {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "/static/") {
return raw
}
u, err := url.Parse(raw)
if err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" {
return raw
}
return ""
}
func profileHistoryWorks(items []playletser.PlayletHistoryData) []profileWork {
works := make([]profileWork, 0, len(items))
for _, item := range items {
if len(works) == 6 {
break
}
name := strings.TrimSpace(item.Title)
if name == "" {
name = strings.TrimSpace(item.PlayName)
}
if name == "" {
continue
}
progress := 0
if item.TotalEpisode > 0 {
progress = item.WatchedCount * 100 / item.TotalEpisode
}
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
meta := "观看记录"
if item.LastEpisode > 0 {
meta = fmt.Sprintf("看至第 %d 集", item.LastEpisode)
}
remaining := ""
if item.UnwatchedCount > 0 {
remaining = fmt.Sprintf("剩 %d 集", item.UnwatchedCount)
}
works = append(works, profileWork{Title: name, Meta: meta, Image: profileCover(item.Cover), Progress: fmt.Sprintf("%d%%", progress), Remaining: remaining, URL: profilePlayURL(item.Slug, item.ID, item.LastEpisode)})
}
return works
}
func profileFavoriteWorks(items []playletser.PlayletCollectData) []profileWork {
works := make([]profileWork, 0, len(items))
for _, item := range items {
if len(works) == 6 {
break
}
name := strings.TrimSpace(item.Title)
if name == "" {
name = strings.TrimSpace(item.PlayName)
}
if name == "" {
continue
}
work := profileWork{Title: name, Meta: strings.Join(item.ThemeNames, " "), Image: profileCover(item.Cover), URL: profilePlayURL(item.Slug, item.ID, 1)}
if item.TotalEpisode > 0 {
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
}
if item.IsFeatured {
work.Badge = "爆剧"
}
works = append(works, work)
}
return works
}
func profileCover(raw string) string {
if safe := safeProfileImage(raw); safe != "" {
return safe
}
return "/static/images/placeholder.png?v=2"
}
func profilePlayURL(slug string, id int64, episode int) string {
if slug == "" {
if id <= 0 {
return ""
}
slug = fmt.Sprint(id)
}
if episode < 1 {
episode = 1
}
return "/play/" + url.PathEscape(slug) + "/" + fmt.Sprint(episode)
}
+87
View File
@@ -0,0 +1,87 @@
package web
import (
"fmt"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type purchasedWork struct {
Title, Image, Info, Date, Progress, URL string
}
// PurchasedSeries 已购剧集:GET /api/app/playlet/bought/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_bought_list
func PurchasedSeries(c *gin.Context) {
page, validPage := accountPageNum(c)
if !validPage {
NotFound(c)
return
}
data := gin.H{"title": "已购剧集 - 黄果短剧", "navActive": "purchased", "accountTab": "purchased"}
responses := gin.H{}
loadAccountSummary(c, data, responses)
size := 20
if useragent.TokenFromRequest(c.Request) == "" {
responses["playlet/bought/list"] = gin.H{"error": "暂无登录会话"}
data["purchasedResponse"] = responses["playlet/bought/list"]
HTMLOK(c, "purchased.html", data)
return
}
list, code, err := playletser.FetchBoughtList(c, page, size)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, _, err = playletser.FetchBoughtList(c, page, size)
}
if err == nil && list != nil {
responses["playlet/bought/list"] = gin.H{"code": 200, "data": list}
data["paging"] = makeAccountPage("/purchased", page, size, list.TotalCount)
works := make([]purchasedWork, 0, len(list.List))
for _, item := range list.List {
title := strings.TrimSpace(item.PlayName)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
continue
}
info := fmt.Sprintf("已购 %d 集", item.BoughtChapterCount)
if item.TotalEpisode > 0 {
info += fmt.Sprintf(" / 共 %d 集", item.TotalEpisode)
}
info += fmt.Sprintf(" · %d 金币", item.CostCoins)
progress := 0
if item.TotalEpisode > 0 {
progress = item.WatchedCount * 100 / item.TotalEpisode
}
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
works = append(works, purchasedWork{
Title: title, Image: profileCover(item.Cover), Info: info,
Date: playletser.FormatHistoryAt(item.BoughtAt), Progress: fmt.Sprintf("%d%%", progress),
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
})
}
data["purchased"] = works
} else if err != nil {
responses["playlet/bought/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
log.Error("fetch bought playlets failed", log.E(err))
data["purchasedUnavailable"] = true
} else {
responses["playlet/bought/list"] = gin.H{"code": code, "data": list}
}
data["purchasedResponse"] = responses["playlet/bought/list"]
HTMLOK(c, "purchased.html", data)
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Rank 排行榜页:GET /api/app/playlet/rank。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_rank
func Rank(c *gin.Context) {
q := playletser.ParseRankQuery(c)
raw := playletser.FetchRankOrEmpty(c, q)
tabTitle := playletser.RankTabTitle(q.SortOrder)
title := tabTitle + " - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
HTMLOK(c, "rank.html", gin.H{
"title": title,
"navActive": "rank",
"seoInfo": seo,
"seoSelections": rankingSEOSelections(tabTitle),
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"sortOrder": q.SortOrder,
"tabTitle": tabTitle,
"rankTabs": playletser.BuildRankTabs(q),
"rankOffset": playletser.RankOffset(q),
"isBinge": playletser.IsRankBinge(q.SortOrder),
"pagination": playletser.BuildRankPagination(q, total),
"canonicalPath": playletser.RankCanonicalPath(q),
})
}
+48
View File
@@ -0,0 +1,48 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// RecentlyAdded 最近上新页:GET /api/app/playlet/today-update。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_today_update
func RecentlyAdded(c *gin.Context) {
q := playletser.ParseTodayUpdateQuery(c.Param("page"))
raw := playletser.FetchTodayUpdateOrEmpty(c, q)
title := "最近上新 - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
HTMLOK(c, "recentlyAdded.html", gin.H{
"title": title,
"navActive": "todayUpdate",
"seoInfo": seo,
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"pagination": playletser.BuildTodayUpdatePagination(q, total),
"canonicalPath": playletser.TodayUpdateCanonicalPath(q),
})
}
+279
View File
@@ -0,0 +1,279 @@
package web
import (
"net/http"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/rechargeser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const rechargePageSize = 5
// Recharge 金币充值页:套餐 GET /api/app/recharge/package/list,购买 POST /api/app/coin/buy,流水 GET /api/app/coin/trans/list。
func Recharge(c *gin.Context) {
data := gin.H{
"title": "金币充值 - 黄果短剧", "pageHeading": "金币充值",
"backHref": "/profile", "helpHref": "/help",
}
responses := gin.H{}
loadAccountSummary(c, data, responses)
packages, err := rechargeser.FetchPackageList(c)
if err != nil {
log.Error("fetch recharge packages failed", log.E(err))
data["packageUnavailable"] = true
packages = &rechargeser.RechargePackageListResp{}
responses["recharge/package/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
} else {
responses["recharge/package/list"] = gin.H{"code": 200, "data": packages}
}
vm := rechargeser.BuildDepositPageVM(packages, "coin")
data["packages"] = vm.CoinList
data["paymentMethods"] = vm.PayMethods
data["rechargePackageResponse"] = packages
balance := authser.RechargeFromCookies(c)
if _, ok := data["recharge"]; ok {
balance = toInt64(data["recharge"])
}
data["balance"] = balance
data["balanceText"] = authser.FormatRecharge(balance)
page, _, err := fetchRechargeTransactions(c, 1, rechargePageSize, 0)
if err != nil {
log.Error("fetch recharge transactions failed", log.E(err))
data["transactionsUnavailable"] = true
page = &rechargeser.CoinTransPage{PageNum: 1, PageSize: rechargePageSize}
responses["coin/trans/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
} else {
responses["coin/trans/list"] = gin.H{"code": 200, "data": page}
}
data["transactions"] = rechargeser.BuildTxListFromCoinTrans(page.List, 0)
data["transactionPage"] = page
data["transactionResponse"] = page
data["rechargeDebug"] = responses
HTMLOK(c, "recharge.html", data)
}
// RechargeCoinBuy BFF:代理 POST /api/app/coin/buy。
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/post_api_app_coin_buy
func RechargeCoinBuy(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
var body rechargeser.BuyPayReq
if err := c.ShouldBindJSON(&body); err != nil || body.PackageID <= 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "packageId required", "tip": "请选择充值套餐", "data": nil})
return
}
body.PayMode = rechargeser.NormalizePayMode(body.PayMode)
if body.PayMode == "" {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "payMode required", "tip": "请选择支付方式", "data": nil})
return
}
if !validCoinPackagePayment(c, body.PackageID, body.PayMode) {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "invalid package/payMode", "tip": "套餐或支付方式不可用,请刷新后重试", "data": nil})
return
}
env, err := rechargeser.BuyCoin(c, body)
if err != nil {
log.Error("buy coin failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "创建支付订单失败,请稍后重试", "data": nil})
return
}
if env != nil {
if retry, needLogin := authser.OnSessionExpired(c, env.Code); needLogin {
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": "登录已过期,请重新登录", "data": nil})
return
} else if retry {
env, err = rechargeser.BuyCoin(c, body)
if err != nil {
log.Error("buy coin retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "创建支付订单失败,请稍后重试", "data": nil})
return
}
}
}
if env == nil || env.Code != 200 {
code, tip, msg := 500, "创建支付订单失败", "buy failed"
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmptyAuth(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{"code": code, "msg": msg, "tip": tip, "data": nil})
return
}
safeURL := safePaymentURL(env.Data.PayURL)
if safeURL == "" {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "invalid payUrl", "tip": "支付地址无效,请稍后重试", "data": nil})
return
}
env.Data.PayURL = safeURL
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": env.Msg,
"tip": firstNonEmptyAuth(env.Tip, "订单创建成功"),
"data": env.Data,
})
}
// RechargeTransactions BFF:代理 GET /api/app/coin/trans/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/get_api_app_coin_trans_list
// query:page / pageSize / type(0全部 1充值 2消费 3奖励)
func RechargeTransactions(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
page := queryInt(c, "page", 1)
pageSize := queryInt(c, "pageSize", rechargePageSize)
typ := queryIntAllowZero(c, "type", 0)
if typ < 0 || typ > 3 {
typ = 0
}
result, code, err := fetchRechargeTransactions(c, page, pageSize, typ)
if err != nil {
log.Error("fetch recharge transactions failed", log.E(err))
tip := "交易明细加载失败"
if code > 0 {
c.JSON(http.StatusOK, gin.H{"code": code, "msg": err.Error(), "tip": tip, "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": tip, "data": nil})
return
}
if result == nil {
result = &rechargeser.CoinTransPage{PageNum: page, PageSize: pageSize}
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "ok",
"tip": "",
"data": gin.H{
"list": rechargeser.BuildTxListFromCoinTrans(result.List, 0),
"rawList": result.List,
"pageNum": result.PageNum,
"pageSize": result.PageSize,
"totalCount": result.TotalCount,
},
})
}
// RechargeCoinTransExport BFF:代理 GET /api/app/coin/trans/export。
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/get_api_app_coin_trans_export
// query:type(0全部 1充值 2消费 3奖励),返回 CSV。
func RechargeCoinTransExport(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
typ := queryIntAllowZero(c, "type", 0)
if typ < 0 || typ > 3 {
typ = 0
}
result, err := rechargeser.FetchCoinTransExport(c, typ)
if err != nil {
log.Error("export coin trans failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "导出失败,请稍后重试", "data": nil})
return
}
ct := strings.TrimSpace(result.ContentType)
if ct == "" || strings.Contains(strings.ToLower(ct), "json") {
ct = "text/csv; charset=utf-8"
}
disposition := strings.TrimSpace(result.ContentDisposition)
if disposition == "" {
disposition = `attachment; filename="coin-trans.csv"`
}
c.Header("Content-Type", ct)
c.Header("Content-Disposition", disposition)
c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, ct, result.Body)
}
func fetchRechargeTransactions(c *gin.Context, page, pageSize, typ int) (*rechargeser.CoinTransPage, int, error) {
result, code, err := rechargeser.FetchCoinTransPage(c, page, pageSize, typ)
if retry, _ := authser.OnSessionExpired(c, code); retry {
result, code, err = rechargeser.FetchCoinTransPage(c, page, pageSize, typ)
}
return result, code, err
}
// attachRechargeModal 注入充值弹窗数据:GET /api/app/recharge/package/list → coinList。
func attachRechargeModal(c *gin.Context, data gin.H) {
if data == nil {
return
}
packages := rechargeser.FetchPackageListOrEmpty(c)
vm := rechargeser.BuildDepositPageVM(packages, "coin")
data["rechargePackages"] = vm.CoinList
data["rechargePayMethods"] = vm.PayMethods
data["rechargePackageResponse"] = packages
if _, ok := data["unlockCost"]; !ok {
data["unlockCost"] = int64(0)
}
if _, ok := data["balance"]; !ok {
data["balance"] = authser.RechargeFromCookies(c)
}
if _, ok := data["balanceText"]; !ok {
data["balanceText"] = authser.FormatRecharge(toInt64(data["balance"]))
}
}
func validCoinPackagePayment(c *gin.Context, packageID int64, payMode string) bool {
data, err := rechargeser.FetchPackageList(c)
if err != nil || data == nil {
return false
}
for _, item := range data.CoinList {
if item.ID != packageID {
continue
}
for _, method := range item.PayMethods {
if rechargeser.NormalizePayMode(method.Type) == payMode {
return true
}
}
return false
}
return false
}
func safePaymentURL(raw string) string {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return ""
}
return raw
}
func queryInt(c *gin.Context, name string, fallback int) int {
v, err := strconv.Atoi(strings.TrimSpace(c.Query(name)))
if err != nil || v <= 0 {
return fallback
}
return v
}
// queryIntAllowZero 允许 0(如 type=0 全部)。
func queryIntAllowZero(c *gin.Context, name string, fallback int) int {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return v
}
+11
View File
@@ -0,0 +1,11 @@
package web
import "github.com/gin-gonic/gin"
// RegisterPage H5 注册页:渲染 template/pages/register.html。
// PC 端由页面内脚本回首页并打开 registerPop;H5 直接展示本页。
func RegisterPage(c *gin.Context) {
HTMLOK(c, "register.html", gin.H{
"title": "注册账号 - 黄果短剧",
})
}
+660
View File
@@ -0,0 +1,660 @@
package web
import (
"net/http"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/configser"
"videoweb/app/service/playletser"
"videoweb/app/service/seoser"
"videoweb/app/service/translationser"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// HTML 渲染页面并注入登录态(以 cookie 为准)。
// 无 token 时自动游客登录(POST /api/app/auth/guest),保证默认进入即有会话。
func HTML(c *gin.Context, code int, name string, data gin.H) {
if data == nil {
data = gin.H{}
}
// 所有服务端页面先完成渠道首次归因;已有会话也不会漏记落地页渠道。
if channel := authser.ResolveChannel(c, ""); channel != "" {
data["channel"] = channel
}
authser.SyncUserOnEnter(c)
loggedIn, userName, avatar, email := authser.AuthFromCookies(c)
registered := authser.IsRegisteredFromCookies(c)
data["isLoggedIn"] = loggedIn
data["isRegistered"] = registered
// 已登录(含游客)展示用户菜单;未登录才展示「登录」按钮。
if _, ok := data["guestHeader"]; !ok {
data["guestHeader"] = !loggedIn
}
if _, ok := data["isVip"]; !ok {
data["isVip"] = loggedIn && authser.IsActiveVipFromCookies(c)
}
isVip, _ := data["isVip"].(bool)
data["isVip"] = isVip
if loggedIn {
if _, provided := data["userName"]; !provided && userName != "" {
data["userName"] = userName
}
if _, provided := data["avatar"]; !provided && avatar != "" {
data["avatar"] = avatar
}
if email != "" {
data["email"] = email
}
if _, ok := data["userId"]; !ok {
if uid := authser.UserIDFromCookies(c); uid > 0 {
data["userId"] = authser.FormatUserID(uid)
}
}
if _, ok := data["recharge"]; !ok {
recharge := authser.RechargeFromCookies(c)
data["recharge"] = recharge
data["rechargeText"] = authser.FormatRecharge(recharge)
} else if _, ok := data["rechargeText"]; !ok {
data["rechargeText"] = authser.FormatRecharge(toInt64(data["recharge"]))
}
if _, ok := data["vipType"]; !ok {
data["vipType"] = authser.VipTypeFromCookies(c)
}
} else {
delete(data, "userName")
delete(data, "avatar")
delete(data, "email")
delete(data, "userId")
delete(data, "recharge")
delete(data, "rechargeText")
delete(data, "vipType")
}
// 导航分类:GET /api/app/translation/category/list → categoryList(包含分类 seoInfo)
if _, ok := data["categoryList"]; !ok {
data["categoryList"] = translationser.FetchHeaderCategoryListOrDefault(c)
}
if _, ok := data["requestPath"]; !ok {
data["requestPath"] = c.Request.URL.Path
}
// 抽屉题材:GET /api/app/theme/list
if _, ok := data["themeList"]; !ok {
data["themeList"] = playletser.FetchThemesOrEmpty(c, 0)
}
// 顶栏短剧下拉快捷入口:homeConfigData type=1
if _, ok := data["homeConfigPlaylets"]; !ok {
if configs, ok := data["homeConfigData"].([]playletser.HomeConfigData); ok {
data["homeConfigPlaylets"] = playletser.HomeConfigsByType(configs, 1)
} else {
home := playletser.FetchHomeOrEmpty(c)
data["homeConfigPlaylets"] = playletser.HomeConfigsByType(home.HomeConfigData, 1)
}
}
// PC 搜索下拉热搜:GET /api/app/playlet/hot-like
if _, ok := data["searchTrends"]; !ok {
trends := hotSearchTrends(playletser.FetchHotLikeOrEmpty(c).HotSearch)
if len(trends) > 8 {
trends = trends[:8]
}
data["searchTrends"] = trends
}
// 公共配置:GET /api/app/config/info → config / appConfig
if _, ok := data["appConfig"]; !ok {
vm := configser.FetchAppConfigVMOrEmpty(c)
data["appConfig"] = vm
if _, ok := data["config"]; !ok {
data["config"] = vm.Config
}
} else if _, ok := data["config"]; !ok {
if vm, ok := data["appConfig"].(configser.AppConfigVM); ok {
data["config"] = vm.Config
}
}
data["trackerAppId"] = configser.ResolveTrackerAppID()
attachTrackerPage(c.Request.URL.Path, code, data)
attachPageSEO(c, code, data)
c.HTML(code, name, data)
}
// HTMLOK 以 200 渲染页面。
func HTMLOK(c *gin.Context, name string, data gin.H) {
if pageRepresentsMissingResource(c, data) {
NotFound(c)
return
}
HTML(c, http.StatusOK, name, data)
}
func toInt64(v any) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case int32:
return int64(n)
case float64:
return int64(n)
case string:
i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
return i
default:
return 0
}
}
// attachPageSEO 在一个入口统一生成页面级 SEO 信号,避免各模板各自维护后产生冲突。
func attachPageSEO(c *gin.Context, code int, data gin.H) {
if c == nil || c.Request == nil {
return
}
cfg, ok := data["appConfig"].(configser.AppConfigVM)
if !ok {
cfg = configser.FetchAppConfigVMOrEmpty(c)
data["appConfig"] = cfg
}
path := c.Request.URL.Path
pageTitle, _ := data["title"].(string)
pageTitle = strings.TrimSpace(pageTitle)
seoInfo := pageSEOFromData(data["seoInfo"])
siteName := firstNonEmptySEO(seoInfo.WebSiteName, cfg.SeoInfo.WebSiteName, "黄果短剧")
routeConfigSEO, _ := data["routeConfigSEO"].(bool)
if !routeConfigSEO {
seoInfo = replaceSitewideSEOWithRouteSEO(path, data, seoInfo, pageSEOFromData(cfg.SeoInfo), siteName)
}
navigationSEOApplied := false
if categories, ok := data["categoryList"].([]translationser.TranslationCategory); ok {
headerNav := translationser.BuildHeaderNav(&translationser.AppTranslationInfo{CategoryList: categories})
activeKey, _ := data["navActive"].(string)
if navSEO, found := translationser.FindHeaderNavSEOByPath(headerNav, path); found {
// 与 jha-1040-cgdj-web 一致:导航分类页面以分类接口 TDK 为主,页面接口只补缺。
seoInfo = playletser.MergeSeoInfo(playletser.SeoInfo{
Title: navSEO.Title,
Description: navSEO.Description,
Keywords: navSEO.Keyworld,
}, seoInfo)
navigationSEOApplied = true
} else if !routeConfigSEO {
// 当前项目的详情/播放页会复用 navActive;这类页面保持内容 TDK 为主,导航只补缺。
if navSEO, found := translationser.FindHeaderNavSEO(headerNav, activeKey, path); found {
seoInfo = playletser.MergeSeoInfo(seoInfo, playletser.SeoInfo{
Title: navSEO.Title,
Description: navSEO.Description,
Keywords: navSEO.Keyworld,
})
}
}
}
if selections, ok := data["seoSelections"].([]playletser.SEOSelection); ok && len(selections) > 0 {
seoInfo = playletser.DifferentiateSeoInfo(seoInfo, selections)
}
if seoInfo.Title == "" {
seoInfo.Title = firstNonEmptySEO(pageTitle, defaultPageTitle(path, siteName))
}
if seoInfo.Description == "" {
seoInfo.Description = defaultMetaDescription(path, seoInfo.Title, data, siteName)
}
if len(seoInfo.Keywords) == 0 {
seoInfo.Keywords = defaultMetaKeywords(path, siteName)
}
if pageNum := paginationPage(data["pagination"]); pageNum > 1 {
seoInfo = playletser.PaginateSeoInfo(seoInfo, seoInfo.Description, pageNum)
}
metaTitle := seoInfo.Title
if !navigationSEOApplied {
metaTitle = ensureBrandTitle(metaTitle, siteName)
}
data["title"] = metaTitle
data["metaTitle"] = metaTitle
data["metaDescription"] = seoInfo.Description
data["metaKeywords"] = strings.Join(seoInfo.Keywords, ",")
data["siteName"] = siteName
data["seoInfo"] = seoInfo
siteURL := seoser.ResolveSiteURL(c, cfg)
data["siteURL"] = siteURL
data["foreverUrlAddr"] = siteURL
data["seoVerify"] = seoser.VerifyFromConfig()
data["ogType"] = pageOGType(path)
data["ogImage"] = pageOGImage(siteURL, path, data)
attachPaginationLinks(data, siteURL)
robots, _ := data["robots"].(string)
robots = strings.TrimSpace(robots)
if robots == "" {
robots = defaultRobotsForPath(path, code)
}
if !robotsHasNoIndex(robots) && pageShouldNotIndex(c, data) {
robots = "noindex,follow"
}
data["robots"] = robots
noindex := robotsHasNoIndex(robots)
if noindex {
data["noindex"] = true
c.Header("X-Robots-Tag", strings.ReplaceAll(robots, ",", ", "))
} else {
delete(data, "noindex")
}
canonicalEligible := code >= http.StatusOK && code < http.StatusMultipleChoices && !noindex && seoser.IsIndexablePath(path)
if canonicalEligible {
canonicalURL := seoser.CanonicalWithoutQuery(seoser.PageCanonicalURL(siteURL, cfg.ForeverURLAddr, c))
if canonicalPath, _ := data["canonicalPath"].(string); strings.HasPrefix(strings.TrimSpace(canonicalPath), "/") {
canonicalURL = seoser.CanonicalWithoutQuery(seoser.AbsURL(siteURL, strings.TrimSpace(canonicalPath)))
}
canonicalURL = seoser.CanonicalWithoutQuery(seoser.ContentCanonicalURL(siteURL, canonicalURL, map[string]any(data)))
data["canonicalURL"] = canonicalURL
data["jsonLD"] = seoser.PageJSONLD(siteURL, canonicalURL, c.Request.URL.RequestURI(), map[string]any(data), cfg)
} else {
delete(data, "canonicalURL")
delete(data, "jsonLD")
}
}
func pageShouldNotIndex(c *gin.Context, data gin.H) bool {
if isNavPage, _ := data["isNavPage"].(bool); isNavPage {
return true
}
if c != nil && c.Request != nil {
path := c.Request.URL.Path
if isEpisodePlayPath(path, c.Param("ep")) {
return true
}
if strings.HasPrefix(path, "/series-play/") {
if canWatch, ok := data["canWatch"].(bool); ok && !canWatch {
return true
}
}
}
return pageRepresentsMissingResource(c, data)
}
// pageRepresentsMissingResource 识别不存在的实体、非法分页与越界分页。
// 这些请求必须返回真实 404,不能仅依赖 noindex 掩盖 200 软 404。
func pageRepresentsMissingResource(c *gin.Context, data gin.H) bool {
if valid, exists := data["seoEntityValid"].(bool); exists && !valid {
return true
}
pageNum := int(toInt64(data["pageNum"]))
if c != nil {
if rawPage := strings.TrimSpace(c.Param("page")); rawPage != "" {
requestedPage, err := strconv.Atoi(rawPage)
// 分页二级路由只允许第 2 页及以后;第 1 页使用更短的规范地址。
if err != nil || requestedPage <= 1 {
return true
}
pageNum = requestedPage
}
}
pageSize := int(toInt64(data["pageSize"]))
totalCount, hasTotal := data["totalCount"]
return hasTotal && playletser.PageOutOfRange(pageNum, pageSize, toInt64(totalCount))
}
func pageSEOFromData(value any) playletser.SeoInfo {
var seo playletser.SeoInfo
switch value := value.(type) {
case playletser.SeoInfo:
seo = value
case *playletser.SeoInfo:
if value != nil {
seo = *value
}
case videoser.SeoInfo:
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
case *videoser.SeoInfo:
if value != nil {
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
}
case configser.SeoInfo:
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
case *configser.SeoInfo:
if value != nil {
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
}
}
seo.Normalize()
return seo
}
// replaceSitewideSEOWithRouteSEO 只替换接口误复用的全站首页 TDK。
// 页面接口真正下发的独立 SEO 仍保持最高优先级。
func replaceSitewideSEOWithRouteSEO(path string, data gin.H, seo, siteDefault playletser.SeoInfo, siteName string) playletser.SeoInfo {
routeSEO, ok := routeSpecificSEO(path, data, siteName)
if !ok {
return seo
}
if sameNonEmptySEOValue(seo.Title, siteDefault.Title) {
seo.Title = routeSEO.Title
}
if sameNonEmptySEOValue(seo.Description, siteDefault.Description) {
seo.Description = routeSEO.Description
}
if sameSEOKeywords(seo.Keywords, siteDefault.Keywords) {
seo.Keywords = append([]string(nil), routeSEO.Keywords...)
}
seo.Normalize()
return seo
}
func isEpisodePlayPath(path, epParam string) bool {
if strings.TrimSpace(epParam) != "" {
return true
}
segments := strings.Split(strings.Trim(path, "/"), "/")
return len(segments) >= 3 && segments[0] == "play" && segments[1] != "" && segments[2] != ""
}
func routeSpecificSEO(path string, data gin.H, siteName string) (playletser.SeoInfo, bool) {
path = strings.TrimRight(strings.TrimSpace(path), "/")
title := ""
switch {
case path == "/list" || strings.HasPrefix(path, "/list/"):
title = firstNonEmptySEO(dataString(data, "pageHeading"), "全部短剧")
case path == "/series" || strings.HasPrefix(path, "/series/"):
title = firstNonEmptySEO(dataString(data, "pageHeading"), "剧集与动画")
case path == "/topic" || strings.HasPrefix(path, "/topic/"):
title = "专题剧单"
case strings.HasPrefix(path, "/topic-detail/"):
if name := dataString(data, "name"); name != "" {
title = name + "专题短剧"
} else {
title = "短剧专题"
}
case strings.HasPrefix(path, "/tags/"):
if name := dataString(data, "tagName"); name != "" {
title = name + "标签短剧"
} else {
title = "标签短剧"
}
case strings.HasPrefix(path, "/more-list/"):
title = firstNonEmptySEO(dataString(data, "configName"), "精选短剧")
case strings.HasPrefix(path, "/more-series-list/"):
title = firstNonEmptySEO(dataString(data, "configName"), "精选剧集")
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
title = "短剧排行榜"
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
title = "热门短剧推荐"
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
title = "最近上新短剧"
default:
return playletser.SeoInfo{}, false
}
return playletser.SeoInfo{
Title: title,
Description: defaultMetaDescription(path, title, data, siteName),
Keywords: defaultMetaKeywords(path, siteName),
}, true
}
func dataString(data gin.H, key string) string {
value, _ := data[key].(string)
return strings.TrimSpace(value)
}
func sameNonEmptySEOValue(value, generic string) bool {
value = strings.TrimSpace(value)
generic = strings.TrimSpace(generic)
return generic != "" && strings.EqualFold(value, generic)
}
func sameSEOKeywords(value, generic []string) bool {
if len(generic) == 0 || len(value) != len(generic) {
return false
}
for i := range value {
if !strings.EqualFold(strings.TrimSpace(value[i]), strings.TrimSpace(generic[i])) {
return false
}
}
return true
}
func paginationPage(value any) int {
switch value := value.(type) {
case playletser.PaginationVM:
return value.Page
case *playletser.PaginationVM:
if value != nil {
return value.Page
}
}
return 0
}
func attachPaginationLinks(data gin.H, siteURL string) {
var pagination playletser.PaginationVM
switch value := data["pagination"].(type) {
case playletser.PaginationVM:
pagination = value
case *playletser.PaginationVM:
if value == nil {
return
}
pagination = *value
default:
return
}
if !pagination.Visible {
return
}
if pagination.HasPrev && strings.TrimSpace(pagination.PrevURL) != "" {
data["paginationPrevURL"] = seoser.AbsURL(siteURL, pagination.PrevURL)
}
if pagination.HasNext && strings.TrimSpace(pagination.NextURL) != "" {
data["paginationNextURL"] = seoser.AbsURL(siteURL, pagination.NextURL)
}
}
func defaultRobotsForPath(path string, code int) string {
if code >= http.StatusBadRequest {
return "noindex,follow"
}
if seoser.IsIndexablePath(path) {
return "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1"
}
if seoser.IsPrivatePath(path) {
return "noindex,nofollow"
}
return "noindex,follow"
}
func robotsHasNoIndex(robots string) bool {
for _, directive := range strings.Split(strings.ToLower(robots), ",") {
if strings.TrimSpace(directive) == "noindex" {
return true
}
}
return false
}
func defaultPageTitle(path, siteName string) string {
path = strings.TrimRight(strings.TrimSpace(path), "/")
if siteName == "" {
siteName = "黄果短剧"
}
switch {
case path == "":
return siteName + " - 热门短剧在线看"
case path == "/about":
return "关于我们"
case path == "/nav" || path == "/dh":
return siteName + " · 官方导航"
case path == "/help":
return "常见问题"
case path == "/terms":
return "用户条款"
case path == "/privacy":
return "隐私政策"
case path == "/2257":
return "2257 合规声明"
case path == "/dmca":
return "DMCA 版权声明"
case path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile":
return "搜索"
case path == "/list" || strings.HasPrefix(path, "/list/"):
return "全部短剧"
case path == "/series" || strings.HasPrefix(path, "/series/"):
return "剧集与动画"
case path == "/topic" || strings.HasPrefix(path, "/topic/"):
return "专题剧单"
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
return "短剧排行榜"
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
return "最近上新"
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
return "热门推荐"
case strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/"):
return "在线播放"
default:
return siteName
}
}
func defaultMetaDescription(path, title string, data gin.H, siteName string) string {
for _, key := range []string{"description", "desc", "heroDesc", "categoryDescription", "tagDescription", "topicDescription"} {
if value, _ := data[key].(string); strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
if siteName == "" {
siteName = "黄果短剧"
}
cleanTitle := strings.TrimSpace(strings.TrimSuffix(title, " - "+siteName))
path = strings.TrimRight(strings.TrimSpace(path), "/")
if isNavPage, _ := data["isNavPage"].(bool); isNavPage {
return siteName + "官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。"
}
switch {
case path == "":
return siteName + "汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。"
case path == "/about":
return "了解" + siteName + "的平台理念、内容特色与联系方式。"
case path == "/nav" || path == "/dh":
return siteName + "官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。"
case path == "/help":
return "查看" + siteName + "常见问题、播放帮助、账号使用与客服支持信息。"
case path == "/terms":
return "查看" + siteName + "用户服务条款及平台使用规则。"
case path == "/privacy":
return "查看" + siteName + "隐私政策与个人信息保护说明。"
case path == "/2257":
return "查看" + siteName + " 18 U.S.C. 第 2257 章合规声明、年龄限制与内容反馈处理原则。"
case path == "/dmca":
return "查看" + siteName + " DMCA 版权声明、投诉材料要求与处理流程说明。"
case path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile":
return "在" + siteName + "搜索剧名、演员、标签与相关作品。"
case strings.HasPrefix(path, "/play/"):
return "在线观看《" + cleanTitle + "》,查看选集、剧情信息与相关推荐。"
case strings.HasPrefix(path, "/series-play/"):
return "在线观看《" + cleanTitle + "》,浏览视频信息与相关推荐。"
case path == "/list" || strings.HasPrefix(path, "/list/"):
return "浏览" + siteName + "全部短剧,按题材、更新状态和热度筛选精彩作品。"
case path == "/series" || strings.HasPrefix(path, "/series/"):
return "浏览" + siteName + "精选剧集与动画,按类型、专题和热度发现更多内容。"
case path == "/topic" || strings.HasPrefix(path, "/topic/") || strings.HasPrefix(path, "/topic-detail/"):
return "浏览" + siteName + "精选专题剧单,发现同题材热门作品与持续更新内容。"
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
return "查看" + siteName + "实时更新的短剧排行榜,浏览热播榜、新剧榜与追剧榜。"
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
return "浏览" + siteName + "热门推荐,发现近期高热度、广受欢迎的精选短剧。"
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
return "查看" + siteName + "最近上新的短剧与剧集,及时追更最新内容。"
default:
return cleanTitle + ",浏览" + siteName + "相关内容与推荐。"
}
}
func defaultMetaKeywords(path, siteName string) []string {
path = strings.TrimRight(strings.TrimSpace(path), "/")
if siteName == "" {
siteName = "黄果短剧"
}
switch {
case path == "":
return []string{siteName, "短剧", "在线短剧", "热门短剧"}
case path == "/list" || strings.HasPrefix(path, "/list/"):
return []string{"短剧列表", "短剧分类", "在线短剧", siteName}
case path == "/series" || strings.HasPrefix(path, "/series/"):
return []string{"剧集", "动画", "在线观看", siteName}
case strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/"):
return []string{"在线播放", "短剧播放", siteName}
case path == "/topic" || strings.HasPrefix(path, "/topic/") || strings.HasPrefix(path, "/topic-detail/"):
return []string{"短剧专题", "专题剧单", siteName}
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
return []string{"短剧排行榜", "热门短剧", siteName}
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
return []string{"热门短剧", "短剧推荐", siteName}
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
return []string{"最新短剧", "最近上新", siteName}
case strings.HasPrefix(path, "/tags/"):
return []string{"标签短剧", "短剧分类", siteName}
case strings.HasPrefix(path, "/more-list/"):
return []string{"精选短剧", "热门短剧", siteName}
case strings.HasPrefix(path, "/more-series-list/"):
return []string{"精选剧集", "剧集推荐", siteName}
default:
return []string{siteName, "在线短剧"}
}
}
func ensureBrandTitle(title, siteName string) string {
title = strings.TrimSpace(title)
siteName = strings.TrimSpace(siteName)
if title == "" {
return siteName
}
if siteName == "" || strings.Contains(title, siteName) || strings.Contains(title, "黄果短剧") {
return title
}
return title + " - " + siteName
}
func pageOGType(path string) string {
path = strings.TrimRight(strings.TrimSpace(path), "/")
if strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/") {
return "video.episode"
}
if path == "/terms" || path == "/privacy" || path == "/2257" || path == "/dmca" {
return "article"
}
return "website"
}
func pageOGImage(siteURL string, path string, data gin.H) string {
path = strings.TrimRight(strings.TrimSpace(path), "/")
// 播放页分享小卡片统一用品牌 OG 图,避免微信等抓取剧集封面。
if strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/") {
return seoser.AbsURL(siteURL, "/static/og-default.png")
}
if playlet, ok := data["playlet"].(*playletser.PlayletInfoResp); ok && playlet != nil {
if cover := firstNonEmptySEO(playlet.Cover, playlet.Banner); cover != "" {
return seoser.AbsURL(siteURL, cover)
}
}
if cover, _ := data["cover"].(string); strings.TrimSpace(cover) != "" {
return seoser.AbsURL(siteURL, cover)
}
return seoser.AbsURL(siteURL, "/static/og-default.png")
}
func firstNonEmptySEO(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
+176
View File
@@ -0,0 +1,176 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"videoweb/app/service/configser"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
func TestAttachPageSEOUsesPageTypeInsteadOfRequestDomain(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
requestURL string
foreverURL string
status int
data gin.H
wantRobots string
wantRobotsHeader string
wantCanonical string
wantJSONLD bool
}{
{
name: "public page on alternate domain remains indexable",
requestURL: "https://alternate.example/",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{},
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
wantCanonical: "https://primary.example/",
wantJSONLD: true,
},
{
name: "public page without permanent address remains indexable",
requestURL: "https://alternate.example/about",
status: http.StatusOK,
data: gin.H{},
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
wantCanonical: "https://alternate.example/about",
wantJSONLD: true,
},
{
name: "search page is not indexable",
requestURL: "https://alternate.example/search/short-drama",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
{
name: "private page is neither indexed nor followed",
requestURL: "https://alternate.example/profile",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{},
wantRobots: "noindex,nofollow",
wantRobotsHeader: "noindex, nofollow",
},
{
name: "navigation page is not indexable",
requestURL: "https://alternate.example/",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{"isNavPage": true},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
{
name: "missing public entity is not indexable",
requestURL: "https://alternate.example/tags/missing",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{"seoEntityValid": false},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
{
name: "error page is not indexable",
requestURL: "https://alternate.example/about",
foreverURL: "https://primary.example",
status: http.StatusNotFound,
data: gin.H{},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
{
name: "canonical play page stays indexable",
requestURL: "https://alternate.example/play/zhuazou",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{},
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
wantCanonical: "https://primary.example/play/zhuazou",
wantJSONLD: true,
},
{
name: "play page with chapter still canonicalizes to two-segment path",
requestURL: "https://alternate.example/play/zhuazou",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{
"playlet": playletser.PlayletInfoResp{ID: 12, Slug: "zhuazou"},
"chapter": playletser.PlayletChapterInfoResp{ID: 901, PlayletID: 12},
},
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
wantCanonical: "https://primary.example/play/zhuazou",
wantJSONLD: true,
},
{
name: "episode play page is not indexable",
requestURL: "https://alternate.example/play/zhuazou/12",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
{
name: "unwatchable series play page is not indexable",
requestURL: "https://alternate.example/series-play/film-1",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{"canWatch": false},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
response := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(response)
ctx.Request = httptest.NewRequest(http.MethodGet, tt.requestURL, nil)
data := gin.H{
"appConfig": configser.AppConfigVM{
ForeverURLAddr: tt.foreverURL,
},
}
for key, value := range tt.data {
data[key] = value
}
attachPageSEO(ctx, tt.status, data)
if got := data["robots"]; got != tt.wantRobots {
t.Fatalf("robots = %q, want %q", got, tt.wantRobots)
}
if got := response.Header().Get("X-Robots-Tag"); got != tt.wantRobotsHeader {
t.Fatalf("X-Robots-Tag = %q, want %q", got, tt.wantRobotsHeader)
}
canonicalValue, hasCanonical := data["canonicalURL"]
if tt.wantCanonical == "" {
if hasCanonical {
t.Fatalf("canonicalURL must be absent, got %#v", canonicalValue)
}
} else {
canonical, ok := canonicalValue.(string)
if !hasCanonical || !ok || canonical != tt.wantCanonical {
t.Fatalf("canonicalURL = %#v, want %q", canonicalValue, tt.wantCanonical)
}
}
jsonLD, hasJSONLD := data["jsonLD"]
if hasJSONLD != tt.wantJSONLD {
t.Fatalf("jsonLD presence = %v, want %v (value %#v)", hasJSONLD, tt.wantJSONLD, jsonLD)
}
})
}
}
+284
View File
@@ -0,0 +1,284 @@
package web
import (
"fmt"
"net/url"
"strings"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
type searchTrend struct {
Rank int
Title string
Heat string
Badge string
}
// SearchMobile 移动端搜索入口。
// 热搜 + 猜你喜欢:GET /api/app/playlet/hot-like
// 联想:前端调用 GET /api/web/search/suggest → 上游 /api/app/playlet/search/suggest
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_hot_like
func SearchMobile(c *gin.Context) {
if hasLegacySearchQuery(c) {
NotFound(c)
return
}
raw := playletser.FetchHotLikeOrEmpty(c)
trends := hotSearchTrends(raw.HotSearch)
recommended := raw.GuessLike
if len(recommended) > 15 {
recommended = recommended[:15]
}
HTMLOK(c, "searchMobile.html", gin.H{
"title": "搜索 - 黄果短剧",
"query": searchQueryFromRequest(c),
"trends": trends,
"recommended": recommended,
"hotLikeDebug": gin.H{
"path": "/api/app/playlet/hot-like",
"response": raw,
},
})
}
func hotSearchTrends(items []playletser.PlayletHotSearchItem) []searchTrend {
out := make([]searchTrend, 0, len(items))
for i, item := range items {
keyword := strings.TrimSpace(item.Keyword)
if keyword == "" {
continue
}
rank := i + 1
badge := ""
switch rank {
case 1:
badge = "热"
case 2:
badge = "新"
}
out = append(out, searchTrend{
Rank: rank,
Title: keyword,
Heat: formatSearchHeat(item.Count),
Badge: badge,
})
}
return out
}
func formatSearchHeat(n int64) string {
if n <= 0 {
return "0"
}
if n < 10000 {
return fmt.Sprintf("%d", n)
}
v := float64(n) / 10000
if v >= 100 {
return fmt.Sprintf("%.0f 万", v)
}
return fmt.Sprintf("%.1f 万", v)
}
// Search 搜索结果页:GET /api/app/playlet/search。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_search
// 无结果时「大家都在看」:GET /api/app/playlet/all/list(取 6 条最热)。
func Search(c *gin.Context) {
if hasLegacySearchQuery(c) {
NotFound(c)
return
}
keyword := searchQueryFromRequest(c)
pageStr := strings.TrimSpace(c.Param("page"))
q := playletser.ParseSearchQuery(keyword, pageStr)
list := []playletser.CommonData(nil)
recommended := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var searchDebug any
var watchingDebug any
if q.Keyword != "" {
raw := playletser.FetchSearchOrEmpty(c, q)
searchDebug = gin.H{
"path": "/api/app/playlet/search",
"keyword": q.Keyword,
"pageNum": q.PageNum,
"pageSize": q.PageSize,
"response": raw,
}
if raw != nil {
list = raw.List
total = raw.TotalCount
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
if strings.TrimSpace(raw.Keyword) != "" {
keyword = raw.Keyword
}
}
} else {
searchDebug = gin.H{
"path": "/api/app/playlet/search",
"keyword": "",
"note": "empty keyword, skip upstream",
}
}
if len(list) == 0 {
watchingQ := playletser.AllListQuery{
PageNum: 1,
PageSize: 6,
SortType: 2, // 最热
UpdateStatus: 0, // 全部
}
watching := playletser.FetchAllListOrEmpty(c, watchingQ)
watchingDebug = gin.H{
"path": "/api/app/playlet/all/list",
"pageNum": watchingQ.PageNum,
"pageSize": watchingQ.PageSize,
"sortType": watchingQ.SortType,
"response": watching,
}
if watching != nil {
recommended = watching.List
if len(recommended) > 6 {
recommended = recommended[:6]
}
}
}
HTMLOK(c, "search.html", gin.H{
"title": "搜索 - 黄果短剧",
"query": keyword,
"hasResults": len(list) > 0,
"resultCount": total,
"works": list,
"recommended": recommended,
"pageNum": pageNum,
"pageSize": pageSize,
"pagination": playletser.BuildSearchPagination(q, total),
"searchDebug": searchDebug,
"watchingDebug": watchingDebug,
})
}
type searchSuggestFeature struct {
Title string `json:"title"`
Cover string `json:"cover"`
URL string `json:"url"`
Meta string `json:"meta"`
}
type searchSuggestItem struct {
Title string `json:"title"`
Kind string `json:"kind"`
Query string `json:"query"`
URL string `json:"url"`
}
// SearchSuggest PC 顶栏输入联想:GET /api/web/search/suggest?keyword=
// 上游:GET /api/app/playlet/search/suggest → proto.PlayletSearchSuggestResp
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_search_suggest
func SearchSuggest(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("keyword"))
if keyword == "" {
keyword = strings.TrimSpace(c.Query("q"))
}
if keyword == "" {
c.JSON(200, gin.H{"code": 200, "keyword": "", "feature": nil, "items": []searchSuggestItem{}})
return
}
raw := playletser.FetchSearchSuggestOrEmpty(c, keyword)
if kw := strings.TrimSpace(raw.Keyword); kw != "" {
keyword = kw
}
var feature *searchSuggestFeature
items := make([]searchSuggestItem, 0, len(raw.List)+1)
for _, item := range raw.List {
title := playletser.PlayletDisplayName(item.PlayName, item.Title)
if title == "" {
continue
}
href := playletser.PlayPath(item.Slug, item.ID)
if feature == nil {
feature = &searchSuggestFeature{
Title: title,
Cover: strings.TrimSpace(item.Cover),
URL: href,
Meta: buildSearchSuggestMeta(item),
}
continue
}
items = append(items, searchSuggestItem{
Title: title,
Kind: "短剧 剧集",
Query: title,
URL: href,
})
}
items = append(items, searchSuggestItem{
Title: keyword,
Kind: "搜索",
Query: keyword,
URL: playletser.SearchResultsURL(keyword),
})
c.JSON(200, gin.H{
"code": 200,
"keyword": keyword,
"feature": feature,
"items": items,
})
}
func buildSearchSuggestMeta(item playletser.CommonData) string {
parts := make([]string, 0, 4)
for _, name := range item.ThemeNames {
name = strings.TrimSpace(name)
if name != "" {
parts = append(parts, name)
}
}
for _, name := range item.TagNames {
name = strings.TrimSpace(name)
if name != "" && len(parts) < 2 {
parts = append(parts, name)
}
}
if item.TotalEpisode > 0 {
parts = append(parts, fmt.Sprintf("全 %d 集", item.TotalEpisode))
} else if item.UpdateStatus == 1 {
parts = append(parts, "连载中")
}
if anchor := strings.TrimSpace(item.Anchor); anchor != "" && anchor != "暂无" && anchor != "原创" {
parts = append(parts, "主演 "+anchor)
}
return strings.Join(parts, " ")
}
func hasLegacySearchQuery(c *gin.Context) bool {
query := c.Request.URL.Query()
return query.Has("q") || query.Has("page")
}
func searchQueryFromRequest(c *gin.Context) string {
q := strings.TrimSpace(c.Param("q"))
if q == "" {
return ""
}
if decoded, err := url.PathUnescape(q); err == nil {
return strings.TrimSpace(decoded)
}
return q
}
+56
View File
@@ -0,0 +1,56 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/configser"
"videoweb/app/service/seoser"
"github.com/gin-gonic/gin"
)
// RobotsTXT 输出可抓取规则及绝对 sitemap 地址。
func RobotsTXT(c *gin.Context) {
cfg := configser.FetchAppConfigVMOrEmpty(c)
siteURL := seoser.ResolveSiteURL(c, cfg)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, seoser.BuildRobotsTXT(siteURL))
}
// LLMSTXT 输出简洁站点说明及核心页面的规范地址,供 AI 抓取工具发现内容。
func LLMSTXT(c *gin.Context) {
cfg := configser.FetchAppConfigVMOrEmpty(c)
writeLLMSTXT(c, cfg)
}
func writeLLMSTXT(c *gin.Context, cfg configser.AppConfigVM) {
siteURL := seoser.ResolveSiteURL(c, cfg)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, seoser.BuildLLMSTXT(siteURL, cfg))
}
// SEOVerifyFile 输出 IndexNow 所有权验证文本。
func SEOVerifyFile(c *gin.Context) {
key := strings.TrimSuffix(strings.TrimPrefix(c.Request.URL.Path, "/"), ".txt")
configured := seoser.VerifyFromConfig().IndexNowKey
if key == "" || key != configured {
NotFound(c)
return
}
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, configured)
}
// DownloadApp 将站内统一下载入口重定向到配置的官方地址,避免模板回退链接产生 404。
func DownloadApp(c *gin.Context) {
cfg := configser.FetchAppConfigVMOrEmpty(c)
target := strings.TrimSpace(cfg.AppAddr)
if target == "" {
target = strings.TrimSpace(cfg.OfficialAppAddr)
}
if target == "" {
target = "/"
}
c.Header("X-Robots-Tag", "noindex, nofollow")
c.Redirect(http.StatusFound, target)
}
+30
View File
@@ -0,0 +1,30 @@
package web
import "videoweb/app/service/playletser"
func rankingSEOSelections(value string) []playletser.SEOSelection {
if value == "" {
return nil
}
return []playletser.SEOSelection{{Dimension: "榜单", Value: value}}
}
func moreListSEOSelections(q playletser.MoreListQuery, themes []playletser.PlayletThemeItem) []playletser.SEOSelection {
selections := make([]playletser.SEOSelection, 0, 2)
if q.TimeRange == 1 {
selections = append(selections, playletser.SEOSelection{Dimension: "时间", Value: "本周"})
} else if q.TimeRange == 2 {
selections = append(selections, playletser.SEOSelection{Dimension: "时间", Value: "本月"})
}
themeName := q.ThemeSlug
for _, theme := range themes {
if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) {
themeName = theme.Name
break
}
}
if themeName != "" {
selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: themeName})
}
return selections
}
+103
View File
@@ -0,0 +1,103 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// Series 剧集列表页:GET /api/app/video/all/list + topic/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_all_list
func Series(c *gin.Context) {
q := videoser.ParseSeriesListQuery(c)
topics := videoser.FetchVideoTopicListOrEmpty(c, q.MediaType)
topicResolved := seriesTopicResolved(q, topics)
raw := videoser.FetchVideoAllListOrEmpty(c, q)
pageHeading, seoSelections := seriesSEOContext(q, topics)
title := pageHeading + " - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
list := []videoser.VideoAllListItemResp(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo videoser.SeoInfo
var advitise any
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
advitise = raw.Advitise
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
HTMLOK(c, "series.html", gin.H{
"title": title,
"navActive": "series",
"seoInfo": seo,
"seoSelections": seoSelections,
"pageHeading": pageHeading,
"mediaType": q.MediaType,
"topicId": q.TopicID,
"sortType": q.SortType,
"seoEntityValid": topicResolved || total > 0,
"topicList": topics,
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"advitise": advitise,
"pagination": videoser.BuildSeriesListPagination(q, total),
"canonicalPath": q.URL(),
})
}
func seriesTopicResolved(q videoser.SeriesListQuery, topics []videoser.VideoTopicInfo) bool {
if q.TopicID <= 0 {
return true
}
for _, topic := range topics {
if topic.ID == q.TopicID {
return true
}
}
return false
}
func seriesSEOContext(q videoser.SeriesListQuery, topics []videoser.VideoTopicInfo) (string, []playletser.SEOSelection) {
heading := "剧集与动画"
selections := make([]playletser.SEOSelection, 0, 3)
if q.MediaType == videoser.MediaFilm || q.MediaType == videoser.MediaAnime {
mediaName := videoser.MediaTypeLabel(q.MediaType)
heading = mediaName
selections = append(selections, playletser.SEOSelection{Dimension: "类型", Value: mediaName})
}
if q.TopicID > 0 {
for _, topic := range topics {
if topic.ID == q.TopicID {
name := topic.DisplayName()
if name != "" {
heading = name + heading
selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: name})
}
break
}
}
}
if q.SortType == videoser.SortHeat {
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "最热"})
} else if q.SortType == videoser.SortCollect {
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"})
}
return heading, selections
}
+85
View File
@@ -0,0 +1,85 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/authser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// Setting 展示账号设置,字段来自最新的用户信息接口。
func Setting(c *gin.Context) {
data := gin.H{"title": "账号设置 - 黄果短剧", "navActive": "setting", "accountTab": "setting"}
loadAccountSummary(c, data, gin.H{})
HTMLOK(c, "setting.html", data)
}
// UpdateProfile 代理账号资料修改。可选字段必须显式传入,避免覆盖未修改内容。
func UpdateProfile(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "tip": "请先登录"})
return
}
var body authser.UpdateProfileReq
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "参数错误"})
return
}
if body.NickName != nil {
v := strings.TrimSpace(*body.NickName)
if v == "" || len([]rune(v)) > 40 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "昵称须为 1 至 40 个字符"})
return
}
body.NickName = &v
}
if body.Introduction != nil {
v := strings.TrimSpace(*body.Introduction)
if len([]rune(v)) > 300 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "简介不能超过 300 个字符"})
return
}
body.Introduction = &v
}
if body.Avatar != nil {
v := strings.TrimSpace(*body.Avatar)
if safeProfileImage(v) == "" || strings.HasPrefix(v, "/static/") {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "头像地址无效"})
return
}
body.Avatar = &v
}
if (body.OldPassword == nil) != (body.NewPassword == nil) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "请输入旧密码和新密码"})
return
}
if body.OldPassword != nil && (*body.OldPassword == "" || *body.NewPassword == "") {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "密码不能为空"})
return
}
if body.NickName == nil && body.Introduction == nil && body.Avatar == nil && body.OldPassword == nil && body.PrivacyMode == nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "没有需要保存的修改"})
return
}
env, err := authser.UpdateProfile(c, body)
if err != nil {
log.Error("update profile failed", log.E(err))
c.JSON(http.StatusBadGateway, gin.H{"code": 500, "tip": "保存失败,请稍后重试"})
return
}
if env == nil || env.Code != 200 {
if env == nil {
c.JSON(http.StatusBadGateway, gin.H{"code": 500, "tip": "保存失败,请稍后重试"})
return
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "tip": firstNonEmptyAuth(env.Tip, env.Msg, "保存失败")})
return
}
if infoEnv, user, err := authser.GetInfo(c); err == nil && infoEnv != nil && infoEnv.Code == 200 && user != nil {
authser.SetAuthCookies(c, user)
}
c.JSON(http.StatusOK, gin.H{"code": 200, "tip": firstNonEmptyAuth(env.Tip, "保存成功")})
}
+89
View File
@@ -0,0 +1,89 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Tags 标签作品列表页:GET /api/app/playlet/tag/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_tag_list
func Tags(c *gin.Context) {
q := playletser.ParseTagListQuery(c)
tagOptions := playletser.FetchTagOptionsOrEmpty(c)
tagName := playletser.ResolveTagListIdentity(&q, tagOptions)
tagResolved := tagName != ""
raw := playletser.FetchTagListOrEmpty(c, q)
if tagName == "" && raw != nil {
if raw.SeoInfo.Title != "" {
tagName = raw.SeoInfo.Title
} else {
tagName = playletser.FindTagNameBySlug(raw.List, q.TagSlug)
if tagName == "" {
tagName = playletser.FindTagName(raw.List, q.TagID)
}
}
}
title := "标签 - 黄果短剧"
if raw != nil {
title = playletser.TagListPageTitle(tagName, q.PageNum, raw.SeoInfo)
} else if tagName != "" {
title = playletser.TagListPageTitle(tagName, q.PageNum, playletser.SeoInfo{})
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
desc := ""
if seo.Description != "" {
desc = seo.Description
} else if total > 0 {
desc = fmt.Sprintf("%d 部作品带有此标签", total)
}
seoSelections := make([]playletser.SEOSelection, 0, 2)
if tagName != "" {
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "标签", Value: tagName})
}
if q.SortType == 2 {
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "排序", Value: "最热"})
} else if q.SortType == 3 {
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"})
}
HTMLOK(c, "tags.html", gin.H{
"title": title,
"navActive": "list",
"seoInfo": seo,
"seoSelections": seoSelections,
"tagName": tagName,
"tagSlug": q.TagSlug,
"tagId": q.TagID,
"sortType": q.SortType,
"seoEntityValid": tagResolved || total > 0,
"relatedTags": playletser.RelatedTagOptions(tagOptions, q.TagID, q.TagSlug),
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"heroDesc": desc,
"pagination": playletser.BuildTagListPagination(q, total),
"canonicalPath": playletser.TagListCanonicalPath(q),
})
}
+8
View File
@@ -0,0 +1,8 @@
package web
import "github.com/gin-gonic/gin"
// Terms renders the static Terms of Service page.
func Terms(c *gin.Context) {
HTMLOK(c, "terms.html", gin.H{})
}
+58
View File
@@ -0,0 +1,58 @@
package web
import (
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Topic 专题剧单列表页:GET /api/app/playlet/topic/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_topic_list
func Topic(c *gin.Context) {
q := playletser.ParseTopicListQuery(c)
raw := playletser.FetchTopicListOrEmpty(c, q)
home := playletser.FetchHomeOrEmpty(c)
homeConfigData := playletser.ActiveHomeConfigs(home.HomeConfigData)
list := []playletser.PlayletTopicPageItem(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
var seoSelections []playletser.SEOSelection
if q.SortType == 2 {
seoSelections = []playletser.SEOSelection{{Dimension: "排序", Value: "最热"}}
}
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
// 首页“专题”区块的配置 TDK 优先;专题列表接口 SEO 仅补齐配置未填写的字段。
if configSEO, ok := playletser.FirstHomeConfigSEOByType(homeConfigData, 3); ok {
seo = playletser.MergeSeoInfo(configSEO, seo)
}
title := "专题剧单 - 黄果短剧"
if seo.Title != "" {
title = seo.Title
}
HTMLOK(c, "topic.html", gin.H{
"title": title,
"navActive": "topic",
"seoInfo": seo,
"routeConfigSEO": true,
"homeConfigData": homeConfigData,
"seoSelections": seoSelections,
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"sortType": q.SortType,
"pagination": playletser.BuildTopicListPagination(q, total),
"canonicalPath": q.URL(),
})
}
+97
View File
@@ -0,0 +1,97 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// TopicDetail 专题详情页:GET /api/app/playlet/topic/detail。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_topic_detail
func TopicDetail(c *gin.Context) {
q := playletser.ParseTopicDetailQuery(c)
playletser.ResolveTopicDetailID(c, &q)
raw := playletser.FetchTopicDetailOrEmpty(c, q)
if raw != nil {
if raw.Slug != "" {
q.Slug = raw.Slug
}
if raw.ID > 0 {
q.ID = raw.ID
}
}
name := ""
if raw != nil {
name = raw.Name
}
title := "专题 - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
} else if name != "" {
title = name + " - 黄果短剧"
}
list := []playletser.CommonData(nil)
more := []playletser.PlayletTopicPageItem(nil)
total := int64(0)
playletCount := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
desc := ""
heroCover := ""
covers := []string(nil)
if raw != nil {
list = raw.List
more = raw.MoreTopics
total = raw.TotalCount
playletCount = raw.PlayletCount
seo = raw.SeoInfo
desc = raw.Desc
heroCover = playletser.TopicDetailHeroCover(*raw)
covers = playletser.TopicDetailCoverURLs(*raw)
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
countText := "持续更新"
if playletCount > 0 {
countText = fmt.Sprintf("%d 部 持续更新", playletCount)
} else if total > 0 {
countText = fmt.Sprintf("%d 部 持续更新", total)
}
var seoSelections []playletser.SEOSelection
if q.SortType != 1 {
seoSelections = []playletser.SEOSelection{{Dimension: "排序", Value: playletser.TopicDetailSortLabel(q.SortType)}}
}
HTMLOK(c, "topicDetail.html", gin.H{
"title": title,
"navActive": "topic",
"seoInfo": seo,
"seoSelections": seoSelections,
"topic": raw,
"name": name,
"desc": desc,
"slug": q.Slug,
"topicId": q.ID,
"list": list,
"moreTopics": more,
"totalCount": total,
"playletCount": playletCount,
"countText": countText,
"pageNum": pageNum,
"pageSize": pageSize,
"sortType": q.SortType,
"sortLabel": playletser.TopicDetailSortLabel(q.SortType),
"seoEntityValid": q.ID > 0 && (name != "" || total > 0),
"heroCover": heroCover,
"covers": covers,
"pagination": playletser.BuildTopicDetailPagination(q, total),
"canonicalPath": q.URL(),
})
}
+80
View File
@@ -0,0 +1,80 @@
package web
import (
"net/http"
"strings"
)
// attachTrackerPage 为所有 HTML 页面补齐稳定的 pageKey/pageName,避免事件落入 unknown。
func attachTrackerPage(path string, code int, data map[string]any) {
if data == nil {
return
}
key, name := trackerPageFromRequest(path, code)
if current, _ := data["pageKey"].(string); strings.TrimSpace(current) == "" {
data["pageKey"] = key
}
if current, _ := data["pageName"].(string); strings.TrimSpace(current) == "" {
data["pageName"] = name
}
}
func trackerPageFromRequest(path string, code int) (string, string) {
if code >= http.StatusInternalServerError {
return "server_error", "服务异常页面"
}
if code >= http.StatusBadRequest {
return "not_found", "404页面"
}
return trackerPageFromPath(path)
}
func trackerPageFromPath(path string) (string, string) {
p := strings.TrimSuffix(strings.TrimSpace(path), "/")
if p == "" {
return "home", "首页"
}
rules := []struct {
prefix string
key string
name string
}{
{"/series-play", "series_play", "AI成人剧集播放页"},
{"/series", "series", "AI成人剧集"},
{"/play", "play", "短剧播放页"},
{"/detail", "detail", "短剧详情"},
{"/list", "list", "短剧列表"},
{"/recently-added", "recently_added", "最新短剧"},
{"/popular-recommend", "popular_recommend", "热门推荐"},
{"/rank", "rank", "排行榜"},
{"/topic-detail", "topic_detail", "专题详情"},
{"/topic", "topic", "专题列表"},
{"/tags", "tags", "标签详情"},
{"/more-series-list", "more_series_list", "成人剧集列表"},
{"/more-list", "more_list", "短剧扩展列表"},
{"/search-mobile", "search", "搜索结果页"},
{"/search", "search", "搜索结果页"},
{"/history", "history", "观看历史"},
{"/favorites", "favorites", "我的收藏"},
{"/purchased", "purchased", "已购剧集"},
{"/profile", "profile", "个人中心"},
{"/setting", "setting", "设置"},
{"/recharge", "recharge", "充值中心"},
{"/download-app", "download_app", "下载APP"},
{"/feedback", "feedback", "问题反馈"},
{"/help", "help", "帮助中心"},
{"/about", "about", "关于我们"},
{"/terms", "terms", "用户协议"},
{"/privacy", "privacy", "隐私政策"},
{"/2257", "compliance_2257", "2257合规声明"},
{"/dmca", "dmca", "DMCA版权声明"},
{"/login", "login", "登录"},
{"/register", "register", "注册"},
}
for _, rule := range rules {
if p == rule.prefix || strings.HasPrefix(p, rule.prefix+"/") {
return rule.key, rule.name
}
}
return "internal_other", "站内其他页面"
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"net/http"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// VideoCollectSet BFF:代理 POST /api/app/video/collect/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_collect_set
func VideoCollectSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsCollect *bool `json:"isCollect"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isCollect required",
"tip": "缺少视频 ID 或收藏状态",
"data": nil,
})
return
}
env, err := videoser.CollectSet(c, videoser.VideoCollectSetReq{
ID: body.ID,
IsCollect: *body.IsCollect,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"net/http"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// VideoLikeSet BFF:代理 POST /api/app/video/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_like_set
func VideoLikeSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isLike required",
"tip": "缺少视频 ID 或点赞状态",
"data": nil,
})
return
}
env, err := videoser.LikeSet(c, videoser.VideoLikeSetReq{
ID: body.ID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}