Initial commit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fish03
2026-09-23 14:43:16 +07:00
co-authored by Claude Opus 5.5
commit bef9f8b4ed
663 changed files with 81305 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.DS_Store
~$*
outputs/
.codex/
app/docs/
*.tar.gz
+36
View File
@@ -0,0 +1,36 @@
include tool/build/Makefile.help
## 更新APP文档
update_app_doc:
cd app && swag init --parseDependency --parseDepth 4
## 更新Web文档
update_h5_doc:
cd h5app && swag init --parseDependency --parseDepth 4
## 更新Web文档
update_web_doc:
cd web && swag init --parseDependency --parseDepth 4
## 更新所有项目文档
update_all_docs: update_app_doc update_h5_doc update_web_doc
## 更新测试服Web
deploy_test_web:
cd web && ./deploy_test.sh
## 更新测试服H5
deploy_test_h5:
cd h5app && ./deploy_test.sh
## 更新测试服APP
deploy_test_app:
cd app && ./deploy_test.sh
## 更新测试服APP + H5
deploy_test_h5_and_app: deploy_test_app deploy_test_h5
test:
./tool/build/check_syntax.sh
.DEFAULT_GOAL := help
Executable
+30
View File
@@ -0,0 +1,30 @@
GOPATH:=$(shell go env GOPATH)
GIT_BRANCH:=$(shell git symbolic-ref --short -q HEAD)
GIT_VERSION:=$(shell git log --pretty=oneline -p -1 | head -n 1 | awk '{print $$1}')
BUILD_DATE:=$(shell date '+%Y-%m-%d %H:%M:%S %z')
BUILD_IP:=$(shell ifconfig | grep inet | grep -v grep | grep -v "inet6" | grep -v "127.0.0.1" | awk '{print $$2}')
VERSON_PATH:=pfshop/pub/srvversion
APP_BUILD_FLAG:=-ldflags "-X '$(VERSON_PATH).GIT_BRANCH=$(GIT_BRANCH)' -X '$(VERSON_PATH).GIT_VERSION=$(GIT_VERSION)' -X '$(VERSON_PATH).BUILD_DATE=$(BUILD_DATE)' -X '$(VERSON_PATH).BUILD_IP=$(BUILD_IP)'"
.PHONY: test deploy
deploy:
@echo "deploy, GOPATH=$(GOPATH)"
go clean
rm -f web_mdfls_h5
env GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_mdfls_h5 -v
tar czf web_mdfls_h5.tar.gz web_mdfls_h5 templates/ static/ conf/m3u8.key
rm -f web_mdfls_h5
test:
@echo "test, GOPATH=$(GOPATH)"
GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_mdfls_h5
tar czf web_mdfls_h5.tar.gz web_mdfls_h5 templates/ static/ conf/m3u8.key
rm -f web_mdfls_h5
localtest:
@echo "test, GOPATH=$(GOPATH)"
go build $(APP_BUILD_FLAG) -o app
View File
+22
View File
@@ -0,0 +1,22 @@
package web
import (
"videoweb/app/service/applistser"
"github.com/gin-gonic/gin"
)
// Application 福利应用页 SSR:拉取 /api/app/applist/list。
func Application(c *gin.Context) {
raw := applistser.FetchListOrEmpty(c, 0)
vm := applistser.BuildPageVM(raw)
data := gin.H{
"title": "福利应用",
"navActive": "application",
"categories": vm.Categories,
}
if len(vm.Categories) == 0 {
setNoIndex(data, true)
}
HTMLOK(c, "application.html", data)
}
+548
View File
@@ -0,0 +1,548 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/authser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"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"`
}
type resetPasswordBody struct {
AccountNo string `json:"accountNo"`
Email string `json:"email"`
NewPassword string `json:"newPassword"`
}
type updateProfileBody struct {
Avatar string `json:"avatar"`
Introduction string `json:"introduction"`
NickName string `json:"nickName"`
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
PrivacyMode *bool `json:"privacyMode"`
}
// Register 代理上游注册,成功后写入登录 cookie。
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 = strings.TrimSpace(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 + "@qmdj.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.Code != 200 || user == nil {
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": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": env.Msg,
"tip": firstNonEmpty(env.Tip, "注册成功"),
"data": user,
})
}
// 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 = strings.TrimSpace(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.Code != 200 || user == nil {
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": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": env.Msg,
"tip": firstNonEmpty(env.Tip, "登录成功"),
"data": user,
})
}
// ResetPassword 代理上游找回密码接口。
func ResetPassword(c *gin.Context) {
var body resetPasswordBody
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)
if body.AccountNo == "" || body.Email == "" || body.NewPassword == "" {
c.JSON(http.StatusOK, gin.H{"code": 1001, "msg": "参数错误", "tip": "请填写账号、邮箱和新密码", "data": nil})
return
}
env, err := authser.ResetPassword(c, authser.ResetPasswordReq{
AccountNo: body.AccountNo,
Email: body.Email,
NewPassword: body.NewPassword,
})
if err != nil {
log.Error("reset password upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "reset password failed", "tip": "重置密码失败,请稍后重试", "data": nil})
return
}
tip := firstNonEmpty(env.Tip, env.Msg)
if env.Code == 200 {
tip = firstNonEmpty(tip, "密码重置成功")
} else {
tip = firstNonEmpty(tip, "密码重置失败")
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
}
// Guest 代理上游游客登录,成功后写入登录 cookie。
// 对应 POST /api/app/auth/guest;需传 devID,上游要求 X-User-Agent(由 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 = strings.TrimSpace(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.Code != 200 || user == nil {
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": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "游客登录成功"),
"data": user,
})
}
// UpdateProfile 代理上游修改资料,成功后刷新 getInfo 并写 cookie。
func UpdateProfile(c *gin.Context) {
var body updateProfileBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.Avatar = strings.TrimSpace(body.Avatar)
body.Introduction = strings.TrimSpace(body.Introduction)
body.NickName = strings.TrimSpace(body.NickName)
body.OldPassword = strings.TrimSpace(body.OldPassword)
body.NewPassword = strings.TrimSpace(body.NewPassword)
req := authser.UpdateProfileReq{
Avatar: body.Avatar,
Introduction: body.Introduction,
NickName: body.NickName,
OldPassword: body.OldPassword,
NewPassword: body.NewPassword,
PrivacyMode: body.PrivacyMode,
}
if req.Avatar == "" && req.Introduction == "" && req.NickName == "" && req.OldPassword == "" && req.NewPassword == "" && req.PrivacyMode == nil {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请至少修改一项资料",
"data": nil,
})
return
}
if (req.OldPassword == "") != (req.NewPassword == "") {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "修改密码需同时填写旧密码和新密码",
"data": nil,
})
return
}
env, err := authser.UpdateProfile(c, req)
if err != nil {
log.Error("update profile upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "update profile failed",
"tip": "保存失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = authser.UpdateProfile(c, req)
if err != nil {
log.Error("update profile retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "update profile failed",
"tip": "保存失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "保存失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
// 上游成功后拉最新资料写 cookie
_, user, infoErr := authser.GetInfo(c)
if infoErr != nil || user == nil {
log.Error("update profile getInfo failed", log.E(infoErr))
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": env.Msg,
"tip": firstNonEmpty(env.Tip, "保存成功"),
"data": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": env.Msg,
"tip": firstNonEmpty(env.Tip, "保存成功"),
"data": user,
})
}
// UploadImage 代理上游图片上传(头像不加 watermark)。
func UploadImage(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请选择图片文件",
"data": nil,
})
return
}
f, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "open file failed",
"tip": "读取图片失败",
"data": nil,
})
return
}
defer f.Close()
resp, err := authser.UploadImage(c, fileHeader.Filename, f)
if err != nil {
log.Error("upload image upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "upload failed",
"tip": "上传失败,请稍后重试",
"data": nil,
})
return
}
if resp == nil || resp.Code != 200 || resp.Data == nil || strings.TrimSpace(resp.Data.SourceNameFs) == "" {
tip := "上传失败"
msg := ""
code := 500
if resp != nil {
code = resp.Code
msg = resp.Msg
tip = firstNonEmpty(resp.Tip, resp.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(resp.Msg, "success"),
"tip": firstNonEmpty(resp.Tip, "上传成功"),
"data": gin.H{
"id": resp.Data.ID,
"sourceNameFs": resp.Data.SourceNameFs,
"url": resp.Data.SourceNameFs,
},
})
}
// GetUserInfo 代理上游 GET /api/app/auth/getInfo。
func GetUserInfo(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录",
"data": nil,
})
return
}
env, user, err := authser.GetInfo(c)
if err != nil {
log.Error("getInfo upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "getInfo failed",
"tip": "获取用户信息失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, user, err = authser.GetInfo(c)
if err != nil {
log.Error("getInfo retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "getInfo 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
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
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": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "成功"),
"data": user,
})
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+57
View File
@@ -0,0 +1,57 @@
package web
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// LoginEntryURL 首页弹窗登录入口;redirectPath 为登录成功后回跳路径(须为站内相对路径)。
func LoginEntryURL(redirectPath string) string {
redirectPath = strings.TrimSpace(redirectPath)
if i := strings.IndexByte(redirectPath, '?'); i >= 0 {
redirectPath = redirectPath[:i]
}
if !isSafeRedirect(redirectPath) || redirectPath == "/" {
return "/login/"
}
return "/login/return" + ensureRedirectTrailingSlash(redirectPath)
}
// RegisterEntryURL 首页弹窗注册入口。
func RegisterEntryURL() string {
return "/register/"
}
func ensureRedirectTrailingSlash(path string) string {
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
if path != "/" && !strings.HasSuffix(path, "/") {
path += "/"
}
return path
}
// RedirectToLogin 未登录访问受保护页时,跳到首页并打开登录弹窗。
func RedirectToLogin(c *gin.Context) {
path := ""
if c != nil && c.Request != nil && c.Request.URL != nil {
path = c.Request.URL.RequestURI()
}
if path == "" || path == "/login" || strings.HasPrefix(path, "/login/") {
path = "/"
}
c.Redirect(http.StatusFound, LoginEntryURL(path))
}
func isSafeRedirect(path string) bool {
if path == "" || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
return false
}
if strings.ContainsAny(path, "\r\n") {
return false
}
return true
}
+72
View File
@@ -0,0 +1,72 @@
package web
import (
"strings"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// CategoryDetail 分类详情页:按分类 slug 查询,slug 缺失时兼容数值 ID。
func CategoryDetail(c *gin.Context) {
identifier := strings.TrimSpace(c.Param("slug"))
if identifier == "" {
NotFound(c, "分类不存在")
return
}
q := playletser.ParseCategoryDetailQuery(identifier, routeOrQuery(c, "page", "page"))
raw := playletser.FetchCategoryDetailOrEmpty(c, q)
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
NotFound(c, "页面不存在")
return
}
if emptyScopedPage(true, raw.TotalCount, len(raw.List)) {
NotFound(c, "分类不存在")
return
}
// 接口返回了规范 slug 时优先使用 slug;没有 slug 才继续使用 ID。
if slug := strings.TrimSpace(raw.CategorySlug); slug != "" {
q.CategorySlug = slug
q.CategoryID = raw.CategoryID
} else if raw.CategoryID > 0 {
q.CategoryID = raw.CategoryID
}
if redirectCanonical(c, playletser.CategoryDetailCanonicalPath(q)) {
return
}
pageName := strings.TrimSpace(raw.CategoryName)
if pageName == "" {
pageName = strings.TrimSpace(raw.SeoInfo.Title)
}
if pageName == "" {
pageName = identifier
}
description := strings.TrimSpace(raw.SeoInfo.Description)
if description == "" {
description = "汇集近期最新发布的短剧、漫剧、AI换脸、AI魔改等内容,持续更新不同题材与类型的新作品,方便快速发现站内最近上线和最新更新的精彩内容。"
}
title := playletser.CategoryDetailPageTitle(pageName, q.PageNum, raw.SeoInfo)
data := gin.H{
"title": title,
"pageKey": "category_detail",
"pageName": pageName,
"pageTitle": pageName,
"categoryDescription": description,
"sectionTitle": "最新上架作品",
"pageNum": q.PageNum,
"pagePath": playletser.CategoryDetailCanonicalPath(q),
"seoInfo": raw.SeoInfo,
"list": raw.List,
"totalCount": raw.TotalCount,
"itemListOffset": (q.PageNum - 1) * q.PageSize,
"pagination": playletser.BuildCategoryDetailPagination(q, raw.TotalCount),
}
if raw.TotalCount <= 0 || len(raw.List) == 0 {
setNoIndex(data, true)
}
HTMLOK(c, "categoryDetail.html", data)
}
+50
View File
@@ -0,0 +1,50 @@
package web
import (
"net/http"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// ChapterDetail 代理上游 GET /api/app/playlet/chapter/detail(播放页 CSR 切集)。
func ChapterDetail(c *gin.Context) {
chapterID, _ := strconv.ParseInt(c.Query("chapterId"), 10, 64)
playletID, _ := strconv.ParseInt(c.Query("playletId"), 10, 64)
playletSlug := strings.TrimSpace(c.Query("playletSlug"))
if chapterID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 chapterId",
"data": nil,
})
return
}
data, err := playletser.FetchChapterDetail(c, chapterID, playletID, playletSlug)
if err != nil {
log.Error("chapter detail upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "chapter detail failed",
"tip": "获取剧集失败,请稍后重试",
"data": nil,
})
return
}
if data != nil && useragent.TokenFromRequest(c.Request) != "" {
authser.SetRechargeCookie(c, data.Balance)
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "success",
"tip": "成功",
"data": data,
})
}
+196
View File
@@ -0,0 +1,196 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type chapterLikeBody struct {
ChapterID int64 `json:"chapterId"`
IsLike *bool `json:"isLike"`
}
// ChapterLikeSet 代理上游 POST /api/app/playlet/chapter/like/set。
func ChapterLikeSet(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录后再点赞",
"data": nil,
})
return
}
var body chapterLikeBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 chapterId 或 isLike",
"data": nil,
})
return
}
env, err := playletser.ChapterLikeSet(c, playletser.PlayletChapterLikeSetReq{
ChapterID: body.ChapterID,
IsLike: *body.IsLike,
})
if err != nil {
log.Error("chapter like set upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "chapter like failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = playletser.ChapterLikeSet(c, playletser.PlayletChapterLikeSetReq{
ChapterID: body.ChapterID,
IsLike: *body.IsLike,
})
if err != nil {
log.Error("chapter like set retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "chapter like failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "操作失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "成功"),
"data": env.Data,
})
}
type chapterCollectBody struct {
ChapterID int64 `json:"chapterId"`
IsCollect *bool `json:"isCollect"`
}
// ChapterCollectSet 代理上游 POST /api/app/playlet/chapter/collect/set。
func ChapterCollectSet(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录后再收藏",
"data": nil,
})
return
}
var body chapterCollectBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 chapterId 或 isCollect",
"data": nil,
})
return
}
env, err := playletser.ChapterCollectSet(c, playletser.PlayletChapterCollectSetReq{
ChapterID: body.ChapterID,
IsCollect: *body.IsCollect,
})
if err != nil {
log.Error("chapter collect set upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "chapter collect failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = playletser.ChapterCollectSet(c, playletser.PlayletChapterCollectSetReq{
ChapterID: body.ChapterID,
IsCollect: *body.IsCollect,
})
if err != nil {
log.Error("chapter collect set retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "chapter collect failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "操作失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "成功"),
"data": env.Data,
})
}
+106
View File
@@ -0,0 +1,106 @@
package web
import (
"net/http"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type chapterUnlockBody struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
// ChapterUnlock 代理上游 POST /api/app/playlet/chapter/unlock。
func ChapterUnlock(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录后再解锁",
"data": nil,
})
return
}
var body chapterUnlockBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.PlayletID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 chapterId 或 playletId",
"data": nil,
})
return
}
env, err := playletser.ChapterUnlock(c, playletser.PlayletChapterUnlockReq{
ChapterID: body.ChapterID,
PlayletID: body.PlayletID,
})
if err != nil {
log.Error("chapter unlock upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "chapter unlock failed",
"tip": "解锁失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = playletser.ChapterUnlock(c, playletser.PlayletChapterUnlockReq{
ChapterID: body.ChapterID,
PlayletID: body.PlayletID,
})
if err != nil {
log.Error("chapter unlock retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "chapter unlock failed",
"tip": "解锁失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "解锁失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
authser.SetRechargeCookie(c, env.Data.Balance)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "解锁成功"),
"data": env.Data,
})
}
+103
View File
@@ -0,0 +1,103 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type chapterWatchReportBody struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
// ChapterWatchReport 代理上游 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 chapterWatchReportBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 chapterId",
"data": nil,
})
return
}
env, err := playletser.ChapterWatchReport(c, playletser.PlayletChapterWatchReportReq{
ChapterID: body.ChapterID,
PlayletID: body.PlayletID,
})
if err != nil {
log.Error("chapter watch report upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "chapter watch report failed",
"tip": "上报失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = playletser.ChapterWatchReport(c, playletser.PlayletChapterWatchReportReq{
ChapterID: body.ChapterID,
PlayletID: body.PlayletID,
})
if err != nil {
log.Error("chapter watch report retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "chapter watch report failed",
"tip": "上报失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "上报失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "成功"),
"data": env.Data,
})
}
+84
View File
@@ -0,0 +1,84 @@
package web
import (
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// Collect 我的收藏 SSR:
// 短剧 GET /api/app/playlet/collect/list;剧集 GET /api/app/playlet/chapter/collect/list。
func Collect(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
RedirectToLogin(c)
return
}
q := playletser.ParseCollectListQuery(routeOrQuery(c, "page", "page"), routeOrQuery(c, "type", "type"))
if redirectCanonical(c, playletser.CollectCanonicalPath(q)) {
return
}
data := gin.H{
"title": "我的收藏",
"description": "查看你在黄果短剧收藏的短剧与剧集,随时继续追更喜欢的内容。",
"navActive": "userCenter",
"collectType": q.Type,
"pageNum": q.PageNum,
"pageSize": q.PageSize,
"totalCount": int64(0),
"pagination": playletser.BuildCollectPagination(q, 0),
}
setNoIndex(data, false)
if q.Type == playletser.CollectTypeChapter {
raw, code, err := playletser.FetchChapterCollectList(c, q)
if err != nil {
aborted, retry := AbortSSRIfSessionExpired(c, code)
if aborted {
return
}
if retry {
raw, _, err = playletser.FetchChapterCollectList(c, q)
}
if err != nil {
log.Error("fetch playlet chapter collect list failed", log.E(err))
raw = &playletser.PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
}
if raw == nil {
raw = &playletser.PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data["chapterList"] = raw.List
data["pageNum"] = raw.PageNum
data["pageSize"] = raw.PageSize
data["totalCount"] = raw.TotalCount
data["pagination"] = playletser.BuildCollectPagination(q, raw.TotalCount)
} else {
raw, code, err := playletser.FetchCollectList(c, q)
if err != nil {
aborted, retry := AbortSSRIfSessionExpired(c, code)
if aborted {
return
}
if retry {
raw, _, err = playletser.FetchCollectList(c, q)
}
if err != nil {
log.Error("fetch playlet collect list failed", log.E(err))
raw = &playletser.PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
}
if raw == nil {
raw = &playletser.PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data["list"] = raw.List
data["pageNum"] = raw.PageNum
data["pageSize"] = raw.PageSize
data["totalCount"] = raw.TotalCount
data["pagination"] = playletser.BuildCollectPagination(q, raw.TotalCount)
}
HTMLOK(c, "collect.html", data)
}
+282
View File
@@ -0,0 +1,282 @@
package web
import (
"net/http"
"strconv"
"strings"
"unicode/utf8"
"videoweb/app/service/commentser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// CommentList 代理上游 GET /api/app/comment/list(加载更多等)。
func CommentList(c *gin.Context) {
objectID, _ := strconv.ParseInt(c.Query("objectId"), 10, 64)
objectType, _ := strconv.Atoi(c.Query("objectType"))
pageNum, _ := strconv.Atoi(c.Query("pageNum"))
pageSize, _ := strconv.Atoi(c.Query("pageSize"))
sortOrder, _ := strconv.Atoi(c.Query("sortOrder"))
q := commentser.ListQuery{
ObjectID: objectID,
ObjectType: objectType,
PageNum: pageNum,
PageSize: pageSize,
SortOrder: sortOrder,
}
if q.ObjectID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 objectId",
"data": nil,
})
return
}
data, err := commentser.FetchList(c, q)
if err != nil {
log.Error("comment list upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "comment list failed",
"tip": "获取评论失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "success",
"tip": "成功",
"data": data,
})
}
type commentSendBody struct {
ObjectID int64 `json:"objectId"`
ObjectType int `json:"objectType"`
Text string `json:"text"`
ParentsID int64 `json:"parentsId"`
ReplyCommentID int64 `json:"replyCommentId"`
}
// CommentSend 代理上游 POST /api/app/comment/send。
// parentsId=0 一级评论;parentsId>0 二级评论;replyCommentId 为实际被回复评论。
func CommentSend(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录后再发表评论",
"data": nil,
})
return
}
var body commentSendBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.Text = strings.TrimSpace(body.Text)
if body.ObjectID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 objectId",
"data": nil,
})
return
}
if body.ObjectType != commentser.ObjectTypeChapter {
body.ObjectType = commentser.ObjectTypePlaylet
}
if body.Text == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请输入评论内容",
"data": nil,
})
return
}
if utf8.RuneCountInString(body.Text) > 200 {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "评论最多 200 字",
"data": nil,
})
return
}
if body.ParentsID < 0 {
body.ParentsID = 0
}
if body.ReplyCommentID < 0 {
body.ReplyCommentID = 0
}
env, err := commentser.Send(c, commentser.CommentSendReq{
ObjectID: body.ObjectID,
ObjectType: body.ObjectType,
Text: body.Text,
ParentsID: body.ParentsID,
ReplyCommentID: body.ReplyCommentID,
})
if err != nil {
log.Error("comment send upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "comment send failed",
"tip": "发表失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = commentser.Send(c, commentser.CommentSendReq{
ObjectID: body.ObjectID,
ObjectType: body.ObjectType,
Text: body.Text,
ParentsID: body.ParentsID,
ReplyCommentID: body.ReplyCommentID,
})
if err != nil {
log.Error("comment send retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "comment send failed",
"tip": "发表失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "发表失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "发表成功"),
"data": env.Data,
})
}
type commentLikeBody struct {
CommentID int64 `json:"commentId"`
IsLike *bool `json:"isLike"`
}
// CommentLikeSet 代理上游 POST /api/app/comment/like/set。
func CommentLikeSet(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录后再点赞",
"data": nil,
})
return
}
var body commentLikeBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
if body.CommentID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 commentId 或 isLike",
"data": nil,
})
return
}
env, err := commentser.LikeSet(c, commentser.CommentLikeSetReq{
CommentID: body.CommentID,
IsLike: *body.IsLike,
})
if err != nil {
log.Error("comment like set upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "comment like failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = commentser.LikeSet(c, commentser.CommentLikeSetReq{
CommentID: body.CommentID,
IsLike: *body.IsLike,
})
if err != nil {
log.Error("comment like set retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "comment like failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "操作失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "成功"),
"data": env.Data,
})
}
+244
View File
@@ -0,0 +1,244 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/rechargeser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type buyPayBody struct {
PackageID int64 `json:"packageId"`
PayMode string `json:"payMode"`
}
// attachWalletFromGetInfo 登录态拉取 getInfo,刷新 cookie,并写入模板金币余额。
// 返回 true 表示注册账号会话失效,调用方应跳转登录。
func attachWalletFromGetInfo(c *gin.Context, data gin.H) bool {
if data == nil || useragent.TokenFromRequest(c.Request) == "" {
return false
}
env, user, err := authser.GetInfo(c)
if err != nil {
log.Error("wallet getInfo failed", log.E(err))
return false
}
if env != nil && authser.IsSessionExpiredCode(env.Code) {
retry, needLogin := authser.OnSessionExpired(c, env.Code)
if needLogin {
return true
}
if retry {
env, user, err = authser.GetInfo(c)
if err != nil {
log.Error("wallet getInfo retry failed", log.E(err))
return false
}
}
}
if env == nil || env.Code != 200 || user == nil {
return false
}
authser.SetAuthCookies(c, user)
data["userId"] = authser.FormatUserID(user.UserID)
data["recharge"] = user.Recharge
data["rechargeText"] = authser.FormatRecharge(user.Recharge)
data["vipType"] = user.VipType
data["showCardId"] = user.ShowCardID
data["showCardName"] = strings.TrimSpace(user.ShowCardName)
active := authser.IsActiveVipUser(user.VipType, user.CurrentVipExpire)
data["isVip"] = active
if active {
data["vipLabel"] = authser.VipDisplayLabel(user.ShowCardName, user.VipType)
data["vipExpireText"] = authser.FormatVipExpireText(user.CurrentVipExpire)
} else {
delete(data, "vipLabel")
delete(data, "vipExpireText")
}
return false
}
// Deposit 金币与会员充值页 SSR:套餐列表 + 金币流水明细。
// Query tab=coin 时默认选中金币充值套餐。
func Deposit(c *gin.Context) {
// 先补游客会话,保证可拉流水。
authser.EnsureGuestOnEnter(c)
preferTab := routeOrQuery(c, "tab", "tab")
if hasQuery(c, "tab") {
target := "/deposit"
if preferTab != "" {
target += "/" + preferTab
}
target += "/"
if redirectCanonical(c, target) {
return
}
}
raw := rechargeser.FetchPackageListOrEmpty(c)
vm := rechargeser.BuildDepositPageVM(raw, preferTab)
if useragent.TokenFromRequest(c.Request) != "" {
list, code, err := rechargeser.FetchCoinTransList(c, 1, 20)
if err != nil {
retry, needLogin := authser.OnSessionExpired(c, code)
if needLogin {
RedirectToLogin(c)
return
}
if retry {
list, _, err = rechargeser.FetchCoinTransList(c, 1, 20)
}
if err != nil {
log.Error("fetch coin trans list failed", log.E(err))
list = nil
}
}
vm.AttachTxList(list)
}
data := gin.H{
"title": "金币与会员",
"vipList": vm.VipList,
"coinList": vm.CoinList,
"payMethods": vm.PayMethods,
"txList": vm.TxList,
"preferTab": preferTab,
}
setNoIndex(data, false)
if attachWalletFromGetInfo(c, data) {
RedirectToLogin(c)
return
}
HTMLOK(c, "deposit.html", data)
}
// CoinBuy 代理上游 POST /api/app/coin/buy。
func CoinBuy(c *gin.Context) {
handleBuyPay(c, "coin")
}
// VipPay 代理上游 POST /api/app/vip/pay。
func VipPay(c *gin.Context) {
handleBuyPay(c, "vip")
}
func handleBuyPay(c *gin.Context, kind string) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录后再支付",
"data": nil,
})
return
}
var body buyPayBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.PayMode = rechargeser.NormalizePayMode(body.PayMode)
if body.PackageID <= 0 || body.PayMode == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请选择套餐和支付方式",
"data": nil,
})
return
}
req := rechargeser.BuyPayReq{
PackageID: body.PackageID,
PayMode: body.PayMode,
}
var (
env *rechargeser.APIEnvelope[rechargeser.PayOrderResp]
err error
)
switch kind {
case "coin":
env, err = rechargeser.BuyCoin(c, req)
default:
env, err = rechargeser.VipPay(c, req)
}
if err != nil {
log.Error("buy/pay upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "pay failed",
"tip": "发起支付失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
switch kind {
case "coin":
env, err = rechargeser.BuyCoin(c, req)
default:
env, err = rechargeser.VipPay(c, req)
}
if err != nil {
log.Error("buy/pay retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "pay failed",
"tip": "发起支付失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "发起支付失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
payURL := strings.TrimSpace(env.Data.PayURL)
if payURL == "" {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty payUrl",
"tip": "未获取到支付链接,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "请完成支付"),
"data": gin.H{
"isOpenNewBrowser": env.Data.IsOpenNewBrowser,
"payUrl": payURL,
"payUrlMode": env.Data.PayURLMode,
"tradeNo": env.Data.TradeNo,
},
})
}
+32
View File
@@ -0,0 +1,32 @@
package web
import (
"strings"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Detail 详情页 SSR:GET /api/app/playlet/detail;seoInfo 直接注入模板。
func Detail(c *gin.Context) {
key := strings.TrimSpace(c.Param("slug"))
if key == "" {
NotFound(c, "未找到该短剧")
return
}
raw := playletser.FetchDetailOrNilByKey(c, key)
if raw == nil {
NotFound(c, "未找到该短剧")
return
}
// 有 slug 使用 slug,否则保留 ID 路由。
if redirectCanonical(c, playletser.DetailPath(raw.Slug, raw.ID)) {
return
}
HTMLOK(c, "detail.html", gin.H{
"title": raw.Title,
"detail": raw,
"seoInfo": raw.SeoInfo,
"episodeRanges": playletser.BuildEpisodeRanges(raw.PlayletChapterInfo, raw.TotalEpisode, 30),
})
}
+34
View File
@@ -0,0 +1,34 @@
package web
import (
"net/http"
"videoweb/app/service/configser"
"github.com/gin-gonic/gin"
)
var dialogTemplates = map[string]string{
"download": "downloadAppDialog",
"login": "loginDialog",
"register": "registerDialog",
"search": "searchDialog",
}
// DialogFragment renders only the requested dialog body. Styles and behavior
// are loaded by dialogLoader.js after the first user interaction.
func DialogFragment(c *gin.Context) {
name := c.Param("name")
templateName, ok := dialogTemplates[name]
if !ok {
c.Status(http.StatusNotFound)
return
}
data := gin.H{}
if name == "download" {
data["appConfig"] = configser.FetchAppConfigVMOrEmpty(c)
}
c.Header("Cache-Control", "private, max-age=300")
c.HTML(http.StatusOK, templateName, data)
}
+209
View File
@@ -0,0 +1,209 @@
package web
import (
"net/http"
"strings"
"unicode/utf8"
"videoweb/app/service/feedbackser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type feedbackBody struct {
Type int `json:"type"`
Content string `json:"content"`
Contact string `json:"contact"`
Screenshots []string `json:"screenshots"`
}
type contactUsBody struct {
Type int `json:"type"`
Title string `json:"title"`
Content string `json:"content"`
Email string `json:"email"`
Attachments []string `json:"attachments"`
}
// AddFeedback 代理上游 POST /api/app/feedback/add。
func AddFeedback(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录后再提交反馈",
"data": nil,
})
return
}
var body feedbackBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.Content = strings.TrimSpace(body.Content)
body.Contact = strings.TrimSpace(body.Contact)
if body.Type < 1 || body.Type > 4 {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请选择问题类型",
"data": nil,
})
return
}
if body.Content == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请填写问题描述",
"data": nil,
})
return
}
if utf8.RuneCountInString(body.Content) > 500 {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "问题描述最多 500 字",
"data": nil,
})
return
}
shots := make([]string, 0, len(body.Screenshots))
for _, s := range body.Screenshots {
s = strings.TrimSpace(s)
if s != "" {
shots = append(shots, s)
}
}
if len(shots) > 10 {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "最多上传 10 张图片",
"data": nil,
})
return
}
env, err := feedbackser.Add(c, feedbackser.FeedbackReq{
Type: body.Type,
Content: body.Content,
Contact: body.Contact,
Screenshots: shots,
})
if err != nil {
log.Error("feedback add upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "feedback failed",
"tip": "提交失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = feedbackser.Add(c, feedbackser.FeedbackReq{
Type: body.Type,
Content: body.Content,
Contact: body.Contact,
Screenshots: shots,
})
if err != nil {
log.Error("feedback add retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "feedback failed",
"tip": "提交失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "提交失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "提交成功"),
"data": env.Data,
})
}
// AddContactUs 代理上游 POST /api/app/contactus/add。
func AddContactUs(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录后再提交", "data": nil})
return
}
var body contactUsBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1001, "msg": "参数错误", "tip": "参数错误", "data": nil})
return
}
body.Title = strings.TrimSpace(body.Title)
body.Content = strings.TrimSpace(body.Content)
body.Email = strings.TrimSpace(body.Email)
if body.Type < 1 || body.Type > 5 || body.Title == "" || body.Content == "" || body.Email == "" {
c.JSON(http.StatusOK, gin.H{"code": 1001, "msg": "参数错误", "tip": "请完整填写问题类型、标题、内容和邮箱", "data": nil})
return
}
attachments := make([]string, 0, len(body.Attachments))
for _, attachment := range body.Attachments {
if attachment = strings.TrimSpace(attachment); attachment != "" {
attachments = append(attachments, attachment)
}
}
req := feedbackser.ContactUsAddReq{
Type: body.Type, Title: body.Title, Content: body.Content, Email: body.Email, Attachments: attachments,
}
env, err := feedbackser.AddContactUs(c, req)
if err == nil && env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = feedbackser.AddContactUs(c, req)
}
if err != nil {
log.Error("contact us upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "contact us failed", "tip": "提交失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "contact us failed", "tip": "提交失败", "data": nil})
return
}
defaultTip := "提交失败"
if env.Code == 200 {
defaultTip = "提交成功"
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": firstNonEmpty(env.Tip, env.Msg, defaultTip),
"data": env.Data,
})
}
+117
View File
@@ -0,0 +1,117 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// History 观看历史 SSR:GET /api/app/playlet/history/list。
func History(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
RedirectToLogin(c)
return
}
q := playletser.ParseHistoryListQuery(routeOrQuery(c, "page", "page"))
if redirectCanonical(c, playletser.HistoryCanonicalPath(q)) {
return
}
raw, code, err := playletser.FetchHistoryList(c, q)
if err != nil {
aborted, retry := AbortSSRIfSessionExpired(c, code)
if aborted {
return
}
if retry {
raw, _, err = playletser.FetchHistoryList(c, q)
}
if err != nil {
log.Error("fetch playlet history list failed", log.E(err))
raw = &playletser.PlayletHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
}
if raw == nil {
raw = &playletser.PlayletHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data := gin.H{
"title": "观看历史",
"description": "查看黄果短剧观看历史,快速回到上次看到的剧集继续观看。",
"navActive": "userCenter",
"list": raw.List,
"pageNum": raw.PageNum,
"pageSize": raw.PageSize,
"totalCount": raw.TotalCount,
"pagination": playletser.BuildHistoryPagination(q, raw.TotalCount),
"emptyTitle": "暂无观看历史",
"emptyDesc": "开始播放短剧后,记录会出现在这里",
}
setNoIndex(data, false)
HTMLOK(c, "history.html", data)
}
// HistoryClear 代理上游 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("playlet history clear upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "history clear failed",
"tip": "清空失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = playletser.HistoryClear(c)
if err != nil {
log.Error("playlet history clear retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "history clear failed",
"tip": "清空失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "清空失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "已清空观看历史"),
"data": env.Data,
})
}
+28
View File
@@ -0,0 +1,28 @@
package web
import (
"videoweb/app/service/configser"
"videoweb/app/service/seoser"
"github.com/gin-gonic/gin"
)
// HomeRoad 渲染黄果短剧最新地址与防失联入口页。
func HomeRoad(c *gin.Context) {
cfg := configser.FetchAppConfigVMOrEmpty(c)
HTMLOK(c, "homeRoad.html", gin.H{
"title": "回家的路 - 黄果短剧官方最新地址发布页",
"pageKey": "home_road",
"pageName": "回家的路",
"pagePath": "/home-road/",
"navActive": "homeRoad",
"appConfig": cfg,
"homeRoad": configser.BuildHomeRoadPageVM(cfg),
"faqItems": []seoser.FAQItem{
{Question: "回家的路页面有什么作用?", Answer: "集中展示最新地址、永久入口、备用入口和保存方式,方便访问异常时快速返回黄果短剧。"},
{Question: "建议保存哪个入口?", Answer: "优先收藏当前推荐入口,并额外保存永久入口或备用入口。"},
{Question: "入口打不开怎么办?", Answer: "先切换网络,再尝试永久入口或备用入口;仍无法访问时返回本页查看最新地址。"},
{Question: "如何确认入口是否安全?", Answer: "只从本页、站内导航或黄果社区提示进入,不要从陌生弹窗、未知短链跳转。"},
},
})
}
+30
View File
@@ -0,0 +1,30 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
// HotLike 代理上游 GET /api/app/playlet/hot-like。
func HotLike(c *gin.Context) {
data, err := playletser.FetchHotLike(c)
if err != nil {
log.Error("playlet hot-like upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "playlet hot-like failed",
"tip": "获取热搜和猜你喜欢失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "success",
"tip": "成功",
"data": data,
})
}
+39
View File
@@ -0,0 +1,39 @@
package web
import (
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Index 首页 SSR:拉取 /api/app/playlet/home,直接用接口字段渲染。
func Index(c *gin.Context) {
renderIndex(c, "")
}
func AuthIndex(mode string) gin.HandlerFunc {
return func(c *gin.Context) {
renderIndex(c, mode)
}
}
func renderIndex(c *gin.Context, authMode string) {
authRedirect := c.Param("redirect")
raw := playletser.FetchHomeOrEmpty(c)
data := gin.H{
"title": "黄果短剧",
"navActive": "home",
"homeResponse": raw,
"seoInfo": raw.SeoInfo,
"announcements": raw.Announcements,
"homeCategoryData": raw.HomeCategoryData,
"homeRecommendThemes": raw.HomeRecommendThemes,
"homeBannerData": playletser.ActiveHomeBanners(raw.HomeBannerData),
"authMode": authMode,
"authRedirect": authRedirect,
}
if authMode != "" {
setNoIndex(data, true)
}
HTMLOK(c, "index.html", data)
}
+61
View File
@@ -0,0 +1,61 @@
package web
import (
"strings"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// List 短剧列表页 SSR:GET /api/app/playlet/operate/list,operateType 固定为 1。
func List(c *gin.Context) {
slug := strings.TrimSpace(c.Param("slug"))
if slug == "ai-series" {
legacy := playletser.ParseListQuery(
routeOrQuery(c, "page", "page"),
routeOrQuery(c, "sortOrder", "sortOrder"),
c.Query("type"),
)
redirectCanonical(c, playletser.SeriesPathFromLegacyList(legacy.SortOrder, legacy.PageNum))
return
}
filter := routeOrQuery(c, "sortOrder", "filterType")
if filter == "" {
filter = strings.TrimSpace(c.Query("sortOrder"))
}
q, ok := playletser.ParseShortPlayQuery(filter, routeOrQuery(c, "page", "page"))
if !ok {
NotFound(c, "页面不存在")
return
}
if redirectCanonical(c, playletser.ShortPlayCanonicalPath(q)) {
return
}
raw := playletser.FetchOperateListOrEmpty(c, q)
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
NotFound(c, "页面不存在")
return
}
sectionTitle := playletser.ShortPlaySectionTitle(q.FilterType)
data := gin.H{
"title": "短剧",
"description": "浏览黄果短剧全部短剧列表,按分类、标签筛选海量高清短剧,发现更多精彩剧集。",
"pageKey": "list",
"pageName": "AI成人短剧",
"pagePath": playletser.ShortPlayCanonicalPath(q),
"navActive": "list",
"seoInfo": playletser.SeoInfo{},
"seoSelections": []playletser.SEOSelection{{Dimension: "筛选", Value: strings.TrimSuffix(sectionTitle, "的AI成人短剧")}},
"filters": playletser.BuildShortPlayFilterTabs(q),
"sectionTitle": sectionTitle,
"list": raw.List,
"itemListOffset": (q.PageNum - 1) * q.PageSize,
"pagination": playletser.BuildShortPlayPagination(q, raw.TotalCount),
}
// /list/ai-short-play/original/ 入口页始终可索引;其余空结果仍 noindex。
isOriginalEntry := q.FilterType == 3 && q.PageNum <= 1
if !isOriginalEntry && (raw.TotalCount <= 0 || len(raw.List) == 0) {
setNoIndex(data, true)
}
HTMLOK(c, "list.html", data)
}
+83
View File
@@ -0,0 +1,83 @@
package web
import (
"strings"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// More 专题详情页 SSR:专题由路由 slug 标识;历史 /more 地址会跳转到 /topic canonical。
func More(c *gin.Context) {
q := playletser.ParseRecommendQuery(routeOrQuery(c, "page", "page"))
if slug := strings.TrimSpace(c.Param("slug")); slug != "" {
if id := playletser.ParseDetailID(slug); id > 0 {
q.ThemeID = id
} else {
q.ThemeSlug = slug
}
}
if path := c.Request.URL.Path; path == "/more" || strings.HasPrefix(path, "/more/") {
redirectCanonical(c, playletser.RecommendPagePath(q))
return
}
themes := playletser.FetchThemesOrEmpty(c, 2)
themeName := ""
for _, theme := range themes {
matchesID := q.ThemeID > 0 && theme.ID == q.ThemeID
matchesSlug := strings.TrimSpace(q.ThemeSlug) != "" && strings.EqualFold(strings.TrimSpace(theme.Slug), strings.TrimSpace(q.ThemeSlug))
if !matchesID && !matchesSlug {
continue
}
themeName = strings.TrimSpace(theme.Name)
if q.ThemeID <= 0 {
q.ThemeID = theme.ID
}
if strings.TrimSpace(theme.Slug) != "" {
q.ThemeSlug = strings.TrimSpace(theme.Slug)
}
break
}
if redirectCanonical(c, playletser.RecommendPagePath(q)) {
return
}
raw := playletser.FetchRecommendListOrEmpty(c, q)
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
NotFound(c, "页面不存在")
return
}
hasIdentifier := q.ThemeID > 0 || strings.TrimSpace(q.ThemeSlug) != ""
if emptyScopedPage(hasIdentifier, raw.TotalCount, len(raw.List)) {
NotFound(c, "专题不存在")
return
}
title := playletser.RecommendThemeTitle(themeName, raw.SeoInfo.Title, q.ThemeID)
if queryTitle := strings.TrimSpace(c.Query("title")); queryTitle != "" {
title = queryTitle
}
heading := title
if !strings.HasSuffix(heading, "介绍") {
heading += "介绍"
}
description := strings.TrimSpace(raw.SeoInfo.Description)
if description == "" {
description = "精品高分 AI 成人短剧专题,精选高人气、高评分 AI 原创短剧,每日持续更新。涵盖多种热门剧情与角色设定,为你带来高质量、高清流畅的观影体验。"
}
data := gin.H{
"title": title,
"pageKey": "topic",
"pageName": title,
"pageTitle": title,
"topicHeading": heading,
"topicDescription": description,
"pagePath": playletser.RecommendCanonicalPath(q.ThemeID, q.ThemeSlug),
"seoInfo": raw.SeoInfo,
"list": raw.List,
"itemListOffset": (q.PageNum - 1) * q.PageSize,
"pagination": playletser.BuildRecommendPagination(q, raw.TotalCount),
}
if raw.TotalCount <= 0 || len(raw.List) == 0 {
setNoIndex(data, true)
}
HTMLOK(c, "more.html", data)
}
+15
View File
@@ -0,0 +1,15 @@
package web
import (
"github.com/gin-gonic/gin"
)
// Nav 官方导航页 SSR:直接使用 config/info 注入的 appConfig。
func Nav(c *gin.Context) {
HTMLOK(c, "nav.html", gin.H{
"title": "黄果短剧 · 官方导航",
"isNavPage": true,
"pageKey": "nav",
"pageName": "官方导航",
})
}
+27
View File
@@ -0,0 +1,27 @@
package web
import (
"net/http"
"github.com/gin-gonic/gin"
)
// NotFound 统一 404 页(HTTP 404)。
func NotFound(c *gin.Context, title string) {
if title == "" {
title = "页面未找到"
}
data := gin.H{
"title": title,
"path": c.Request.URL.RequestURI(),
}
setNoIndex(data, true)
HTML(c, http.StatusNotFound, "404.html", data)
}
// emptyScopedPage 判断带明确资源标识的列表详情是否不存在。
// 聚合页允许暂时为空;标签、分类、专题等详情页成功返回空结果时应响应 404,
// 避免用空模板和 HTTP 200 形成搜索引擎软 404。
func emptyScopedPage(hasIdentifier bool, totalCount int64, itemCount int) bool {
return hasIdentifier && totalCount <= 0 && itemCount == 0
}
+26
View File
@@ -0,0 +1,26 @@
package web
import "testing"
func TestEmptyScopedPage(t *testing.T) {
tests := []struct {
name string
hasIdentifier bool
totalCount int64
itemCount int
want bool
}{
{name: "missing detail", hasIdentifier: true, want: true},
{name: "empty aggregate page", hasIdentifier: false, want: false},
{name: "has total", hasIdentifier: true, totalCount: 1, want: false},
{name: "has item", hasIdentifier: true, itemCount: 1, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := emptyScopedPage(tt.hasIdentifier, tt.totalCount, tt.itemCount); got != tt.want {
t.Fatalf("emptyScopedPage(%v, %d, %d) = %v, want %v", tt.hasIdentifier, tt.totalCount, tt.itemCount, got, tt.want)
}
})
}
}
+76
View File
@@ -0,0 +1,76 @@
package web
import (
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// Play 播放页 SSR:GET /api/app/playlet/chapter/detail;seoInfo 直接注入模板。
func Play(c *gin.Context) {
playletKey := strings.TrimSpace(c.Param("slug"))
requestedChapterID := strings.TrimSpace(c.Param("chapterId"))
if playletKey == "" {
NotFound(c, "未找到可播放内容")
return
}
if requestedChapterID != "" && playletser.ParseDetailID(requestedChapterID) <= 0 {
NotFound(c, "未找到该剧集")
return
}
playlet := playletser.FetchDetailOrNilByKey(c, playletKey)
if playlet == nil {
NotFound(c, "未找到可播放内容")
return
}
q := playletser.ParsePlayQuery(playletKey, c.Param("chapterId"))
q.PlayletID = playlet.ID
q.PlayletSlug = playlet.Slug
q.ChapterID = playletser.ResolvePlayChapterID(playlet, q.ChapterID)
if q.ChapterID <= 0 {
NotFound(c, "未找到可播放内容")
return
}
target := playletser.PlayPath(q.PlayletSlug, q.PlayletID)
if q.ChapterID > 0 {
target = playletser.ChapterPlayPath(q.PlayletSlug, q.PlayletID, q.ChapterID)
}
if redirectCanonical(c, target) {
return
}
chapter := playletser.FetchChapterDetailOrNil(c, q.ChapterID, q.PlayletID, q.PlayletSlug)
if chapter == nil {
NotFound(c, "未找到该剧集")
return
}
var episodeRanges []playletser.EpisodeRange
var chapters []playletser.PlayletChapterData
playletID := playlet.ID
playletSlug := playlet.Slug
chapters = playlet.PlayletChapterInfo
episodeRanges = playletser.BuildEpisodeRanges(chapters, playlet.TotalEpisode, 30)
if chapter != nil && useragent.TokenFromRequest(c.Request) != "" {
authser.SetRechargeCookie(c, chapter.Balance)
}
seoInfo := playletser.ResolvePlayPageSEO(chapter, playlet)
data := gin.H{
"title": playletser.ResolvePlayPageTitle(chapter, playlet),
"chapter": chapter,
"playlet": playlet,
"playletID": playletID,
"playletSlug": playletSlug,
"seoInfo": seoInfo,
"chapters": chapters,
"episodeRanges": episodeRanges,
}
HTMLOK(c, "play.html", data)
}
+54
View File
@@ -0,0 +1,54 @@
package web
import (
"net/http"
"strconv"
"strings"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
// ChapterList 代理上游短剧选集列表接口。
func ChapterList(c *gin.Context) {
playletID, _ := strconv.ParseInt(c.Query("playletId"), 10, 64)
playletSlug := strings.TrimSpace(c.Query("playletSlug"))
if playletSlug == "" && playletID <= 0 {
c.JSON(http.StatusOK, gin.H{"code": 1001, "msg": "参数错误", "tip": "缺少 playletSlug 或 playletId", "data": nil})
return
}
data, err := playletser.FetchChapterList(c, playletID, playletSlug)
if err != nil {
log.Error("chapter list upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "chapter list failed", "tip": "获取选集失败,请稍后重试", "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "success", "tip": "成功", "data": data})
}
// OperateList 代理上游运营位短剧列表接口。
func OperateList(c *gin.Context) {
q := playletser.OperateListQuery{
OperateType: parsePositiveInt(c.Query("operateType")),
FilterType: parsePositiveInt(c.Query("filterType")),
PageNum: parsePositiveInt(c.Query("pageNum")),
PageSize: parsePositiveInt(c.Query("pageSize")),
}
q.Normalize()
data, err := playletser.FetchOperateList(c, q)
if err != nil {
log.Error("operate list upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "operate list failed", "tip": "获取运营短剧失败,请稍后重试", "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "success", "tip": "成功", "data": data})
}
func parsePositiveInt(raw string) int {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n <= 0 {
return 0
}
return n
}
+72
View File
@@ -0,0 +1,72 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
type playletForwardHeatBody struct {
ID int64 `json:"id"`
}
// PlayletForwardHeat 代理上游 POST /api/app/playlet/forward/heat。
func PlayletForwardHeat(c *gin.Context) {
var body playletForwardHeatBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 id",
"data": nil,
})
return
}
env, err := playletser.ForwardHeat(c, playletser.PlayletForwardHeatReq{ID: body.ID})
if err != nil {
log.Error("playlet forward heat upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "playlet forward heat failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil || env.Code != 200 {
tip := "操作失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": env.Msg,
"tip": firstNonEmpty(env.Tip, env.Msg, "success"),
"data": env.Data,
})
}
+196
View File
@@ -0,0 +1,196 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type playletLikeBody struct {
ID int64 `json:"id"`
IsLike *bool `json:"isLike"`
}
// PlayletLikeSet 代理上游 POST /api/app/playlet/like/set。
func PlayletLikeSet(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录后再点赞",
"data": nil,
})
return
}
var body playletLikeBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 id 或 isLike",
"data": nil,
})
return
}
env, err := playletser.LikeSet(c, playletser.PlayletLikeSetReq{
ID: body.ID,
IsLike: *body.IsLike,
})
if err != nil {
log.Error("playlet like set upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "playlet like failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = playletser.LikeSet(c, playletser.PlayletLikeSetReq{
ID: body.ID,
IsLike: *body.IsLike,
})
if err != nil {
log.Error("playlet like set retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "playlet like failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "操作失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "成功"),
"data": env.Data,
})
}
type playletCollectBody struct {
ID int64 `json:"id"`
IsCollect *bool `json:"isCollect"`
}
// PlayletCollectSet 代理上游 POST /api/app/playlet/collect/set。
func PlayletCollectSet(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录后再收藏",
"data": nil,
})
return
}
var body playletCollectBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少 id 或 isCollect",
"data": nil,
})
return
}
env, err := playletser.CollectSet(c, playletser.PlayletCollectSetReq{
ID: body.ID,
IsCollect: *body.IsCollect,
})
if err != nil {
log.Error("playlet collect set upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "playlet collect failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env != nil && TryJSONSessionRecover(c, env.Code) {
env, err = playletser.CollectSet(c, playletser.PlayletCollectSetReq{
ID: body.ID,
IsCollect: *body.IsCollect,
})
if err != nil {
log.Error("playlet collect set retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "playlet collect failed",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
}
if env == nil || env.Code != 200 {
tip := "操作失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmpty(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmpty(env.Msg, "success"),
"tip": firstNonEmpty(env.Tip, "成功"),
"data": env.Data,
})
}
+82
View File
@@ -0,0 +1,82 @@
package web
import (
"net/http"
"strconv"
"strings"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Rank 排行榜页 SSR:GET /api/app/playlet/rank,列表用接口字段渲染。
func Rank(c *gin.Context) {
sortOrder := strings.TrimSpace(c.Param("sortOrder"))
if sortOrder != "" {
value, err := strconv.Atoi(sortOrder)
if err != nil || value < 1 || value > 3 {
NotFound(c, "页面不存在")
return
}
}
if sortOrder == "" {
sortOrder = strings.TrimSpace(c.Query("sortOrder"))
}
page := strings.TrimSpace(c.Param("page"))
if page != "" {
value, err := strconv.Atoi(page)
if err != nil || value <= 0 {
NotFound(c, "页面不存在")
return
}
}
if page == "" {
page = strings.TrimSpace(c.Query("page"))
}
q := playletser.ParseRankQuery(page, sortOrder)
if c.Query("sortOrder") != "" || c.Query("page") != "" {
target := "/rank"
if q.SortOrder != 1 {
target += "/" + strconv.Itoa(q.SortOrder)
}
if q.PageNum > 1 {
target += "/page/" + strconv.Itoa(q.PageNum)
}
target += "/"
c.Redirect(http.StatusMovedPermanently, target)
return
}
if redirectCanonical(c, playletser.RankCanonicalPath(q)) {
return
}
raw := playletser.FetchRankOrEmpty(c, q)
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
NotFound(c, "页面不存在")
return
}
tabs := playletser.BuildRankTabs(q)
seoSelections := make([]playletser.SEOSelection, 0, 1)
for _, tab := range tabs {
if tab.Active {
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "排行榜类型", Value: tab.Label})
break
}
}
data := gin.H{
"title": "排行榜",
"description": "黄果短剧排行榜实时更新热播短剧,查看热门榜、飙升榜,不错过高口碑必看剧集。",
"navActive": "rank",
"seoInfo": raw.SeoInfo,
"seoSelections": seoSelections,
"tabs": tabs,
"list": raw.List,
"rankOffset": playletser.RankOffset(q),
"sortOrder": q.SortOrder,
"isBinge": playletser.IsRankBinge(q.SortOrder),
"pagination": playletser.BuildRankPagination(q, raw.TotalCount),
}
if raw.TotalCount <= 0 || len(raw.List) == 0 {
setNoIndex(data, true)
}
HTMLOK(c, "rank.html", data)
}
+314
View File
@@ -0,0 +1,314 @@
package web
import (
"net/http"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/configser"
"videoweb/app/service/playletser"
"videoweb/app/service/seoser"
"videoweb/app/service/translationser"
"github.com/gin-gonic/gin"
)
// HTML 渲染页面并注入 footer / 导航 / 公共配置 / 登录态(以 cookie 为准)。
func HTML(c *gin.Context, code int, name string, data gin.H) {
if data == nil {
data = gin.H{}
}
if _, ok := data["footer"]; !ok {
footer := configser.FetchFooterOrEmpty(c)
data["footer"] = footer
}
headerNav, ok := data["headerNav"].([]translationser.HeaderNavItemVM)
if !ok {
headerNav = translationser.FetchHeaderNavOrDefault(c)
data["headerNav"] = headerNav
}
activeKey, _ := data["navActive"].(string)
resolvedNavActive := translationser.ResolveNavigationActiveKey(activeKey, c.Request.URL.Path)
headerNav = translationser.MarkHeaderNavActive(headerNav, c.Request.URL.Path)
data["headerNav"] = headerNav
data["resolvedNavActive"] = resolvedNavActive
data["pageConsoleData"] = buildPageConsoleData(c.Request.URL.RequestURI(), code, name, data)
seoInfo := pageSEOFromData(data["seoInfo"])
seoChanged := false
if navSEO, found := translationser.FindHeaderNavSEO(headerNav, activeKey, c.Request.URL.Path); found {
seoInfo = playletser.MergeSeoInfo(playletser.SeoInfo{
Title: navSEO.Title,
Description: navSEO.Description,
Keywords: navSEO.Keyworld,
}, seoInfo)
seoChanged = true
}
if selections, ok := data["seoSelections"].([]playletser.SEOSelection); ok && len(selections) > 0 {
seoInfo = playletser.DifferentiateSeoInfo(seoInfo, selections)
seoChanged = true
}
if pageNum := paginationPage(data["pagination"]); pageNum > 1 {
pageTitle, _ := data["title"].(string)
pageTitle = playletser.PaginatedTitle(pageTitle, pageNum)
if pageTitle != "" {
data["title"] = pageTitle
}
seoInfo = playletser.PaginateSeoInfo(seoInfo, paginationDescription(data, pageTitle), pageNum)
seoChanged = true
}
if seoChanged {
data["seoInfo"] = seoInfo
if seoInfo.Title != "" {
data["title"] = seoInfo.Title
}
}
if _, ok := data["appConfig"]; !ok {
data["appConfig"] = configser.FetchAppConfigVMOrEmpty(c)
}
cfg, ok := data["appConfig"].(configser.AppConfigVM)
if !ok {
cfg = configser.FetchAppConfigVMOrEmpty(c)
}
cfg = configser.ApplyTrackerFallback(cfg)
data["appConfig"] = cfg
siteURL := seoser.ResolveSiteURL(c, cfg)
data["foreverUrlAddr"] = siteURL
data["siteURL"] = siteURL
attachPaginationLinks(data, siteURL)
canonicalURL := seoser.CanonicalWithoutQuery(seoser.PageCanonicalURL(siteURL, cfg.ForeverURLAddr, c))
canonicalURL = seoser.ContentCanonicalURL(siteURL, canonicalURL, map[string]any(data))
data["canonicalURL"] = canonicalURL
data["seoVerify"] = seoser.VerifyFromConfig()
attachTrackerPage(c, code, data)
noindex := pageNoIndex(data)
if noindex {
data["noindex"] = true
}
if code >= http.StatusOK && code < http.StatusMultipleChoices && !noindex && seoser.IsIndexablePath(c.Request.URL.Path) {
data["jsonLD"] = seoser.PageJSONLD(siteURL, canonicalURL, c.Request.URL.RequestURI(), map[string]any(data), cfg)
}
// 无登录 token 时自动游客登录(POST /api/app/auth/guest),保证默认进入即有会话。
authser.EnsureGuestOnEnter(c)
loggedIn, userName, avatar, email := authser.AuthFromCookies(c)
registered := authser.IsRegisteredFromCookies(c)
data["isLoggedIn"] = loggedIn
data["isRegistered"] = registered
if _, ok := data["isVip"]; !ok {
data["isVip"] = loggedIn && authser.IsActiveVipFromCookies(c)
}
isVip, _ := data["isVip"].(bool)
data["isVip"] = isVip
if loggedIn {
if userName != "" {
data["userName"] = userName
}
if 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)
}
}
// 允许页面 handler 先注入最新钱包数据(如 getInfo),避免被 cookie 覆盖。
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)
}
vipType := toInt(data["vipType"])
data["vipType"] = vipType
if _, ok := data["showCardName"]; !ok {
data["showCardName"] = authser.ShowCardNameFromCookies(c)
}
showCardName, _ := data["showCardName"].(string)
data["showCardName"] = strings.TrimSpace(showCardName)
if _, ok := data["showCardId"]; !ok {
data["showCardId"] = authser.ShowCardIDFromCookies(c)
}
if isVip {
if _, ok := data["vipLabel"]; !ok {
data["vipLabel"] = authser.VipDisplayLabel(showCardName, vipType)
}
if _, ok := data["vipExpireText"]; !ok {
data["vipExpireText"] = authser.VipExpireTextFromCookies(c)
}
} else {
delete(data, "vipLabel")
delete(data, "vipExpireText")
}
} else {
delete(data, "userName")
delete(data, "avatar")
delete(data, "email")
delete(data, "userId")
delete(data, "recharge")
delete(data, "rechargeText")
delete(data, "vipType")
delete(data, "showCardId")
delete(data, "showCardName")
delete(data, "vipLabel")
delete(data, "vipExpireText")
}
c.HTML(code, name, data)
}
// buildPageConsoleData 构建浏览器控制台使用的页面返回数据快照。
// 快照在公共配置和 cookie 登录态注入前生成,避免把会话相关公共字段写入日志。
func buildPageConsoleData(path string, code int, templateName string, data gin.H) gin.H {
pageData := make(gin.H, len(data))
for key, value := range data {
if pageConsoleExcludedKey(key) {
continue
}
pageData[key] = value
}
payload := gin.H{
"path": path,
"status": code,
"template": templateName,
"data": pageData,
}
if headerNav, ok := data["headerNav"]; ok {
payload["headerNav"] = headerNav
}
if footer, ok := data["footer"]; ok {
payload["footer"] = footer
}
return payload
}
func pageConsoleExcludedKey(key string) bool {
switch key {
case "pageConsoleData", "appConfig", "footer", "headerNav", "jsonLD", "seoVerify",
"isLoggedIn", "isRegistered", "userName", "avatar", "email", "userId":
return true
default:
return false
}
}
func pageSEOFromData(value any) playletser.SeoInfo {
switch seo := value.(type) {
case playletser.SeoInfo:
return seo
case *playletser.SeoInfo:
if seo != nil {
return *seo
}
}
return playletser.SeoInfo{}
}
func paginationPage(value any) int {
switch pagination := value.(type) {
case playletser.PaginationVM:
return pagination.Page
case *playletser.PaginationVM:
if pagination != nil {
return pagination.Page
}
}
return 0
}
func paginationDescription(data gin.H, pageTitle string) string {
for _, key := range []string{"description", "categoryDescription", "tagDescription", "topicDescription"} {
if value, _ := data[key].(string); strings.TrimSpace(value) != "" {
return value
}
}
pageTitle = strings.TrimSpace(pageTitle)
if pageTitle == "" {
return "浏览本页内容与站内相关推荐。"
}
return "浏览" + pageTitle + "的本页内容与站内相关推荐。"
}
func attachPaginationLinks(data gin.H, siteURL string) {
var pagination playletser.PaginationVM
switch value := data["pagination"].(type) {
case playletser.PaginationVM:
pagination = value
case *playletser.PaginationVM:
if value == nil {
return
}
pagination = *value
default:
return
}
if !pagination.Visible {
return
}
if pagination.HasPrev && strings.TrimSpace(pagination.PrevURL) != "" {
data["paginationPrevURL"] = seoser.AbsURL(siteURL, pagination.PrevURL)
}
if pagination.HasNext && strings.TrimSpace(pagination.NextURL) != "" {
data["paginationNextURL"] = seoser.AbsURL(siteURL, pagination.NextURL)
}
}
// HTMLOK 200 快捷方法。
func HTMLOK(c *gin.Context, name string, data gin.H) {
HTML(c, http.StatusOK, name, data)
}
// setNoIndex 统一设置页面级索引策略,并让公共渲染层同步关闭 JSON-LD。
func setNoIndex(data gin.H, follow bool) {
if data == nil {
return
}
data["noindex"] = true
if follow {
data["robots"] = "noindex,follow"
return
}
data["robots"] = "noindex,nofollow"
}
func pageNoIndex(data gin.H) bool {
if noindex, _ := data["noindex"].(bool); noindex {
return true
}
robots, _ := data["robots"].(string)
for _, directive := range strings.Split(strings.ToLower(robots), ",") {
if strings.TrimSpace(directive) == "noindex" {
return true
}
}
return false
}
func toInt64(v any) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case int32:
return int64(n)
case float64:
return int64(n)
case string:
i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
return i
default:
return 0
}
}
func toInt(v any) int {
return int(toInt64(v))
}
+66
View File
@@ -0,0 +1,66 @@
package web
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
)
func TestBuildPageConsoleDataFiltersCommonSessionFields(t *testing.T) {
payload := buildPageConsoleData("/detail/example", http.StatusOK, "detail.html", gin.H{
"title": "示例短剧",
"detail": gin.H{"id": 12, "slug": "example"},
"appConfig": "public config",
"headerNav": "navigation",
"footer": "footer data",
"isLoggedIn": true,
"email": "viewer@example.com",
"pageConsoleData": "old payload",
})
if payload["path"] != "/detail/example" || payload["status"] != http.StatusOK || payload["template"] != "detail.html" {
t.Fatalf("unexpected page console metadata: %#v", payload)
}
pageData, ok := payload["data"].(gin.H)
if !ok {
t.Fatalf("payload data type = %T, want gin.H", payload["data"])
}
if _, ok := pageData["detail"]; !ok {
t.Fatalf("page response data was removed: %#v", pageData)
}
if payload["headerNav"] != "navigation" || payload["footer"] != "footer data" {
t.Fatalf("common navigation/footer data missing: %#v", payload)
}
for _, key := range []string{"appConfig", "headerNav", "isLoggedIn", "email", "pageConsoleData"} {
if _, ok := pageData[key]; ok {
t.Fatalf("excluded key %q remains in console data: %#v", key, pageData)
}
}
}
func TestSetNoIndexAndPageNoIndex(t *testing.T) {
data := gin.H{}
setNoIndex(data, false)
if data["robots"] != "noindex,nofollow" || !pageNoIndex(data) {
t.Fatalf("noindex policy was not applied: %#v", data)
}
if pageNoIndex(gin.H{"robots": "index,follow"}) {
t.Fatal("indexable robots value was treated as noindex")
}
}
func TestRichtextIndexPolicy(t *testing.T) {
publicData := gin.H{}
applyRichtextIndexPolicy(publicData, "关于我们")
if pageNoIndex(publicData) {
t.Fatal("about page should remain indexable")
}
legalData := gin.H{}
applyRichtextIndexPolicy(legalData, "隐私政策")
if !pageNoIndex(legalData) || legalData["robots"] != "noindex,follow" {
t.Fatalf("privacy page policy = %#v, want noindex,follow", legalData)
}
}
+88
View File
@@ -0,0 +1,88 @@
package web
import (
"net/url"
"strings"
"videoweb/app/service/configser"
"videoweb/app/service/seoser"
"github.com/gin-gonic/gin"
)
// Richtext 富文本页 SSR:GET /api/app/config/richtext/list?titles=
func Richtext(c *gin.Context) {
title := routeOrQuery(c, "title", "title")
target := "/richtext"
if title != "" {
target += "/" + url.PathEscape(title)
}
target += "/"
if redirectCanonical(c, target) {
return
}
renderRichtext(c, title)
}
func RichtextPage(title string) gin.HandlerFunc {
return func(c *gin.Context) {
page := configser.FetchRichTextByTitle(c, title)
if !page.Found {
fallbackTemplates := map[string]string{
"关于我们": "aboutUs.html",
"用户协议": "userAgreement.html",
"隐私政策": "privacyPolicy.html",
"帮助中心": "helpCenter.html",
}
if fallbackTemplate := fallbackTemplates[title]; fallbackTemplate != "" {
data := gin.H{"title": title}
applyRichtextIndexPolicy(data, title)
if title == "帮助中心" {
data["faqItems"] = []seoser.FAQItem{
{Question: "无法登录账号怎么办?", Answer: "请确认邮箱与密码是否正确;若忘记密码,可在登录页面使用“找回密码”。"},
{Question: "观看记录没有同步?", Answer: "请确认已登录同一账号并保持网络连接,记录通常会自动同步。"},
{Question: "视频无法播放或加载缓慢?", Answer: "建议切换网络、刷新页面或更新浏览器;问题持续时可提交反馈。"},
{Question: "如何管理收藏内容?", Answer: "进入个人中心的“追剧与收藏”页面,可查看或取消收藏。"},
}
}
HTMLOK(c, fallbackTemplate, data)
return
}
}
renderRichtextData(c, title, page)
}
}
func renderRichtext(c *gin.Context, title string) {
title = strings.TrimSpace(title)
page := configser.FetchRichTextByTitle(c, title)
renderRichtextData(c, title, page)
}
func renderRichtextData(c *gin.Context, title string, page configser.RichTextPageVM) {
if !page.Found {
NotFound(c, "未找到该内容")
return
}
displayTitle := page.Title
if displayTitle == "" {
displayTitle = "内容详情"
}
data := gin.H{
"title": displayTitle,
"richtext": page,
"richtextKey": title,
}
applyRichtextIndexPolicy(data, title)
HTMLOK(c, "richtext.html", data)
}
func applyRichtextIndexPolicy(data gin.H, title string) {
switch strings.TrimSpace(title) {
case "关于我们", "帮助中心":
return
default:
setNoIndex(data, true)
}
}
+43
View File
@@ -0,0 +1,43 @@
package web
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func routeOrQuery(c *gin.Context, routeName, queryName string) string {
if c == nil {
return ""
}
if value := strings.TrimSpace(c.Param(routeName)); value != "" {
return value
}
return strings.TrimSpace(c.Query(queryName))
}
func hasQuery(c *gin.Context, names ...string) bool {
if c == nil || c.Request == nil || c.Request.URL == nil {
return false
}
values := c.Request.URL.Query()
for _, name := range names {
if _, ok := values[name]; ok {
return true
}
}
return false
}
func redirectCanonical(c *gin.Context, target string) bool {
target = strings.TrimSpace(target)
if c == nil || target == "" || c.Request == nil || c.Request.URL == nil {
return false
}
if c.Request.URL.RequestURI() == target {
return false
}
c.Redirect(http.StatusMovedPermanently, target)
return true
}
+45
View File
@@ -0,0 +1,45 @@
package web
import (
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Search 搜索页 SSR:GET /api/app/playlet/search,列表直接用接口字段渲染。
func Search(c *gin.Context) {
q := playletser.ParseSearchQuery(routeOrQuery(c, "keyword", "q"), routeOrQuery(c, "page", "page"))
if redirectCanonical(c, playletser.SearchCanonicalPath(q)) {
return
}
var raw *playletser.PlayletVideoResp
if q.Keyword != "" {
raw = playletser.FetchSearchOrEmpty(c, q)
} else {
raw = &playletser.PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
emptyTitle := "请输入关键词搜索"
emptyDesc := "在上方输入剧名、题材或标签开始搜索"
if q.Keyword != "" {
emptyTitle = "未找到相关短剧"
emptyDesc = "换个关键词试试,或返回首页看看热门内容"
}
pageTitle := "搜索短剧"
description := "在黄果短剧搜索短剧名称、标签与关键词,快速找到想看的剧集。"
if q.Keyword != "" {
pageTitle = "搜索:" + q.Keyword
description = "在黄果短剧搜索「" + q.Keyword + "」相关短剧结果,快速找到想看的剧集。"
}
data := gin.H{
"title": pageTitle,
"description": description,
"query": q.Keyword,
"list": raw.List,
"totalCount": raw.TotalCount,
"pagination": playletser.BuildSearchPagination(q, raw.TotalCount),
"emptyTitle": emptyTitle,
"emptyDesc": emptyDesc,
}
setNoIndex(data, true)
HTMLOK(c, "search.html", data)
}
+38
View File
@@ -0,0 +1,38 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/configser"
"videoweb/app/service/seoser"
"github.com/gin-gonic/gin"
)
// RobotsTXT 动态 robots.txt(含 Sitemap 声明)。
func RobotsTXT(c *gin.Context) {
cfg := configser.FetchAppConfigVMOrEmpty(c)
foreverURL := seoser.ResolveSiteURL(c, cfg)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600")
c.String(http.StatusOK, seoser.BuildRobotsTXT(foreverURL))
}
// SEOVerifyFile 提供百度 HTML 验证文件和 IndexNow key.txt。
func SEOVerifyFile(c *gin.Context) {
codes := seoser.VerifyFromConfig()
name := strings.TrimPrefix(c.Request.URL.Path, "/")
switch {
case codes.BaiduHTML != "" && name == codes.BaiduHTML:
c.Header("Cache-Control", "public, max-age=86400")
c.String(http.StatusOK, codes.Baidu)
return
case codes.IndexNowKey != "" && name == codes.IndexNowKey+".txt":
c.Header("Cache-Control", "public, max-age=86400")
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(codes.IndexNowKey))
return
}
c.Status(http.StatusNotFound)
}
+46
View File
@@ -0,0 +1,46 @@
package web
import (
"strings"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Series AI 成人剧集 SSR:Swagger operate/list 固定 operateType=2,筛选映射 filterType=1/2/3。
func Series(c *gin.Context) {
q, ok := playletser.ParseSeriesQuery(c.Param("filter"), routeOrQuery(c, "page", "page"))
if !ok {
NotFound(c, "页面不存在")
return
}
if redirectCanonical(c, playletser.SeriesCanonicalPath(q)) {
return
}
raw := playletser.FetchOperateListOrEmpty(c, q)
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
NotFound(c, "页面不存在")
return
}
sectionTitle := playletser.SeriesSectionTitle(q.FilterType)
data := gin.H{
"title": "AI成人剧集",
"description": "黄果短剧精选AI成人剧集,持续更新热门漫剧、独家原创剧集,支持免费在线观看。",
"pageKey": "series",
"pageName": "AI成人剧集",
"pagePath": playletser.SeriesCanonicalPath(q),
"navActive": "aiSeries",
"filters": playletser.BuildSeriesFilterTabs(q),
"sectionTitle": sectionTitle,
"seoSelections": []playletser.SEOSelection{{Dimension: "筛选", Value: strings.TrimSuffix(sectionTitle, "的AI成人剧集")}},
"list": raw.List,
"itemListOffset": (q.PageNum - 1) * q.PageSize,
"pagination": playletser.BuildSeriesPagination(q, raw.TotalCount),
}
// 剧集入口页(/series/、/series/popular/、/series/original/)始终可索引;空结果仅对分页打 noindex。
if q.PageNum > 1 && (raw.TotalCount <= 0 || len(raw.List) == 0) {
setNoIndex(data, true)
}
HTMLOK(c, "series.html", data)
}
+32
View File
@@ -0,0 +1,32 @@
package web
import (
"videoweb/app/service/authser"
"github.com/gin-gonic/gin"
)
// AbortSSRIfSessionExpired 处理 SSR 场景下的会话失效码。
// aborted=true:已跳转登录,调用方应立刻 return;
// retry=true:游客已重新登录,调用方应重试上游请求。
func AbortSSRIfSessionExpired(c *gin.Context, code int) (aborted, retry bool) {
if !authser.IsSessionExpiredCode(code) {
return false, false
}
retry, needLogin := authser.OnSessionExpired(c, code)
if needLogin {
RedirectToLogin(c)
return true, false
}
return false, retry
}
// TryJSONSessionRecover JSON 接口遇到会话失效时:游客重登后返回 true(应重试);
// 注册账号清 cookie 后返回 false,调用方继续把 code=1000 交给前端弹登录。
func TryJSONSessionRecover(c *gin.Context, code int) bool {
if !authser.IsSessionExpiredCode(code) {
return false
}
retry, _ := authser.OnSessionExpired(c, code)
return retry
}
+119
View File
@@ -0,0 +1,119 @@
package web
import (
"net/http"
"videoweb/app/service/authser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// Setting 账号设置页:SSR 拉取 /api/app/auth/getInfo。
func Setting(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
RedirectToLogin(c)
return
}
env, user, err := authser.GetInfo(c)
if err != nil {
log.Error("setting getInfo failed", log.E(err))
authser.ClearAuthCookies(c)
RedirectToLogin(c)
return
}
if env != nil && authser.IsSessionExpiredCode(env.Code) {
retry, needLogin := authser.OnSessionExpired(c, env.Code)
if needLogin || !retry {
RedirectToLogin(c)
return
}
env, user, err = authser.GetInfo(c)
if err != nil || env == nil || env.Code != 200 || user == nil {
authser.ClearAuthCookies(c)
RedirectToLogin(c)
return
}
} else if env == nil || env.Code != 200 || user == nil {
authser.ClearAuthCookies(c)
RedirectToLogin(c)
return
}
authser.SetAuthCookies(c, user)
vm := authser.BuildSettingVM(user)
data := gin.H{
"title": "账号设置",
"setting": vm,
}
setNoIndex(data, false)
HTMLOK(c, "setting.html", data)
}
// EditProfile 编辑资料页:SSR 拉取 getInfo,头像/昵称等回填。
func EditProfile(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
RedirectToLogin(c)
return
}
env, user, err := authser.GetInfo(c)
if err != nil {
log.Error("edit-profile getInfo failed", log.E(err))
authser.ClearAuthCookies(c)
RedirectToLogin(c)
return
}
if env != nil && authser.IsSessionExpiredCode(env.Code) {
retry, needLogin := authser.OnSessionExpired(c, env.Code)
if needLogin || !retry {
RedirectToLogin(c)
return
}
env, user, err = authser.GetInfo(c)
if err != nil || env == nil || env.Code != 200 || user == nil {
authser.ClearAuthCookies(c)
RedirectToLogin(c)
return
}
} else if env == nil || env.Code != 200 || user == nil {
authser.ClearAuthCookies(c)
RedirectToLogin(c)
return
}
authser.SetAuthCookies(c, user)
vm := authser.BuildSettingVM(user)
data := gin.H{
"title": "编辑个人资料",
"profile": vm,
}
setNoIndex(data, false)
HTMLOK(c, "editProfile.html", data)
}
// EditPassword 修改密码页:需登录。
func EditPassword(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
RedirectToLogin(c)
return
}
data := gin.H{
"title": "修改登录密码",
}
setNoIndex(data, false)
HTMLOK(c, "editPassword.html", data)
}
// 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,
})
}
+75
View File
@@ -0,0 +1,75 @@
package web
import (
"strings"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
func TagList(c *gin.Context) {
q := playletser.ParseTagListQuery(routeOrQuery(c, "page", "page"))
if slug := strings.TrimSpace(c.Param("slug")); slug != "" {
if id := playletser.ParseDetailID(slug); id > 0 {
q.TagID = id
} else {
q.TagSlug = slug
}
}
if path := c.Request.URL.Path; path == "/tag-list" || strings.HasPrefix(path, "/tag-list/") {
redirectCanonical(c, playletser.TagListCanonicalPath(q))
return
}
raw := playletser.FetchTagListOrEmpty(c, q)
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
NotFound(c, "页面不存在")
return
}
hasIdentifier := q.TagID > 0 || strings.TrimSpace(q.TagSlug) != ""
if emptyScopedPage(hasIdentifier, raw.TotalCount, len(raw.List)) {
NotFound(c, "标签不存在")
return
}
if q.TagID > 0 {
q.TagSlug = playletser.FindTagSlug(raw.List, q.TagID)
}
if redirectCanonical(c, playletser.TagListCanonicalPath(q)) {
return
}
pageName := playletser.FindTagNameBySlug(raw.List, q.TagSlug)
if pageName == "" {
pageName = playletser.FindTagName(raw.List, q.TagID)
}
if pageName == "" {
if name := strings.TrimSpace(raw.SeoInfo.Title); name != "" {
pageName = name
} else {
pageName = "标签"
}
}
description := strings.TrimSpace(raw.SeoInfo.Description)
if description == "" {
description = "精选" + pageName + "题材相关AI成人短剧与AI成人漫剧,持续更新" + pageName + "题材作品,方便快速浏览相关短剧。"
}
title := playletser.TagListPageTitle(pageName, q.PageNum, raw.SeoInfo)
data := gin.H{
"title": title,
"pageKey": "tag_list",
"pageName": pageName,
"pageTitle": pageName,
"tagDescription": description,
"pageNum": q.PageNum,
"pagePath": playletser.TagListCanonicalPath(q),
"seoInfo": raw.SeoInfo,
"list": raw.List,
"totalCount": raw.TotalCount,
"itemListOffset": (q.PageNum - 1) * q.PageSize,
"pagination": playletser.BuildTagListPagination(q, raw.TotalCount),
}
if raw.TotalCount <= 0 || len(raw.List) == 0 {
setNoIndex(data, true)
}
HTMLOK(c, "tagList.html", data)
}
+39
View File
@@ -0,0 +1,39 @@
package web
import (
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// ThemeIndex 专题列表页:专题数据来自 GET /api/app/theme/list?themeType=2。
func ThemeIndex(c *gin.Context) {
q := playletser.ParseThemeIndexQuery(c.Param("page"))
if redirectCanonical(c, playletser.ThemeIndexCanonicalPath(q)) {
return
}
themes := playletser.FetchThemesOrEmpty(c, 2)
list, totalCount := playletser.BuildThemeIndexPage(themes, q)
if playletser.PageOutOfRange(q.PageNum, q.PageSize, totalCount) {
NotFound(c, "页面不存在")
return
}
data := gin.H{
"title": "AI成人短剧专题推荐" + playletser.PageTitleSuffix(q.PageNum),
"description": "专题集中展示黄果短剧各类精品专题合集,涵盖AI成人短剧、AI色情短剧、擦边短剧、AI换脸、AI魔改等热门主题。内容丰富多样,每日持续更新。",
"pageKey": "theme_index",
"pageName": "专题",
"navActive": "topic",
"pageNum": q.PageNum,
"pagePath": playletser.ThemeIndexCanonicalPath(q),
"list": list,
"totalCount": totalCount,
"itemListOffset": (q.PageNum - 1) * q.PageSize,
"pagination": playletser.BuildThemeIndexPagination(q, totalCount),
}
if totalCount <= 0 || len(list) == 0 {
setNoIndex(data, true)
}
HTMLOK(c, "themeIndex.html", data)
}
+33
View File
@@ -0,0 +1,33 @@
package web
import (
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// TodayUpdate 今日更新页 SSR:GET /api/app/playlet/today-update,列表用接口字段渲染。
func TodayUpdate(c *gin.Context) {
q := playletser.ParseTodayUpdateQuery(routeOrQuery(c, "page", "page"))
if redirectCanonical(c, playletser.TodayUpdateCanonicalPath(q)) {
return
}
raw := playletser.FetchTodayUpdateOrEmpty(c, q)
if playletser.PageOutOfRange(q.PageNum, q.PageSize, raw.TotalCount) {
NotFound(c, "页面不存在")
return
}
data := gin.H{
"title": "今日更新",
"description": "黄果短剧今日更新:第一时间追更最新上线短剧与连载剧集,不错过每日精彩更新。",
"navActive": "todayUpdate",
"seoInfo": raw.SeoInfo,
"list": raw.List,
"itemListOffset": (q.PageNum - 1) * q.PageSize,
"pagination": playletser.BuildTodayUpdatePagination(q, raw.TotalCount),
}
if raw.TotalCount <= 0 || len(raw.List) == 0 {
setNoIndex(data, true)
}
HTMLOK(c, "todayUpdate.html", data)
}
+127
View File
@@ -0,0 +1,127 @@
package web
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// attachTrackerPage 按响应状态和路径注入 pageKey / pageName(handler 可显式覆盖)。
// 所有 HTML 页面都必须得到明确页面信息,避免埋点落入 unknown / 未知页面。
func attachTrackerPage(c *gin.Context, code int, data gin.H) {
if data == nil {
return
}
key, name := trackerPageFromRequest(c.Request.URL.Path, code)
if code >= http.StatusBadRequest {
data["pageKey"] = key
data["pageName"] = name
return
}
existingKey, _ := data["pageKey"].(string)
existingName, _ := data["pageName"].(string)
existingKey = strings.TrimSpace(existingKey)
existingName = strings.TrimSpace(existingName)
if existingKey == "" || existingKey == "unknown" {
data["pageKey"] = key
}
if existingName == "" || existingName == "未知页面" {
data["pageName"] = name
}
}
func trackerPageFromRequest(path string, code int) (pageKey, pageName string) {
if code >= http.StatusInternalServerError {
return "server_error", "服务异常页面"
}
if code >= http.StatusBadRequest {
return "not_found", "404页面"
}
return trackerPageFromPath(path)
}
func trackerPageFromPath(path string) (pageKey, pageName string) {
p := strings.TrimSpace(path)
if p == "" || p == "/" {
return "home", "首页"
}
// 去掉尾部斜杠,便于精确匹配
p = strings.TrimSuffix(p, "/")
switch {
case trackerPathHasPrefix(p, "/list"):
return "list", "短剧列表"
case trackerPathHasPrefix(p, "/series"):
return "series", "AI成人剧集"
case trackerPathHasPrefix(p, "/tag"), trackerPathHasPrefix(p, "/tag-list"):
return "tag_list", "标签详情"
case trackerPathHasPrefix(p, "/rank"):
return "rank", "排行榜"
case trackerPathHasPrefix(p, "/today-update"):
return "today_update", "今日更新"
case trackerPathHasPrefix(p, "/category"):
return "category_detail", "分类详情"
case trackerTopicIndexPath(p):
return "theme_index", "专题列表"
case trackerPathHasPrefix(p, "/topic"), trackerPathHasPrefix(p, "/more"):
return "topic", "专题详情"
case trackerPathHasPrefix(p, "/download-app"):
return "download_app", "下载APP"
case trackerPathHasPrefix(p, "/application"):
return "application", "应用推荐"
case trackerPathHasPrefix(p, "/home-road"):
return "home_road", "回家的路"
case trackerPathHasPrefix(p, "/detail"):
return "detail", "短剧详情"
case trackerPathHasPrefix(p, "/play"):
return "play", "播放页"
case trackerPathHasPrefix(p, "/login"):
return "login", "登录"
case trackerPathHasPrefix(p, "/register"):
return "register", "注册"
case trackerPathHasPrefix(p, "/user-center"):
return "user_center", "个人中心"
case trackerPathHasPrefix(p, "/deposit"):
return "deposit", "充值中心"
case trackerPathHasPrefix(p, "/collect"):
return "collect", "我的收藏"
case trackerPathHasPrefix(p, "/history"):
return "history", "观看历史"
case trackerPathHasPrefix(p, "/setting"):
return "setting", "设置"
case trackerPathHasPrefix(p, "/edit-profile"):
return "edit_profile", "编辑资料"
case trackerPathHasPrefix(p, "/edit-password"):
return "edit_password", "修改密码"
case trackerPathHasPrefix(p, "/search"):
return "search", "搜索结果页"
case trackerPathHasPrefix(p, "/about-us"):
return "about_us", "关于我们"
case trackerPathHasPrefix(p, "/user-agreement"):
return "user_agreement", "用户协议"
case trackerPathHasPrefix(p, "/privacy-policy"):
return "privacy_policy", "隐私政策"
case trackerPathHasPrefix(p, "/help-center"):
return "help_center", "帮助中心"
case trackerPathHasPrefix(p, "/richtext"):
return "rich_text", "富文本页"
case trackerPathHasPrefix(p, "/feedback"):
return "feedback", "问题反馈"
case trackerPathHasPrefix(p, "/nav"), trackerPathHasPrefix(p, "/dh"):
return "nav", "官方导航"
default:
return "internal_other", "站内其他页面"
}
}
func trackerPathHasPrefix(path, base string) bool {
return path == base || strings.HasPrefix(path, base+"/")
}
func trackerTopicIndexPath(path string) bool {
if path == "/topic" || path == "/more" {
return true
}
return strings.HasPrefix(path, "/topic/page/") || strings.HasPrefix(path, "/more/page/")
}
+107
View File
@@ -0,0 +1,107 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestTrackerPageFromPath(t *testing.T) {
tests := []struct {
path string
key string
name string
}{
{"/", "home", "首页"},
{"/list/ai-short-play/2/page/3/", "list", "短剧列表"},
{"/series/hot/page/2/", "series", "AI成人剧集"},
{"/tag/family/page/2/", "tag_list", "标签详情"},
{"/tag-list/family/", "tag_list", "标签详情"},
{"/rank/2/page/3/", "rank", "排行榜"},
{"/today-update/page/2/", "today_update", "今日更新"},
{"/category/drama/page/2/", "category_detail", "分类详情"},
{"/topic/page/2/", "theme_index", "专题列表"},
{"/topic/sweet-love/page/2/", "topic", "专题详情"},
{"/more/legacy-topic/", "topic", "专题详情"},
{"/detail/my-playlet/", "detail", "短剧详情"},
{"/play/my-playlet/42/", "play", "播放页"},
{"/login/return/detail/foo/", "login", "登录"},
{"/register/", "register", "注册"},
{"/collect/chapter/page/2/", "collect", "我的收藏"},
{"/search/测试/page/2/", "search", "搜索结果页"},
{"/about-us/", "about_us", "关于我们"},
{"/user-agreement/", "user_agreement", "用户协议"},
{"/privacy-policy/", "privacy_policy", "隐私政策"},
{"/help-center/", "help_center", "帮助中心"},
{"/richtext/自定义内容/", "rich_text", "富文本页"},
{"/dh/", "nav", "官方导航"},
{"/unregistered-path/", "internal_other", "站内其他页面"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
key, name := trackerPageFromPath(tt.path)
if key != tt.key || name != tt.name {
t.Fatalf("trackerPageFromPath(%q) = (%q, %q), want (%q, %q)", tt.path, key, name, tt.key, tt.name)
}
})
}
}
func TestTrackerPageFromRequestUsesResponseStatus(t *testing.T) {
tests := []struct {
status int
key string
name string
}{
{http.StatusNotFound, "not_found", "404页面"},
{http.StatusInternalServerError, "server_error", "服务异常页面"},
}
for _, tt := range tests {
key, name := trackerPageFromRequest("/missing/", tt.status)
if key != tt.key || name != tt.name {
t.Fatalf("trackerPageFromRequest(status=%d) = (%q, %q), want (%q, %q)", tt.status, key, name, tt.key, tt.name)
}
}
}
func TestAttachTrackerPagePreservesExplicitPage(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/topic/custom/", nil)
data := gin.H{"pageKey": "topic", "pageName": "自定义专题"}
attachTrackerPage(c, http.StatusOK, data)
if data["pageKey"] != "topic" || data["pageName"] != "自定义专题" {
t.Fatalf("explicit tracker page was overwritten: %#v", data)
}
}
func TestAttachTrackerPageReplacesUnknownValues(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/play/demo/1/", nil)
data := gin.H{"pageKey": "unknown", "pageName": "未知页面"}
attachTrackerPage(c, http.StatusOK, data)
if data["pageKey"] != "play" || data["pageName"] != "播放页" {
t.Fatalf("unknown tracker page was not replaced: %#v", data)
}
}
func TestAttachTrackerPageMarksErrorResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/topic/deleted/", nil)
data := gin.H{"pageKey": "topic", "pageName": "已删除专题"}
attachTrackerPage(c, http.StatusNotFound, data)
if data["pageKey"] != "not_found" || data["pageName"] != "404页面" {
t.Fatalf("error response retained business page mapping: %#v", data)
}
}
+58
View File
@@ -0,0 +1,58 @@
package web
import (
"strconv"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// UserCenter 个人中心:登录态刷新 getInfo 金币余额,并拉取收藏数量。
func UserCenter(c *gin.Context) {
data := gin.H{
"title": "个人中心",
"navActive": "userCenter",
}
setNoIndex(data, false)
if attachWalletFromGetInfo(c, data) {
RedirectToLogin(c)
return
}
if useragent.TokenFromRequest(c.Request) != "" {
raw, code, err := playletser.FetchCollectList(c, playletser.CollectListQuery{
PageNum: 1,
PageSize: 1,
})
if err != nil {
aborted, retry := AbortSSRIfSessionExpired(c, code)
if aborted {
return
}
if retry {
raw, _, err = playletser.FetchCollectList(c, playletser.CollectListQuery{
PageNum: 1,
PageSize: 1,
})
}
if err != nil {
log.Error("user-center collect count failed", log.E(err))
raw = nil
}
}
count := int64(0)
if raw != nil {
count = raw.TotalCount
}
if count < 0 {
count = 0
}
data["collectCount"] = count
data["collectCountText"] = strconv.FormatInt(count, 10) + "部"
} else {
data["collectCount"] = int64(0)
data["collectCountText"] = "0部"
}
HTMLOK(c, "userCenter.html", data)
}
+33
View File
@@ -0,0 +1,33 @@
package appg
import "github.com/patrickmn/go-cache"
const EsIndex = "sz"
var (
Conf GlobalCfgEx
Cache *cache.Cache
)
type GlobalCfgEx struct {
ServerAddr string `yaml:"serverAddr"`
ListenAddr string `yaml:"listenAddr"`
DHListenAddr string `yaml:"dhListenAddr"`
RunMod string `yaml:"runMod"`
TsAuthKey string `yaml:"tsAuthKey"`
SEO SEOConf `yaml:"seo"`
Tracker TrackerConf `yaml:"tracker"`
}
// SEOConf 站长平台验证与 SEO 相关配置。
type SEOConf struct {
BaiduSiteVerification string `yaml:"baiduSiteVerification"`
BaiduHTMLFile string `yaml:"baiduHtmlFile"` // 如 baidu_verify_codeva-xxx.html
IndexNowKey string `yaml:"indexNowKey"` // Bing IndexNow 密钥,提供 /{key}.txt
}
// TrackerConf 数据上报 SDK 本地兜底(上游 reportSdkConfig 优先)。
type TrackerConf struct {
AppID string `yaml:"appId"`
EncryptedConfig string `yaml:"encryptedConfig"`
}
+11
View File
@@ -0,0 +1,11 @@
serverAddr: "http://172.104.181.99:27413"
listenAddr: "25119"
dhListenAddr: "26812"
runMod: "dev"
tsAuthKey: "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN"
# 数据上报 SDK(web-sdk)。encryptedConfig 优先用上游 config/info.reportSdkConfig;
# 若上游暂未下发,可在此填写密文兜底。
tracker:
appId: "JHA-1043"
encryptedConfig: ""
+1
View File
@@ -0,0 +1 @@
—Џ€И’ љ БCЏ„
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
FILE_NAME="web_cgdj_h5"
REMOTE_FILE_PATH="/home/server2/web_cgdj/h5/"
TAG_NAME="web_cgdj_h5.tar.gz"
set -e
set -o
function send_msg() {
msg="@jscdsndj @xiuyuan_Lee @steven0yu %0A服务:SERVER2  站点-cgdj-H5 %0A更新内容:$content%0A创建时间: $(date +%F_%X)%0A操作人:$(git config user.name)"
curl -X POST "https://api.telegram.org/bot6357085965:AAEmXfjZnZwnDTicL7Wjbd7cXvYv_G98w8A/sendMessage" -d "chat_id=$1&text=$msg" >/dev/null 2>&1
}
read -p "输入yes, 开始上传: " word
if [[ ! "yes" =~ $word ]]; then
echo "终止"
else
read -e -p "本次更新内容(输入q退出编辑): " -r content
echo "开始"
rm -rf ./$FILE_NAME ./$TAG_NAME ./yinsemd5.txt
GOOS=linux GOARCH=amd64 go build -o $FILE_NAME
md5sum $FILE_NAME | cut -d ' ' -f1 >yinsemd5.txt
# 排除 app 定时生成的 SEO 静态文件,避免覆盖服务器上已有 sitemap/rss/llms
tar zvcf $TAG_NAME ./$FILE_NAME templates/ static/ yinsemd5.txt \
--exclude='templates/page/sitemaps/sitemap.xml' \
--exclude='templates/page/sitemaps/sitemap-*.xml' \
--exclude='templates/page/rss.xml' \
--exclude='templates/page/llms.txt' \
--exclude='templates/page/llms-full.txt'
md5sum $TAG_NAME | cut -d ' ' -f1 >yinsemd5.txt
scp -i ~/.ssh/id_rsa -P 22889 ./yinsemd5.txt server2@116.212.120.2:$REMOTE_FILE_PATH
scp -i ~/.ssh/id_rsa -P 22889 ./$TAG_NAME server2@116.212.120.2:$REMOTE_FILE_PATH
send_msg -4791758991 #服务器小组
fi
rm -rf ./$FILE_NAME ./$TAG_NAME ./yinsemd5.txt
View File
+55
View File
@@ -0,0 +1,55 @@
//go:generate swag init --parseDependency --parseDepth 4
//go:generate mtool replicate-module -m contest
package main
import (
"os"
"videoweb/app/server"
)
// @title 视频站APP接口
// @version 1.0
// @description 这是视频站APP接口服务.
// @termsOfService
// @contact.name
// @contact.url
// @contact.email
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @host https://视频站-api.lcncctn.com
// @BasePath /
// @Schemes http https
// @securityDefinitions.basic BasicAuth
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization
// @securitydefinitions.oauth2.application OAuth2Application
// @tokenUrl https://example.com/oauth/token
// @scope.write Grants write access
// @scope.admin Grants read and write access to administrative information
// @securitydefinitions.oauth2.implicit OAuth2Implicit
// @authorizationUrl https://example.com/oauth/authorize
// @scope.write Grants write access
// @scope.admin Grants read and write access to administrative information
// @securitydefinitions.oauth2.password OAuth2Password
// @tokenUrl https://example.com/oauth/token
// @scope.read Grants read access
// @scope.write Grants write access
// @scope.admin Grants read and write access to administrative information
// @securitydefinitions.oauth2.accessCode OAuth2AccessCode
// @tokenUrl https://example.com/oauth/token
// @authorizationUrl https://example.com/oauth/authorize
// @scope.admin Grants read and write access to administrative information
func main() {
server.Run(os.Args)
}
View File
@@ -0,0 +1,25 @@
package router
import (
"os"
"testing"
"github.com/gin-gonic/gin"
)
func TestCategoryDetailTemplateParses(t *testing.T) {
workingDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(".."); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(workingDir) })
engine := gin.New()
setupEngine(engine)
if engine.HTMLRender == nil {
t.Fatal("HTML renderer was not initialized")
}
}
+16
View File
@@ -0,0 +1,16 @@
package router
import (
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// applyClientForwardHeaders 向上游透传客户端 IP / UA(与 API 上游一致):
// X-Real-IP、User-Agent、X-User-Agent。
func applyClientForwardHeaders(header map[string]string, c *gin.Context) {
if header == nil {
return
}
useragent.ApplyToHeader(header, c, useragent.DefaultVer)
}
+321
View File
@@ -0,0 +1,321 @@
package router
import (
"encoding/json"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"videoweb/app/api/web"
"videoweb/app/appg"
"videoweb/app/service/configser"
"videoweb/app/service/playletser"
"videoweb/app/service/seoser"
"github.com/gin-gonic/gin"
)
func Init() *gin.Engine {
setGinMode()
engine := gin.Default()
engine.HandleMethodNotAllowed = true
setupEngine(engine)
engine.NoRoute(renderNotFound)
engine.NoMethod(renderNotFound)
registerM3U8Routes(engine)
registerRoutes(engine)
return engine
}
// DHInit 导航站独立端口:仅暴露官方导航页及静态资源。
func DHInit() *gin.Engine {
setGinMode()
engine := gin.Default()
engine.HandleMethodNotAllowed = true
setupEngine(engine)
engine.StaticFile("/favicon.ico", "./static/favicon.ico")
engine.StaticFile("/favicon.svg", "./static/favicon.svg")
engine.StaticFile("/favicon.png", "./static/favicon.png")
engine.GET("/ui/dialog/:name", web.DialogFragment)
engine.GET("/", web.Nav)
engine.GET("/nav/", web.Nav)
engine.GET("/dh/", web.Nav)
engine.NoRoute(renderNotFound)
engine.NoMethod(renderNotFound)
return engine
}
func setGinMode() {
switch strings.ToLower(strings.TrimSpace(appg.Conf.RunMod)) {
case "test":
gin.SetMode(gin.TestMode)
case "prod":
gin.SetMode(gin.ReleaseMode)
default:
gin.SetMode(gin.DebugMode)
}
}
func setupEngine(engine *gin.Engine) {
tplFuncs := playletser.TemplateFuncs()
tplFuncs["InJSONScript"] = inJSONScript
tplFuncs["jsonStr"] = jsonStr
tplFuncs["InTrackerAppId"] = inTrackerAppId
engine.SetFuncMap(tplFuncs)
engine.LoadHTMLFiles(listHTMLTemplates("templates")...)
engine.Use(staticCacheHeaders(), crawlerHeaders())
registerBrowserDiscoveryRoutes(engine)
engine.Static("/static", "./static")
}
// registerBrowserDiscoveryRoutes 处理 Chrome DevTools 的本地工作区探测请求。
// 该请求并非站点页面,返回 204 可避免进入业务 404 页面和产生误导日志。
func registerBrowserDiscoveryRoutes(engine *gin.Engine) {
engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=86400")
c.Header("X-Robots-Tag", "noindex, nofollow")
c.Status(http.StatusNoContent)
})
}
// staticCacheHeaders 为静态资源设置浏览器缓存,减少重复下载。
func staticCacheHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/static/") {
c.Header("Cache-Control", "public, max-age=604800, stale-while-revalidate=86400")
}
c.Next()
}
}
// crawlerHeaders 防止接口、片段和健康检查等非 HTML 内容进入搜索索引。
func crawlerHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/ui/") ||
strings.HasPrefix(path, "/h5/") ||
path == "/healthz" || path == "/enkey" || path == "/newenkey" {
c.Header("X-Robots-Tag", "noindex, nofollow")
}
c.Next()
}
}
// listHTMLTemplates 收集 templates 下页面模板,跳过遗留广告文件。
func listHTMLTemplates(root string) []string {
var files []string
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
name := strings.ToLower(d.Name())
if !strings.HasSuffix(name, ".html") {
return nil
}
if name == "banners.html" {
return nil
}
files = append(files, path)
return nil
})
if len(files) == 0 {
panic("no html templates found under " + root)
}
return files
}
// inJSONScript 将任意数据序列化为可嵌入 <script> 的 JSON。
func inJSONScript(v any) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("null")
}
return template.JS(b)
}
// jsonStr 将字符串序列化为可嵌入 application/ld+json 的 JSON 字符串字面量。
func jsonStr(s string) template.JS {
b, err := json.Marshal(s)
if err != nil {
return template.JS(`""`)
}
return template.JS(b)
}
func inTrackerAppId() string {
return configser.ResolveTrackerAppID()
}
func registerRoutes(r *gin.Engine) {
r.GET("/", web.Index)
r.GET("/ui/dialog/:name", web.DialogFragment)
r.POST("/api/auth/register", web.Register)
r.POST("/api/auth/login", web.Login)
r.POST("/api/auth/resetPassword", web.ResetPassword)
r.POST("/api/auth/guest", web.Guest)
r.POST("/api/auth/logout", web.Logout)
r.POST("/api/auth/profile", web.UpdateProfile)
r.GET("/api/auth/getInfo", web.GetUserInfo)
r.POST("/api/upload/image", web.UploadImage)
r.POST("/api/feedback/add", web.AddFeedback)
r.POST("/api/contactus/add", web.AddContactUs)
r.GET("/api/comment/list", web.CommentList)
r.POST("/api/comment/send", web.CommentSend)
r.POST("/api/comment/like/set", web.CommentLikeSet)
r.POST("/api/playlet/like/set", web.PlayletLikeSet)
r.POST("/api/playlet/collect/set", web.PlayletCollectSet)
r.POST("/api/playlet/forward/heat", web.PlayletForwardHeat)
r.GET("/api/playlet/chapter/detail", web.ChapterDetail)
r.GET("/api/playlet/chapter/list", web.ChapterList)
r.GET("/api/playlet/operate/list", web.OperateList)
r.GET("/api/playlet/hot-like", web.HotLike)
r.GET("/api/app/playlet/hot-like", web.HotLike)
r.POST("/api/playlet/chapter/like/set", web.ChapterLikeSet)
r.POST("/api/playlet/chapter/collect/set", web.ChapterCollectSet)
r.POST("/api/playlet/chapter/watch/report", web.ChapterWatchReport)
r.POST("/api/playlet/history/clear", web.HistoryClear)
r.POST("/api/playlet/chapter/unlock", web.ChapterUnlock)
r.POST("/api/coin/buy", web.CoinBuy)
r.POST("/api/vip/pay", web.VipPay)
r.GET("/list/", web.List)
r.GET("/list/:slug/", web.List)
r.GET("/list/:slug/:sortOrder/", web.List)
r.GET("/list/:slug/:sortOrder/page/:page/", web.List)
r.GET("/series/", web.Series)
r.GET("/series/page/:page/", web.Series)
r.GET("/series/:filter/", web.Series)
r.GET("/series/:filter/page/:page/", web.Series)
// 标签页面使用 tag 路由;tag-list 仅作为历史地址保留并由 handler 301 到 canonical。
r.GET("/tag/", web.TagList)
r.GET("/tag/page/:page/", web.TagList)
r.GET("/tag/:slug/", web.TagList)
r.GET("/tag/:slug/page/:page/", web.TagList)
r.GET("/tag-list/", web.TagList)
r.GET("/tag-list/page/:page/", web.TagList)
r.GET("/tag-list/:slug/", web.TagList)
r.GET("/tag-list/:slug/page/:page/", web.TagList)
r.GET("/rank/", web.Rank)
r.GET("/rank/page/:page/", web.Rank)
r.GET("/rank/:sortOrder/", web.Rank)
r.GET("/rank/:sortOrder/page/:page/", web.Rank)
r.GET("/today-update/", web.TodayUpdate)
r.GET("/today-update/page/:page/", web.TodayUpdate)
// 分类详情使用语义化 category 路由,分类标识优先使用 slug、无 slug 时使用 ID。
r.GET("/category/:slug/", web.CategoryDetail)
r.GET("/category/:slug/page/:page/", web.CategoryDetail)
// 专题列表和详情统一使用 topic 路由;more 仅作为历史地址保留并由 handler 301 到 canonical。
r.GET("/topic/", web.ThemeIndex)
r.GET("/topic/page/:page/", web.ThemeIndex)
r.GET("/topic/:slug/", web.More)
r.GET("/topic/:slug/page/:page/", web.More)
r.GET("/more/", web.More)
r.GET("/more/page/:page/", web.More)
r.GET("/more/:slug/", web.More)
r.GET("/more/:slug/page/:page/", web.More)
r.GET("/download-app/", func(c *gin.Context) {
web.HTMLOK(c, "downloadApp.html", gin.H{
"title": "下载APP",
"navActive": "downloadApp",
})
})
r.GET("/application/", web.Application)
r.GET("/home-road/", web.HomeRoad)
r.GET("/detail/:slug/", web.Detail)
r.GET("/play/:slug/", web.Play)
r.GET("/play/:slug/:chapterId/", web.Play)
r.GET("/login/", web.AuthIndex("login"))
r.GET("/login/return/*redirect", web.AuthIndex("login"))
r.GET("/register/", web.AuthIndex("register"))
r.GET("/user-center/", web.UserCenter)
r.GET("/deposit/", web.Deposit)
r.GET("/deposit/:tab/", web.Deposit)
r.GET("/collect/", web.Collect)
r.GET("/collect/page/:page/", web.Collect)
r.GET("/collect/:type/", web.Collect)
r.GET("/collect/:type/page/:page/", web.Collect)
r.GET("/history/", web.History)
r.GET("/history/page/:page/", web.History)
r.GET("/setting/", web.Setting)
r.GET("/edit-profile/", web.EditProfile)
r.GET("/edit-password/", web.EditPassword)
r.GET("/search/", web.Search)
r.GET("/search/:keyword/", web.Search)
r.GET("/search/:keyword/page/:page/", web.Search)
r.GET("/about-us/", web.RichtextPage("关于我们"))
r.GET("/user-agreement/", web.RichtextPage("用户协议"))
r.GET("/privacy-policy/", web.RichtextPage("隐私政策"))
r.GET("/help-center/", web.RichtextPage("帮助中心"))
r.GET("/richtext/", web.Richtext)
r.GET("/richtext/:title/", web.Richtext)
r.GET("/feedback/", func(c *gin.Context) {
web.HTMLOK(c, "feedback.html", gin.H{
"title": "问题反馈",
"noindex": true,
"robots": "noindex,nofollow",
})
})
r.GET("/nav/", web.Nav)
r.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
})
registerSitemapRoutes(r)
r.GET("/robots.txt", web.RobotsTXT)
registerSEOVerifyRoutes(r)
r.GET("/favicon.ico", func(c *gin.Context) {
c.File("./static/favicon.ico")
})
r.GET("/favicon.svg", func(c *gin.Context) {
c.File("./static/favicon.svg")
})
r.GET("/favicon.png", func(c *gin.Context) {
c.File("./static/favicon.png")
})
}
func renderNotFound(c *gin.Context) {
web.HTML(c, http.StatusNotFound, "404.html", gin.H{
"path": c.Request.URL.Path,
"noindex": true,
"robots": "noindex,follow",
})
}
func registerSEOVerifyRoutes(r *gin.Engine) {
codes := seoser.VerifyFromConfig()
if codes.BaiduHTML != "" {
r.GET("/"+codes.BaiduHTML, web.SEOVerifyFile)
}
if codes.IndexNowKey != "" {
r.GET("/"+codes.IndexNowKey+".txt", web.SEOVerifyFile)
}
}
+140
View File
@@ -0,0 +1,140 @@
package router
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestContentPagesOnlyRegisterPathParameterRoutes(t *testing.T) {
engine := gin.New()
registerRoutes(engine)
routes := engine.Routes()
registered := make(map[string]bool, len(routes))
handlers := make(map[string]string, len(routes))
for _, route := range routes {
key := route.Method + " " + route.Path
registered[key] = true
handlers[key] = route.Handler
}
for _, route := range []string{
"GET /detail/:slug",
"GET /play/:slug",
"GET /play/:slug/:chapterId",
"GET /topic",
"GET /topic/page/:page",
"GET /topic/:slug",
"GET /topic/:slug/page/:page",
"GET /tag",
"GET /tag/page/:page",
"GET /tag/:slug",
"GET /tag/:slug/page/:page",
"GET /series",
"GET /series/page/:page",
"GET /series/:filter",
"GET /series/:filter/page/:page",
"GET /category/:slug",
"GET /category/:slug/page/:page",
"GET /home-road",
} {
if !registered[route] {
t.Fatalf("missing path parameter route %s", route)
}
}
for _, route := range []string{
"POST /api/auth/resetPassword",
"POST /api/contactus/add",
"GET /api/playlet/chapter/list",
"GET /api/playlet/operate/list",
"GET /api/playlet/hot-like",
"GET /api/app/playlet/hot-like",
} {
if !registered[route] {
t.Fatalf("missing documented API route %s", route)
}
}
for _, route := range []string{"GET /detail", "GET /play"} {
if registered[route] {
t.Fatalf("legacy query route should not be registered: %s", route)
}
}
for route, handlerSuffix := range map[string]string{
"GET /topic": ".ThemeIndex",
"GET /topic/page/:page": ".ThemeIndex",
"GET /topic/:slug": ".More",
"GET /tag": ".TagList",
"GET /tag/:slug": ".TagList",
"GET /category/:slug": ".CategoryDetail",
} {
if !strings.HasSuffix(handlers[route], handlerSuffix) {
t.Fatalf("handler for %s = %q, want suffix %q", route, handlers[route], handlerSuffix)
}
}
}
func TestLegacyTagListRouteRedirectsToSemanticTagRoute(t *testing.T) {
engine := gin.New()
registerRoutes(engine)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/tag-list/fantasy/page/2", nil)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusMovedPermanently {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusMovedPermanently)
}
if location := recorder.Header().Get("Location"); location != "/tag/fantasy/page/2" {
t.Fatalf("Location = %q, want %q", location, "/tag/fantasy/page/2")
}
}
func TestLegacyAISeriesListRedirectsToSeriesPage(t *testing.T) {
engine := gin.New()
registerRoutes(engine)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/list/ai-series/1/page/3", nil)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusMovedPermanently {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusMovedPermanently)
}
if location := recorder.Header().Get("Location"); location != "/series/popular/page/3" {
t.Fatalf("Location = %q, want %q", location, "/series/popular/page/3")
}
}
func TestCrawlerHeadersNoIndexNonHTMLRoutes(t *testing.T) {
engine := gin.New()
engine.Use(crawlerHeaders())
engine.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusOK) })
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
engine.ServeHTTP(recorder, request)
if got := recorder.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
t.Fatalf("X-Robots-Tag = %q, want noindex, nofollow", got)
}
}
func TestChromeDevToolsDiscoveryReturnsNoContent(t *testing.T) {
engine := gin.New()
registerBrowserDiscoveryRoutes(engine)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/.well-known/appspecific/com.chrome.devtools.json", nil)
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent)
}
if got := recorder.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
t.Fatalf("X-Robots-Tag = %q, want noindex, nofollow", got)
}
}
+183
View File
@@ -0,0 +1,183 @@
package router
import (
"encoding/json"
"net/http"
"strings"
"videoweb/app/appg"
commonhttp "videoweb/pub/common/http"
"videoweb/pub/common/log"
"videoweb/pub/common/m3u8"
"github.com/gin-gonic/gin"
)
func registerM3U8Routes(r *gin.Engine) {
r.GET("/h5/m3u8/*path", proxyM3U8)
r.GET("/enkey", serveM3U8Key)
r.GET("/newenkey", serveNewM3U8Key)
}
func proxyM3U8(c *gin.Context) {
rawPath := strings.TrimPrefix(c.Param("path"), "/")
if rawPath == "" {
c.Status(http.StatusBadRequest)
return
}
if c.Request.URL.RawQuery != "" {
rawPath += "?" + c.Request.URL.RawQuery
}
serverAddr := strings.TrimRight(strings.TrimSpace(appg.Conf.ServerAddr), "/")
if serverAddr == "" {
log.Warn("proxyM3U8 empty serverAddr")
c.Status(http.StatusBadGateway)
return
}
targetURL := serverAddr + "/h5/m3u8/" + rawPath
// 播放地址本身带签名 token;勿强制带登录 Authorization。
body, status, err := fetchM3U8Upstream(c, targetURL, "")
if err != nil {
log.Warn("proxyM3U8 request failed", log.Any("target", targetURL), log.E(err))
c.Status(http.StatusBadGateway)
return
}
if status != http.StatusOK {
c.Status(status)
return
}
if code, ok := jsonErrorCode(body); ok {
log.Warn("proxyM3U8 upstream error",
log.Any("target", targetURL),
log.Any("code", code),
log.Any("body", string(body)))
c.Data(http.StatusBadGateway, "application/json; charset=utf-8", body)
return
}
contentType := detectMediaContentType(rawPath)
if isM3U8Content(rawPath, body) {
contentType = "application/vnd.apple.mpegurl"
}
c.Data(http.StatusOK, contentType, body)
}
func fetchM3U8Upstream(c *gin.Context, targetURL, auth string) ([]byte, int, error) {
header := map[string]string{
"Accept": "*/*",
}
if auth != "" {
header["Authorization"] = auth
}
applyClientForwardHeaders(header, c)
resp, err := commonhttp.GetLargeFile(targetURL, header)
if err != nil {
return nil, 0, err
}
return resp.Data, resp.StatusCode, nil
}
func jsonErrorCode(body []byte) (int, bool) {
if len(body) == 0 || body[0] != '{' {
return 0, false
}
var payload struct {
Code int `json:"code"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return 0, false
}
if payload.Code == 200 || payload.Code == 0 {
return 0, false
}
return payload.Code, true
}
func isM3U8Content(rawPath string, body []byte) bool {
if strings.Contains(strings.Split(rawPath, "?")[0], ".m3u8") {
return true
}
return len(body) > 0 && body[0] == '#'
}
func detectMediaContentType(rawPath string) string {
pathOnly := strings.Split(rawPath, "?")[0]
switch {
case strings.HasSuffix(pathOnly, ".m3u8"):
return "application/vnd.apple.mpegurl"
case strings.HasSuffix(pathOnly, ".ts"):
return "video/mp2t"
default:
return "application/octet-stream"
}
}
func serveM3U8Key(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Cache-Control", "no-store")
if key, ok := fetchUpstreamEnkey(c, c.Request.URL.RawQuery); ok {
c.Data(http.StatusOK, "application/octet-stream", key)
return
}
buffer, err := m3u8.Enkey(c.Request.Context())
if err != nil || buffer == nil || buffer.Len() == 0 {
log.Warn("serveM3U8Key local key unavailable", log.E(err))
c.Status(http.StatusBadGateway)
return
}
c.Data(http.StatusOK, "application/octet-stream", buffer.Bytes())
}
func serveNewM3U8Key(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Cache-Control", "no-store")
key, ok := fetchUpstreamKey(c, "/newenkey", c.Request.URL.RawQuery)
if !ok {
c.Status(http.StatusBadGateway)
return
}
c.Data(http.StatusOK, "application/octet-stream", key)
}
func fetchUpstreamEnkey(c *gin.Context, rawQuery string) ([]byte, bool) {
return fetchUpstreamKey(c, "/enkey", rawQuery)
}
func fetchUpstreamKey(c *gin.Context, keyPath, rawQuery string) ([]byte, bool) {
serverAddr := strings.TrimRight(strings.TrimSpace(appg.Conf.ServerAddr), "/")
if serverAddr == "" {
return nil, false
}
targetURL := serverAddr + keyPath
if rawQuery = strings.TrimSpace(rawQuery); rawQuery != "" {
targetURL += "?" + rawQuery
}
header := map[string]string{
"Accept": "*/*",
}
applyClientForwardHeaders(header, c)
resp, err := commonhttp.GetLargeFile(targetURL, header)
if err != nil {
log.Warn("fetchUpstreamKey request failed", log.Any("target", targetURL), log.E(err))
return nil, false
}
if resp.StatusCode != http.StatusOK || len(resp.Data) == 0 || jsonErrorCodeBody(resp.Data) {
log.Warn("fetchUpstreamKey invalid response",
log.Any("target", targetURL),
log.Any("status", resp.StatusCode),
log.Any("len", len(resp.Data)))
return nil, false
}
return resp.Data, true
}
func jsonErrorCodeBody(body []byte) bool {
_, ok := jsonErrorCode(body)
return ok
}
+70
View File
@@ -0,0 +1,70 @@
package router
import (
"bytes"
"html/template"
"strings"
"testing"
)
func TestNavigationPageUsesDedicatedVerificationAndAnalytics(t *testing.T) {
navHTML := renderSEOCommonForTrackingTest(t, true)
for _, want := range []string{
"UEtS2h9mIjbFSM_VHEciNQ5iWuFPq9d-Q2WO0SFyPAw",
"eafd02fb6517fc76",
"112390883",
"G-SY26Y2MSHS",
} {
if !strings.Contains(navHTML, want) {
t.Fatalf("navigation SEO output does not contain %q", want)
}
}
for _, unwanted := range []string{"112387687", "G-TZQS2NZ3TN", "6bb06ed5659790d0"} {
if strings.Contains(navHTML, unwanted) {
t.Fatalf("navigation SEO output unexpectedly contains main-site id %q", unwanted)
}
}
}
func TestMainSiteKeepsMainVerificationAndAnalytics(t *testing.T) {
mainHTML := renderSEOCommonForTrackingTest(t, false)
for _, want := range []string{
"r7geXn5kBugzOSLZzyw4v3NBDMQwAmzFCVkqp92odh0",
"6bb06ed5659790d0",
"112387687",
"G-TZQS2NZ3TN",
} {
if !strings.Contains(mainHTML, want) {
t.Fatalf("main-site SEO output does not contain %q", want)
}
}
for _, unwanted := range []string{"112390883", "G-SY26Y2MSHS", "eafd02fb6517fc76"} {
if strings.Contains(mainHTML, unwanted) {
t.Fatalf("main-site SEO output unexpectedly contains navigation id %q", unwanted)
}
}
}
func renderSEOCommonForTrackingTest(t *testing.T, isNavPage bool) string {
t.Helper()
tpl, err := template.New("tracking-test").Funcs(template.FuncMap{
"InTrackerAppId": func() string { return "test-app" },
"jsonStr": func(any) template.JS { return template.JS(`""`) },
}).ParseFiles("../templates/base/seoCommon.html", "../templates/base/trackerInit.html")
if err != nil {
t.Fatal(err)
}
data := map[string]any{
"isNavPage": isNavPage,
"seoVerify": map[string]any{"Baidu": ""},
"appConfig": map[string]any{
"ReportSDKConfig": "",
"SeoInfo": map[string]any{"WebSiteName": ""},
},
}
var out bytes.Buffer
if err := tpl.ExecuteTemplate(&out, "seoCommon", data); err != nil {
t.Fatal(err)
}
return out.String()
}
+90
View File
@@ -0,0 +1,90 @@
package router
import (
"html"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
const sitemapDir = "./templates/page/sitemaps"
var sitemapLocPattern = regexp.MustCompile(`<loc>([^<]+)</loc>`)
// registerSitemapRoutes 暴露 hgdj_server app 生成的 sitemap 索引及分片文件。
func registerSitemapRoutes(r *gin.Engine) {
r.StaticFile("/sitemap.xml", filepath.Join(sitemapDir, "sitemap.xml"))
r.GET("/sitemaps/:name", serveSitemapShard)
}
func serveSitemapShard(c *gin.Context) {
name := c.Param("name")
if !isValidSitemapShardName(name) {
renderNotFound(c)
return
}
raw, err := os.ReadFile(filepath.Join(sitemapDir, name))
if err != nil {
renderNotFound(c)
return
}
c.Data(http.StatusOK, "application/xml; charset=utf-8", normalizeSitemapPageURLs(raw))
}
// normalizeSitemapPageURLs 让运行时生成的旧 sitemap 与当前 canonical 路由保持一致。
// sitemap 索引和静态文件地址不经过这里,仍保留文件型 URL(无尾斜杠)。
func normalizeSitemapPageURLs(raw []byte) []byte {
return sitemapLocPattern.ReplaceAllFunc(raw, func(match []byte) []byte {
parts := sitemapLocPattern.FindSubmatch(match)
if len(parts) != 2 {
return match
}
loc := html.UnescapeString(strings.TrimSpace(string(parts[1])))
u, err := url.Parse(loc)
if err != nil || u.Path == "" {
return match
}
u.Path = canonicalSitemapPagePath(u.Path)
return []byte("<loc>" + html.EscapeString(u.String()) + "</loc>")
})
}
func canonicalSitemapPagePath(path string) string {
switch {
case path == "/more":
path = "/topic"
case strings.HasPrefix(path, "/more/"):
path = "/topic/" + strings.TrimPrefix(path, "/more/")
case path == "/tag-list":
path = "/tag"
case strings.HasPrefix(path, "/tag-list/"):
path = "/tag/" + strings.TrimPrefix(path, "/tag-list/")
}
if path != "/" && !strings.HasSuffix(path, "/") {
path += "/"
}
return path
}
// isValidSitemapShardName 校验分片文件名,防止路径遍历。
// 合法示例:sitemap-pages.xml、sitemap-playletdetail-0.xml
func isValidSitemapShardName(name string) bool {
if !strings.HasPrefix(name, "sitemap-") || !strings.HasSuffix(name, ".xml") {
return false
}
base := strings.TrimSuffix(name, ".xml")
if len(base) <= len("sitemap-") {
return false
}
for _, ch := range base[len("sitemap-"):] {
if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') {
return false
}
}
return true
}
+161
View File
@@ -0,0 +1,161 @@
package server
import (
"fmt"
"io/ioutil"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"videoweb/app/appg"
"videoweb/app/router"
// "videoweb/app/service/configser"
// "videoweb/app/service/translationser"
"videoweb/pub/common/log"
"videoweb/pub/common/srvversion"
// "videoweb/pub/storage/proxy"
// "videoweb/web/service"
"gopkg.in/yaml.v2"
)
func init() {
time.Local, _ = time.LoadLocation("Asia/Shanghai")
}
func Run(args []string) {
showVersion()
if err := initConfig(); err != nil {
log.Error("init config failed", log.E(err))
os.Exit(1)
}
initHttpServer()
initDHServer()
//tgbot.SendToProgram("启动", "app启动完毕")
shutdownHandler()
}
func initConfig() error {
buf, err := ioutil.ReadFile("conf/app.yaml")
if err != nil {
return err
}
if err = yaml.Unmarshal(buf, &appg.Conf); err != nil {
return err
}
return nil
}
func showVersion() {
log.Info(fmt.Sprintf("sevice version:%s", srvversion.SERVICE_VERSION))
log.Info(fmt.Sprintf("git version:%s", srvversion.GIT_VERSION))
log.Info(fmt.Sprintf("build date:%s", srvversion.BUILD_DATE))
log.Info(fmt.Sprintf("build ip:%s", srvversion.BUILD_IP))
}
// headWriter 拦截 WriteHeader 和 Write,丢弃 body 并记录长度,
// 在 flush 时补设 Content-Length 后再发送真正的状态码。
type headWriter struct {
http.ResponseWriter
contentLength int
statusCode int
wroteHeader bool
}
func (w *headWriter) WriteHeader(code int) {
if !w.wroteHeader {
w.statusCode = code
w.wroteHeader = true
}
}
func (w *headWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.statusCode = http.StatusOK
w.wroteHeader = true
}
w.contentLength += len(b)
return len(b), nil
}
// Flush 实现 http.Flusher,HEAD 响应无 body,做 no-op
func (w *headWriter) Flush() {}
// Unwrap 让 http.NewResponseController 能发现底层 writer 的其他接口
func (w *headWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
// flush 将缓存的 Content-Length 和状态码写入底层 ResponseWriter
func (w *headWriter) flush() {
if !w.wroteHeader {
w.statusCode = http.StatusOK
w.wroteHeader = true
}
if w.contentLength > 0 && w.Header().Get("Content-Length") == "" {
w.Header().Set("Content-Length", strconv.Itoa(w.contentLength))
}
w.ResponseWriter.WriteHeader(w.statusCode)
}
// headToGet 将 HEAD 改写为 GET 让 Gin 路由匹配,同时用 headWriter 丢弃 body。
// 通过 X-Origin-Head 内部 header 让 templatecache / filecache 等中间件识别出原始 HEAD 请求,
// 从而避免一次性 URL 污染缓存造成 DoS 放大(进入前先 Del 防客户端伪造)。
func headToGet(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Header.Del("X-Origin-Head")
if r.Method == http.MethodHead {
r.Method = http.MethodGet
r.Header.Set("X-Origin-Head", "1")
hw := &headWriter{ResponseWriter: w}
h.ServeHTTP(hw, r)
hw.flush()
return
}
h.ServeHTTP(w, r)
})
}
func initHttpServer() {
addr := fmt.Sprintf(":%s", appg.Conf.ListenAddr)
srv := &http.Server{
Addr: addr,
Handler: headToGet(router.Init()),
}
go func() {
// service connections
log.Info("服务已启动", log.Any("地址", addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("listen:", log.E(err))
}
}()
}
func initDHServer() {
if strings.TrimSpace(appg.Conf.DHListenAddr) == "" {
return
}
addr := fmt.Sprintf(":%s", appg.Conf.DHListenAddr)
srv := &http.Server{
Addr: addr,
Handler: headToGet(router.DHInit()),
}
go func() {
log.Info("导航服务已启动", log.Any("地址", addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("listen:", log.E(err))
}
}()
}
func shutdownHandler() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, os.Kill)
<-quit
log.Warn("Shutdown... server")
}
+37
View File
@@ -0,0 +1,37 @@
package applistser
import (
"fmt"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const listPath = "/api/app/applist/list"
// FetchList 拉取应用分类列表。categoryId=0 表示默认。
func FetchList(c *gin.Context, categoryID int64) (*AppListResp, error) {
path := fmt.Sprintf("%s?categoryId=%d", listPath, categoryID)
var env APIEnvelope[AppListResp]
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("applist code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchListOrEmpty 失败时返回空结构,保证 SSR 可渲染。
func FetchListOrEmpty(c *gin.Context, categoryID int64) *AppListResp {
data, err := FetchList(c, categoryID)
if err != nil {
log.Error("fetch applist failed", log.E(err))
return &AppListResp{}
}
if data == nil {
return &AppListResp{}
}
return data
}
+38
View File
@@ -0,0 +1,38 @@
package applistser
// AppListResp 对应 swagger proto.AppListResp 的业务字段(忽略无关 config/advitise)。
type AppListResp struct {
Category AppCategoryInfo `json:"category"`
CategoryList []AppCategoryInfo `json:"categoryList"`
}
// AppCategoryInfo 对应 swagger proto.AppCategoryInfo
type AppCategoryInfo struct {
ID int64 `json:"id"`
Code int `json:"code"`
Title string `json:"title"`
Intro string `json:"intro"`
Sort int `json:"sort"`
AppList []AppInfoData `json:"appList"`
}
// AppInfoData 对应 swagger proto.AppInfoData
type AppInfoData struct {
ID int64 `json:"id"`
CategoryID int64 `json:"categoryId"`
Name string `json:"name"`
Intro string `json:"intro"`
Description string `json:"description"`
Logo string `json:"logo"`
URL string `json:"url"`
Sort int `json:"sort"`
AdvitiseID string `json:"advitiseId"`
Location int `json:"location"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
+83
View File
@@ -0,0 +1,83 @@
package applistser
import (
"fmt"
"strings"
)
// PageVM 福利应用页模板数据
type PageVM struct {
Categories []CategoryVM
}
type CategoryVM struct {
ID int64
SectionID string
Title string
Intro string
Total int
Apps []AppCardVM
IsFirst bool
}
type AppCardVM struct {
ID int64
Name string
Intro string
Logo string
URL string
}
// BuildPageVM 按接口返回的 categoryList / appList 顺序组装页面数据,不做二次排序。
func BuildPageVM(data *AppListResp) PageVM {
vm := PageVM{Categories: []CategoryVM{}}
if data == nil {
return vm
}
list := data.CategoryList
if len(list) == 0 && data.Category.ID > 0 {
list = []AppCategoryInfo{data.Category}
}
if len(list) == 0 {
return vm
}
for i, cat := range list {
cards := make([]AppCardVM, 0, len(cat.AppList))
for _, app := range cat.AppList {
name := strings.TrimSpace(app.Name)
if name == "" {
continue
}
href := strings.TrimSpace(app.URL)
if href == "" {
href = "#"
}
intro := strings.TrimSpace(app.Intro)
if intro == "" {
intro = strings.TrimSpace(app.Description)
}
cards = append(cards, AppCardVM{
ID: app.ID,
Name: name,
Intro: intro,
Logo: strings.TrimSpace(app.Logo),
URL: href,
})
}
title := strings.TrimSpace(cat.Title)
if title == "" {
title = "应用分类"
}
vm.Categories = append(vm.Categories, CategoryVM{
ID: cat.ID,
SectionID: fmt.Sprintf("app-category-%d", cat.ID),
Title: title,
Intro: strings.TrimSpace(cat.Intro),
Total: len(cards),
Apps: cards,
IsFirst: i == 0,
})
}
return vm
}
+261
View File
@@ -0,0 +1,261 @@
package authser
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const (
registerPath = "/api/app/auth/register"
loginPath = "/api/app/auth/login"
resetPath = "/api/app/auth/resetPassword"
guestPath = "/api/app/auth/guest"
getInfoPath = "/api/app/auth/getInfo"
profilePath = "/api/app/auth/profile"
uploadPath = "/api/app/upload/image"
cookieMaxAge = 7 * 24 * 3600
)
// Register 调用上游注册接口。
func Register(c *gin.Context, req RegisterReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, registerPath, req, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// ResetPassword 通过账号和邮箱校验后重置密码。
func ResetPassword(c *gin.Context, req ResetPasswordReq) (*APIEnvelope, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, resetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// Login 调用上游登录接口。
func Login(c *gin.Context, req LoginReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, loginPath, req, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// GuestLogin 调用上游游客登录(按设备 ID 创建或找回游客账号)。
func GuestLogin(c *gin.Context, req GuestLoginReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, guestPath, req, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// GetInfo 通过 token 拉取最新用户信息。
func GetInfo(c *gin.Context) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if err := upstream.GetJSON(c, getInfoPath, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// UpdateProfile 修改资料(昵称 / 头像等)。
func UpdateProfile(c *gin.Context, req UpdateProfileReq) (*APIEnvelope, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, profilePath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// UploadImage 上传图片;头像场景不加水印。
func UploadImage(c *gin.Context, fileName string, r io.Reader) (*ImageUploadResp, error) {
var resp ImageUploadResp
_, err := upstream.PostMultipart(c, uploadPath, map[string]string{
"watermark": "0",
}, &upstream.MultipartFile{
FieldName: "file",
FileName: fileName,
Reader: r,
}, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
func decodeLoginData(raw json.RawMessage) (*LoginResp, error) {
if len(raw) == 0 || string(raw) == "null" || string(raw) == `""` {
return nil, fmt.Errorf("empty login data")
}
var user LoginResp
if err := json.Unmarshal(raw, &user); err != nil {
return nil, err
}
return &user, nil
}
// SetAuthCookies 将登录/注册返回写入 cookie。
func SetAuthCookies(c *gin.Context, user *LoginResp) {
if c == nil || user == nil {
return
}
secure := c.Request != nil && c.Request.TLS != nil
if strings.TrimSpace(user.Token) != "" {
setCookie(c, "token", user.Token, secure)
}
setCookie(c, "userId", strconv.FormatInt(user.UserID, 10), secure)
setCookie(c, "accountNo", user.AccountNo, secure)
setCookie(c, "nickName", user.NickName, secure)
setCookie(c, "introduction", user.Introduction, secure)
setCookie(c, "avatar", user.Avatar, secure)
setCookie(c, "email", user.Email, secure)
setCookie(c, "inviteCode", user.InviteCode, secure)
setCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10), secure)
setCookie(c, "vipType", strconv.Itoa(user.VipType), secure)
setCookie(c, "currentVipExpire", user.CurrentVipExpire, secure)
setCookie(c, "showCardId", strconv.FormatInt(user.ShowCardID, 10), secure)
setCookie(c, "showCardName", user.ShowCardName, secure)
setCookie(c, "userType", strconv.Itoa(user.Type), secure)
isReg := "0"
if user.IsRegister || user.Type == 1 {
isReg = "1"
}
setCookie(c, "isRegister", isReg, secure)
if sid := useragent.SIDFromRequest(c.Request); sid != "" {
setCookie(c, "sid", sid, secure)
}
}
func setCookie(c *gin.Context, name, value string, secure bool) {
// Cookie Value 仅允许 ASCII;中文昵称等需 URL 编码。
// 使用 PathEscape(空格→%20),与前端 encodeURIComponent 一致;
// 避免 QueryEscape 的空格→+,否则 JS decodeURIComponent 会留下字面量 "+"。
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: url.PathEscape(value),
Path: "/",
MaxAge: cookieMaxAge,
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(7 * 24 * time.Hour),
})
}
func cookieValue(c *gin.Context, name string) string {
ck, err := c.Request.Cookie(name)
if err != nil || ck == nil {
return ""
}
raw := strings.TrimSpace(ck.Value)
if raw == "" {
return ""
}
// QueryUnescape 同时兼容历史 QueryEscape(+)与 PathEscape(%20)
if decoded, err := url.QueryUnescape(raw); err == nil {
return strings.TrimSpace(decoded)
}
return raw
}
// ClearAuthCookies 清除登录相关 cookie。
func ClearAuthCookies(c *gin.Context) {
for _, name := range authCookieNames() {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}
}
func authCookieNames() []string {
return []string{
"token", "_token_", "sid", "_s_token_",
"userId", "accountNo", "nickName", "introduction", "avatar",
"email", "inviteCode", "recharge", "vipType", "currentVipExpire", "showCardId", "showCardName",
"userType", "isRegister",
}
}
// AuthFromCookies 从 cookie 读取登录态,供 SSR 头部 / 个人中心展示。
func AuthFromCookies(c *gin.Context) (loggedIn bool, userName, avatar, email string) {
token := useragent.TokenFromRequest(c.Request)
if token == "" {
return false, "", "", ""
}
userName = cookieValue(c, "nickName")
if userName == "" {
userName = cookieValue(c, "accountNo")
}
avatar = usableAvatarURL(cookieValue(c, "avatar"))
email = cookieValue(c, "email")
return true, userName, avatar, email
}
// IsRegisteredFromCookies 是否正式注册账号(非游客)。
func IsRegisteredFromCookies(c *gin.Context) bool {
if useragent.TokenFromRequest(c.Request) == "" {
return false
}
if cookieValue(c, "isRegister") == "1" {
return true
}
return cookieValue(c, "userType") == "1"
}
func usableAvatarURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "/static/") {
return raw
}
// 无域名的相对路径暂不可用(解密拉取需要完整 URL)
return ""
}
+222
View File
@@ -0,0 +1,222 @@
package authser
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const guestDevIDCookie = "guest_dev_id"
const guestSkipAutoCookie = "guest_skip_auto"
// EnsureGuestOnEnter 页面进入时:无 token(未自己登录)则走游客登录并写 cookie。
// 对应上游 POST /api/app/auth/guest。成功后会把会话补进当前 Request,便于同一次 SSR 读到登录态。
// 手动退出后带 guest_skip_auto 时跳过本次;清掉标记后,下次进页再自动游客。
func EnsureGuestOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
// 已有任意会话(游客或正式账号)则跳过
if useragent.TokenFromRequest(c.Request) != "" {
return nil
}
// 手动退出:本次进页不自动游客,并清除标记,供下次进页恢复
if IsGuestSkipAuto(c) {
ClearGuestSkipAuto(c)
return nil
}
devID := ensureGuestDevID(c)
env, user, err := GuestLogin(c, GuestLoginReq{
DevID: devID,
DeviceID: devID,
Channel: guestChannel(c),
})
if err != nil {
log.Error("ensure guest on enter failed", log.E(err))
return nil
}
if env == nil || env.Code != 200 || user == nil || strings.TrimSpace(user.Token) == "" {
tip := ""
if env != nil {
tip = firstNonEmptyTip(env.Tip, env.Msg)
}
log.Error("ensure guest on enter rejected", log.E(fmt.Errorf("code=%v tip=%s", envCode(env), tip)))
return nil
}
SetAuthCookies(c, user)
ClearGuestSkipAuto(c)
patchAuthCookiesToRequest(c, user)
return user
}
// RefreshGuestSession 游客会话失效时(如上游 6001 用户不存在)重新游客登录。
// 正式注册账号不处理,避免把用户踢成游客。
func RefreshGuestSession(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if IsRegisteredFromCookies(c) {
return nil
}
dropRequestCookie(c, "token")
dropRequestCookie(c, "_token_")
return EnsureGuestOnEnter(c)
}
func dropRequestCookie(c *gin.Context, name string) {
if c == nil || c.Request == nil || name == "" {
return
}
var parts []string
for _, ck := range c.Request.Cookies() {
if ck == nil || ck.Name == name {
continue
}
parts = append(parts, ck.Name+"="+ck.Value)
}
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
}
// MarkGuestSkipAuto 手动退出后标记:跳过紧接着的一次自动游客登录。
func MarkGuestSkipAuto(c *gin.Context) {
if c == nil {
return
}
secure := c.Request != nil && c.Request.TLS != nil
http.SetCookie(c.Writer, &http.Cookie{
Name: guestSkipAutoCookie,
Value: "1",
Path: "/",
MaxAge: 0, // session cookie
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// IsGuestSkipAuto 是否处于「手动退出后跳过自动游客」状态。
func IsGuestSkipAuto(c *gin.Context) bool {
return cookieValue(c, guestSkipAutoCookie) == "1"
}
// ClearGuestSkipAuto 清除手动退出跳过标记。
func ClearGuestSkipAuto(c *gin.Context) {
if c == nil {
return
}
http.SetCookie(c.Writer, &http.Cookie{
Name: guestSkipAutoCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}
func ensureGuestDevID(c *gin.Context) string {
if id := cookieValue(c, guestDevIDCookie); id != "" {
return id
}
id := newGuestDevID()
secure := c.Request != nil && c.Request.TLS != nil
setCookie(c, guestDevIDCookie, id, secure)
patchRequestCookie(c, guestDevIDCookie, id)
return id
}
func newGuestDevID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
now := time.Now()
return fmt.Sprintf("%016X%d", uint64(now.UnixNano()), now.UnixMilli())
}
return strings.ToUpper(hex.EncodeToString(b)) + strconv.FormatInt(time.Now().UnixMilli(), 10)
}
// guestChannel 为首屏服务端自动游客登录补渠道;cookie 用于复用前端首次归因结果。
func guestChannel(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
if channel := cookieValue(c, "channel"); channel != "" {
return channel
}
for _, key := range []string{"dc", "ch", "pc"} {
if channel := strings.TrimSpace(c.Query(key)); channel != "" {
secure := c.Request.TLS != nil
setCookie(c, "channel", channel, secure)
patchRequestCookie(c, "channel", channel)
return channel
}
}
return ""
}
func patchAuthCookiesToRequest(c *gin.Context, user *LoginResp) {
if c == nil || user == nil {
return
}
patchRequestCookie(c, "token", user.Token)
patchRequestCookie(c, "userId", strconv.FormatInt(user.UserID, 10))
patchRequestCookie(c, "accountNo", user.AccountNo)
patchRequestCookie(c, "nickName", user.NickName)
patchRequestCookie(c, "introduction", user.Introduction)
patchRequestCookie(c, "avatar", user.Avatar)
patchRequestCookie(c, "email", user.Email)
patchRequestCookie(c, "inviteCode", user.InviteCode)
patchRequestCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10))
patchRequestCookie(c, "vipType", strconv.Itoa(user.VipType))
patchRequestCookie(c, "currentVipExpire", user.CurrentVipExpire)
patchRequestCookie(c, "showCardId", strconv.FormatInt(user.ShowCardID, 10))
patchRequestCookie(c, "showCardName", user.ShowCardName)
patchRequestCookie(c, "userType", strconv.Itoa(user.Type))
isReg := "0"
if user.IsRegister || user.Type == 1 {
isReg = "1"
}
patchRequestCookie(c, "isRegister", isReg)
}
func patchRequestCookie(c *gin.Context, name, value string) {
if c == nil || c.Request == nil || name == "" {
return
}
escaped := url.PathEscape(value)
var parts []string
for _, ck := range c.Request.Cookies() {
if ck == nil || ck.Name == name {
continue
}
parts = append(parts, ck.Name+"="+ck.Value)
}
parts = append(parts, name+"="+escaped)
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
}
func envCode(env *APIEnvelope) int {
if env == nil {
return 0
}
return env.Code
}
func firstNonEmptyTip(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+54
View File
@@ -0,0 +1,54 @@
package authser
import "github.com/gin-gonic/gin"
// TokenExpiredCode 上游约定:token 失效,需重新登录。
const TokenExpiredCode = 1000
// IsSessionExpiredCode 是否为会话失效类业务码。
func IsSessionExpiredCode(code int) bool {
switch code {
case TokenExpiredCode, 1002, 1012, 6001: // InvalidToken / NoToken / InvalidateToken / 用户不存在
return true
default:
return false
}
}
// OnSessionExpired 处理上游会话失效。
// retry=true:游客已重新登录,调用方应重试原请求;
// needLogin=true:注册账号已清 cookie,调用方应跳转登录弹窗/页。
func OnSessionExpired(c *gin.Context, code int) (retry, needLogin bool) {
if c == nil || !IsSessionExpiredCode(code) {
return false, false
}
if IsRegisteredFromCookies(c) {
ClearAuthCookies(c)
dropRequestCookie(c, "token")
dropRequestCookie(c, "_token_")
return false, true
}
if RefreshGuestSession(c) != nil {
return true, false
}
ClearAuthCookies(c)
dropRequestCookie(c, "token")
dropRequestCookie(c, "_token_")
return false, false
}
// RecoverContentSession 用于允许游客访问的内容读取接口。
// 正式账号或游客 token 失效后,清理旧登录态、重新建立游客会话,调用方随后重试一次原请求。
// 写操作仍应使用 OnSessionExpired,避免把用户操作静默降级为游客操作。
func RecoverContentSession(c *gin.Context, code int) bool {
if c == nil || !IsSessionExpiredCode(code) {
return false
}
ClearAuthCookies(c)
for _, name := range authCookieNames() {
dropRequestCookie(c, name)
}
dropRequestCookie(c, guestSkipAutoCookie)
ClearGuestSkipAuto(c)
return EnsureGuestOnEnter(c) != nil
}
+94
View File
@@ -0,0 +1,94 @@
package authser
import "encoding/json"
// RegisterReq 对应 swagger authapi.registerReq
type RegisterReq struct {
AccountNo string `json:"accountNo"`
Email string `json:"email,omitempty"`
Password string `json:"password"`
AgentInviteCode string `json:"agentInviteCode,omitempty"`
DevID string `json:"devID,omitempty"`
Channel string `json:"channel,omitempty"`
}
// LoginReq 对应 swagger authapi.loginReq
type LoginReq struct {
Account string `json:"account"`
Password string `json:"password"`
DevID string `json:"devID,omitempty"`
Channel string `json:"channel,omitempty"`
}
// ResetPasswordReq 对应 swagger authapi.resetPasswordReq。
type ResetPasswordReq struct {
AccountNo string `json:"accountNo"`
Email string `json:"email"`
NewPassword string `json:"newPassword"`
}
// GuestLoginReq 对应 swagger authser.GuestLoginReq
// POST /api/app/auth/guest
type GuestLoginReq struct {
DevID string `json:"devID"`
DeviceID string `json:"deviceId,omitempty"`
Mac string `json:"mac,omitempty"`
Channel string `json:"channel,omitempty"`
}
// UpdateProfileReq 对应 swagger authapi.updateProfileReq
type UpdateProfileReq struct {
Avatar string `json:"avatar,omitempty"`
Introduction string `json:"introduction,omitempty"`
NickName string `json:"nickName,omitempty"`
OldPassword string `json:"oldPassword,omitempty"`
NewPassword string `json:"newPassword,omitempty"`
PrivacyMode *bool `json:"privacyMode,omitempty"`
}
// LoginResp 对应 swagger authser.LoginResp
// GET /api/app/auth/getInfo、登录/注册/游客成功 data。
type LoginResp struct {
Token string `json:"token"`
UserID int64 `json:"userId"`
AccountNo string `json:"accountNo"`
Email string `json:"email"`
NickName string `json:"nickName"`
Introduction string `json:"introduction"` // 简介
Avatar string `json:"avatar"` // 头像
InviteCode string `json:"inviteCode"` // 邀请码
VipType int `json:"vipType"` // 0没卡 1月卡 2年卡
CurrentVipExpire string `json:"currentVipExpire"` // VIP 过期时间,需结合 vipType 判断是否有效
ShowCardID int64 `json:"showCardId"` // 最新会员卡 id
ShowCardName string `json:"showCardName"` // 最新会员卡名称,如「1个月会员」
Status int `json:"status"` // 0正常 1封禁 2禁言
Type int `json:"type"` // 身份类型:0游客 1普通用户
IsNewUser bool `json:"isNewUser"` // 本次是否新建游客
IsRegister bool `json:"isRegister"` // 是否已注册
LoginType int `json:"loginType"` // 登录方式
PrivacyMode bool `json:"privacyMode"` // 隐私模式
Score int64 `json:"score"` // 积分
Recharge int64 `json:"recharge"` // 金币余额
}
// APIEnvelope 上游统一响应壳。Data 在失败时可能是 string/null。
type APIEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data json.RawMessage `json:"data"`
}
// ImageUploadData 对应 swagger uploadser.ImageUploadData
type ImageUploadData struct {
ID string `json:"id"`
SourceNameFs string `json:"sourceNameFs"`
}
// ImageUploadResp 对应 swagger uploadser.ImageUploadResp
type ImageUploadResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data *ImageUploadData `json:"data"`
}
+87
View File
@@ -0,0 +1,87 @@
package authser
import (
"fmt"
"strings"
)
// SettingPageVM 账号设置页展示模型。
type SettingPageVM struct {
UserID int64
AccountNo string
NickName string
Introduction string
Email string
EmailMasked string
EmailBound bool
Avatar string // 可用头像 URL;空则模板用默认图
InviteCode string
PrivacyMode bool
VipType int
VipExpire string
Recharge int64
Score int64
DisplayName string
}
// BuildSettingVM 由 LoginResp 构建设置页 VM。
func BuildSettingVM(user *LoginResp) SettingPageVM {
if user == nil {
return SettingPageVM{}
}
nick := strings.TrimSpace(user.NickName)
account := strings.TrimSpace(user.AccountNo)
email := strings.TrimSpace(user.Email)
intro := strings.TrimSpace(user.Introduction)
display := nick
if display == "" {
display = account
}
if display == "" {
display = "用户"
}
return SettingPageVM{
UserID: user.UserID,
AccountNo: account,
NickName: nick,
Introduction: intro,
Email: email,
EmailMasked: MaskEmail(email),
EmailBound: email != "",
Avatar: usableAvatarURL(user.Avatar),
InviteCode: strings.TrimSpace(user.InviteCode),
PrivacyMode: user.PrivacyMode,
VipType: user.VipType,
VipExpire: strings.TrimSpace(user.CurrentVipExpire),
Recharge: user.Recharge,
Score: user.Score,
DisplayName: display,
}
}
// MaskEmail 邮箱脱敏,如 ab***@mail.com。
func MaskEmail(email string) string {
email = strings.TrimSpace(email)
if email == "" {
return ""
}
at := strings.LastIndex(email, "@")
if at <= 0 || at == len(email)-1 {
return email
}
local := email[:at]
domain := email[at:]
runes := []rune(local)
if len(runes) <= 2 {
return string(runes[:1]) + "***" + domain
}
return string(runes[:2]) + "***" + domain
}
// FormatUserID 展示用 ID。
func FormatUserID(id int64) string {
if id <= 0 {
return ""
}
return fmt.Sprintf("%d", id)
}
+187
View File
@@ -0,0 +1,187 @@
package authser
import (
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func parseVipExpireMs(raw string) int64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
if n > 0 && n < 1e12 {
return n * 1000
}
return n
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return t.UnixMilli()
}
}
return 0
}
// UserIDFromCookies 读取 cookie userId。
func UserIDFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "userId")), 10, 64)
if n < 0 {
return 0
}
return n
}
// VipTypeFromCookies 读取 cookie vipType。
func VipTypeFromCookies(c *gin.Context) int {
if c == nil {
return 0
}
n, _ := strconv.Atoi(strings.TrimSpace(cookieValue(c, "vipType")))
return n
}
// VipTypeLabel vipType 文案:0普通 1月卡 2年卡(与 91ly / 编辑资料注释一致)。
func VipTypeLabel(vipType int) string {
switch vipType {
case 1:
return "月卡会员"
case 2:
return "年卡会员"
default:
return "普通会员"
}
}
// ShowCardNameFromCookies 读取 cookie showCardName。
func ShowCardNameFromCookies(c *gin.Context) string {
if c == nil {
return ""
}
return strings.TrimSpace(cookieValue(c, "showCardName"))
}
// ShowCardIDFromCookies 读取 cookie showCardId。
func ShowCardIDFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "showCardId")), 10, 64)
if n < 0 {
return 0
}
return n
}
// VipDisplayLabel 优先使用 getInfo.showCardName,否则回退 vipType 文案。
func VipDisplayLabel(showCardName string, vipType int) string {
if s := strings.TrimSpace(showCardName); s != "" {
return s
}
return VipTypeLabel(vipType)
}
// VipDisplayLabelFromCookies 从 cookie 组装会员展示名。
func VipDisplayLabelFromCookies(c *gin.Context) string {
return VipDisplayLabel(ShowCardNameFromCookies(c), VipTypeFromCookies(c))
}
// IsActiveVipUser 根据 vipType + currentVipExpire 判断是否有效会员。
func IsActiveVipUser(vipType int, currentVipExpire string) bool {
if vipType == 0 {
return false
}
expireMs := parseVipExpireMs(currentVipExpire)
if expireMs <= 0 {
return false
}
return expireMs > time.Now().UnixMilli()
}
// FormatVipExpireText 格式化到期时间展示。
func FormatVipExpireText(currentVipExpire string) string {
ms := parseVipExpireMs(currentVipExpire)
if ms <= 0 {
return ""
}
return time.UnixMilli(ms).In(time.Local).Format("有效期至 2006/01/02")
}
// VipExpireTextFromCookies 格式化到期时间展示。
func VipExpireTextFromCookies(c *gin.Context) string {
if c == nil {
return ""
}
return FormatVipExpireText(cookieValue(c, "currentVipExpire"))
}
// IsActiveVipFromCookies vipType≠0 且 currentVipExpire 未过期(与 91ly 一致)。
func IsActiveVipFromCookies(c *gin.Context) bool {
if c == nil {
return false
}
return IsActiveVipUser(VipTypeFromCookies(c), cookieValue(c, "currentVipExpire"))
}
// RechargeFromCookies 读取 cookie recharge(金币余额)。
func RechargeFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "recharge")), 10, 64)
if n < 0 {
return 0
}
return n
}
// SetRechargeCookie 更新金币余额 cookie(解锁/充值后同步)。
func SetRechargeCookie(c *gin.Context, balance int64) {
if c == nil {
return
}
if balance < 0 {
balance = 0
}
secure := c.Request != nil && c.Request.TLS != nil
setCookie(c, "recharge", strconv.FormatInt(balance, 10), secure)
}
// FormatRecharge 金币余额千分位展示。
func FormatRecharge(n int64) string {
if n < 0 {
n = 0
}
s := strconv.FormatInt(n, 10)
if len(s) <= 3 {
return s
}
var b strings.Builder
rem := len(s) % 3
if rem > 0 {
b.WriteString(s[:rem])
if len(s) > rem {
b.WriteByte(',')
}
}
for i := rem; i < len(s); i += 3 {
b.WriteString(s[i : i+3])
if i+3 < len(s) {
b.WriteByte(',')
}
}
return b.String()
}
+22
View File
@@ -0,0 +1,22 @@
package commentser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const likeSetPath = "/api/app/comment/like/set"
// LikeSet 设置评论点赞/取消点赞。
func LikeSet(c *gin.Context, req CommentLikeSetReq) (*APIEnvelope[CommentLikeSetResp], error) {
if req.CommentID <= 0 {
return nil, fmt.Errorf("comment like invalid commentId")
}
var env APIEnvelope[CommentLikeSetResp]
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+145
View File
@@ -0,0 +1,145 @@
package commentser
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
listPath = "/api/app/comment/list"
defaultCommentSize = 10
)
// ListQuery 评论列表查询参数。
type ListQuery struct {
ObjectID int64
ObjectType int
PageNum int
PageSize int
SortOrder int
}
// Normalize 校正分页与排序默认值。
func (q *ListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultCommentSize
}
if q.PageSize > 50 {
q.PageSize = 50
}
if q.ObjectType != ObjectTypeChapter {
q.ObjectType = ObjectTypePlaylet
}
if q.SortOrder != SortLatest {
q.SortOrder = SortHot
}
}
// FetchList 分页查询评论。
func FetchList(c *gin.Context, q ListQuery) (*GetCommentListResp, error) {
q.Normalize()
if q.ObjectID <= 0 {
return nil, fmt.Errorf("comment list invalid objectId")
}
params := url.Values{}
params.Set("objectId", strconv.FormatInt(q.ObjectID, 10))
params.Set("objectType", strconv.Itoa(q.ObjectType))
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
authser.EnsureGuestOnEnter(c)
requestPath := listPath + "?" + params.Encode()
env, err := fetchListOnce(c, requestPath)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) && authser.RecoverContentSession(c, env.Code) {
env, err = fetchListOnce(c, requestPath)
if err != nil {
return nil, err
}
}
if env.Code != 200 {
return nil, fmt.Errorf("comment list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
func fetchListOnce(c *gin.Context, path string) (*APIEnvelope[GetCommentListResp], error) {
var env APIEnvelope[GetCommentListResp]
if err := upstream.GetJSON(c, path, &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchListOrEmpty 失败时返回空列表。
func FetchListOrEmpty(c *gin.Context, q ListQuery) *GetCommentListResp {
data, err := FetchList(c, q)
if err != nil {
log.Error("fetch comment list failed", log.E(err))
return &GetCommentListResp{
ObjectID: q.ObjectID,
PageNum: q.PageNum,
PageSize: q.PageSize,
}
}
if data == nil {
return &GetCommentListResp{
ObjectID: q.ObjectID,
PageNum: q.PageNum,
PageSize: q.PageSize,
}
}
return data
}
// ParseSortOrder 解析站点评论排序(1 热门 / 2 最新)。
func ParseSortOrder(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n != SortLatest {
return SortHot
}
return SortLatest
}
// FormatCreatedAt 展示用时间格式化(非字段映射)。
func FormatCreatedAt(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return t.Local().Format("2006/1/2 15:04")
}
}
return raw
}
// AvatarOrDefault 头像空时回退默认图。
func AvatarOrDefault(avatar string) string {
avatar = strings.TrimSpace(avatar)
if avatar == "" {
return "/static/images/avatar.png"
}
return avatar
}
+45
View File
@@ -0,0 +1,45 @@
package commentser
import (
"fmt"
"strings"
"unicode/utf8"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const sendPath = "/api/app/comment/send"
// Send 发布评论(一级 / 二级)。
func Send(c *gin.Context, req CommentSendReq) (*APIEnvelope[any], error) {
req.Text = strings.TrimSpace(req.Text)
if req.ObjectID <= 0 {
return nil, fmt.Errorf("comment send invalid objectId")
}
if req.ObjectType != ObjectTypeChapter {
req.ObjectType = ObjectTypePlaylet
}
if req.Text == "" {
return nil, fmt.Errorf("comment send empty text")
}
if utf8.RuneCountInString(req.Text) > 200 {
return nil, fmt.Errorf("comment send text too long")
}
if req.ParentsID < 0 {
req.ParentsID = 0
}
if req.ReplyCommentID < 0 {
req.ReplyCommentID = 0
}
// 二级评论:未显式指定 replyCommentId 时默认回复父评论
if req.ParentsID > 0 && req.ReplyCommentID <= 0 {
req.ReplyCommentID = req.ParentsID
}
var env APIEnvelope[any]
if _, err := upstream.PostJSON(c, sendPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+11
View File
@@ -0,0 +1,11 @@
package commentser
import "html/template"
// TemplateFuncs 评论展示辅助。
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"formatCommentTime": FormatCreatedAt,
"commentAvatar": AvatarOrDefault,
}
}
+71
View File
@@ -0,0 +1,71 @@
package commentser
// CommentSendReq 对应 swagger proto.CommentSendReq
// parentsId 为 0 发一级评论,大于 0 发二级评论;replyCommentId 为实际被回复的评论。
type CommentSendReq struct {
ObjectID int64 `json:"objectId"`
ObjectType int `json:"objectType"`
Text string `json:"text"`
ParentsID int64 `json:"parentsId"`
ReplyCommentID int64 `json:"replyCommentId"`
}
// CommentLikeSetReq 对应 swagger proto.CommentLikeSetReq
type CommentLikeSetReq struct {
CommentID int64 `json:"commentId"`
IsLike bool `json:"isLike"`
}
// CommentLikeSetResp 对应 swagger proto.CommentLikeSetResp
type CommentLikeSetResp struct {
CommentID int64 `json:"commentId"`
CountLike int64 `json:"countLike"`
IsLike int `json:"isLike"`
}
// CommentInfo 对应 swagger proto.CommentInfo
type CommentInfo struct {
ID int64 `json:"id"`
ObjectID int64 `json:"objectId"`
ObjectType int `json:"objectType"`
Text string `json:"text"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
UserAvatar string `json:"userAvatar"`
CountLike int64 `json:"countLike"`
CommentCount int64 `json:"commentCount"`
IsLike int `json:"isLike"`
CreatedAt string `json:"createdAt"`
Level int `json:"level,omitempty"`
ParentsID int64 `json:"parentsId,omitempty"`
ReplyCommentID int64 `json:"replyCommentId,omitempty"`
ReplyUserName string `json:"replyUserName,omitempty"`
Children []CommentInfo `json:"children,omitempty"`
}
// GetCommentListResp 对应 swagger proto.GetCommentListResp
type GetCommentListResp struct {
List []CommentInfo `json:"list"`
ObjectID int64 `json:"objectId"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data T `json:"data"`
}
const (
// ObjectTypePlaylet 短剧评论
ObjectTypePlaylet = 10
// ObjectTypeChapter 短剧剧集评论
ObjectTypeChapter = 11
SortHot = 1 // 热门
SortLatest = 2 // 最新
)
+51
View File
@@ -0,0 +1,51 @@
package configser
import (
"fmt"
"sync"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const footerPath = "/api/app/config/footer/info"
const footerCacheTTL = 60 * time.Second
var (
footerMu sync.Mutex
footerCache *FooterInfo
footerAt time.Time
)
// FetchFooter 拉取底部配置,返回与 swagger proto.FooterInfo 一致的结构。
func FetchFooter(c *gin.Context) (*FooterInfo, error) {
var env APIEnvelope[FooterInfoResp]
if err := upstream.GetPublicJSON(c, footerPath, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("footer info code=%d msg=%s", env.Code, env.Msg)
}
info := env.Data.FooterInfo
return &info, nil
}
// FetchFooterOrEmpty 带短缓存;失败或空数据时返回空结构,不做写死兜底/字段映射。
func FetchFooterOrEmpty(c *gin.Context) FooterInfo {
footerMu.Lock()
defer footerMu.Unlock()
if footerCache != nil && time.Since(footerAt) < footerCacheTTL {
return *footerCache
}
info, err := FetchFooter(c)
if err != nil {
log.Error("fetch footer info failed", log.E(err))
return FooterInfo{}
}
footerCache = info
footerAt = time.Now()
return *info
}
+88
View File
@@ -0,0 +1,88 @@
package configser
import (
"fmt"
"strings"
)
// HomeRoadLinkVM 是“回家的路”页面中的可访问线路。
type HomeRoadLinkVM struct {
Label string
URL string
}
// HomeRoadContactVM 是防失联联系方式。
type HomeRoadContactVM struct {
Title string
Description string
Action string
URL string
}
// HomeRoadPageVM 将 config/info 中的地址字段整理成页面所需结构。
type HomeRoadPageVM struct {
MainURL string
Routes []HomeRoadLinkVM
Permanent []HomeRoadLinkVM
Contacts []HomeRoadContactVM
}
func firstHomeRoadURL(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return "/"
}
// BuildHomeRoadPageVM 使用服务端动态配置组装“回家的路”页面。
func BuildHomeRoadPageVM(cfg AppConfigVM) HomeRoadPageVM {
mainURL := firstHomeRoadURL(cfg.NewURLAddr, cfg.ForeverURLAddr)
routes := make([]HomeRoadLinkVM, 0, 3)
for _, item := range cfg.NavPageList {
if len(routes) == 3 {
break
}
if url := strings.TrimSpace(item.URL); url != "" {
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: url})
}
}
for _, url := range []string{cfg.NewURLAddr, cfg.NewURLAddr1, cfg.NewURLAddr2} {
if len(routes) == 3 {
break
}
if url = strings.TrimSpace(url); url != "" {
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: url})
}
}
for len(routes) < 3 {
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: mainURL})
}
permanent := make([]HomeRoadLinkVM, 0, 3)
for _, item := range []HomeRoadLinkVM{
{Label: "永久地址", URL: cfg.ForeverURLAddr},
{Label: "永久中转页", URL: cfg.NewURLAddr},
{Label: "最新中转页(免翻墙)", URL: cfg.NewURLAddr1},
} {
item.URL = strings.TrimSpace(item.URL)
if item.URL != "" {
permanent = append(permanent, item)
}
}
email := strings.TrimSpace(cfg.GmailAddr)
emailURL := ""
if email != "" {
emailURL = "mailto:" + email
}
contacts := []HomeRoadContactVM{
{Title: "✉ 邮箱自动回复", Description: "发送任意邮件到下方地址,30秒内自动回复当前所有可用入口的最新汇总。", Action: firstHomeRoadURL(email, "huangguoaiai@gmail.com") + " →", URL: firstHomeRoadURL(emailURL, "mailto:huangguoaiai@gmail.com")},
{Title: "➤ TG 官方交流群", Description: "获取域名变更通知的首选渠道,实时播报并加入用户互助社区。", Action: "立即订阅最新动态 →", URL: firstHomeRoadURL(cfg.TGGroup, "/")},
{Title: "◆ 官方商务合作", Description: "联系官方商务合作,洽谈商务事宜,精诚合作,财富共赢。", Action: "立即联系 →", URL: firstHomeRoadURL(cfg.Telegram, "/")},
{Title: "𝕏 推特社区", Description: "关注官方推特社区,第一时间获取地址变更与官方公告。", Action: "快速访问 →", URL: firstHomeRoadURL(cfg.TwitterAddr, "/")},
}
return HomeRoadPageVM{MainURL: mainURL, Routes: routes, Permanent: permanent, Contacts: contacts}
}
+40
View File
@@ -0,0 +1,40 @@
package configser
import "testing"
func TestBuildHomeRoadPageVMUsesDynamicConfig(t *testing.T) {
cfg := AppConfigVM{
NewURLAddr: "https://main.example.com",
NewURLAddr1: "https://relay.example.com",
NewURLAddr2: "https://latest.example.com",
ForeverURLAddr: "https://forever.example.com",
PDFAddr: "https://publish.example.com",
GmailAddr: "home@example.com",
TGGroup: "https://t.me/example",
Telegram: "https://t.me/business-example",
CustomerServiceHost: "https://service.example.com",
TwitterAddr: "https://x.com/example",
NavPageList: []NavPageItem{
{Line: 1, URL: "https://line1.example.com"},
{Line: 2, URL: "https://line2.example.com"},
{Line: 3, URL: "https://line3.example.com"},
},
}
vm := BuildHomeRoadPageVM(cfg)
if vm.MainURL != cfg.NewURLAddr || len(vm.Routes) != 3 || vm.Routes[1].URL != "https://line2.example.com" {
t.Fatalf("unexpected route VM: %#v", vm)
}
if len(vm.Permanent) != 3 || vm.Permanent[1].URL != cfg.NewURLAddr || vm.Permanent[2].URL != cfg.NewURLAddr1 {
t.Fatalf("unexpected permanent links: %#v", vm.Permanent)
}
if len(vm.Contacts) != 4 || vm.Contacts[0].URL != "mailto:home@example.com" || vm.Contacts[2].URL != cfg.Telegram {
t.Fatalf("unexpected contacts: %#v", vm.Contacts)
}
}
func TestBuildHomeRoadPageVMSkipsEmptyPermanentAddresses(t *testing.T) {
vm := BuildHomeRoadPageVM(AppConfigVM{NewURLAddr: "https://relay.example.com"})
if len(vm.Permanent) != 1 || vm.Permanent[0].Label != "永久中转页" {
t.Fatalf("unexpected permanent links: %#v", vm.Permanent)
}
}
+53
View File
@@ -0,0 +1,53 @@
package configser
import (
"fmt"
"sync"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
configInfoPath = "/api/app/config/info"
configInfoCacheTTL = 60 * time.Second
)
var (
configMu sync.Mutex
configCache *AppConfigVM
configAt time.Time
)
// FetchConfigInfo 拉取 APP 公共配置。
func FetchConfigInfo(c *gin.Context) (*ConfigInfoResp, error) {
var env APIEnvelope[ConfigInfoResp]
if err := upstream.GetPublicJSON(c, configInfoPath, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("config info code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchAppConfigVMOrEmpty 带短缓存,失败时返回空配置。
func FetchAppConfigVMOrEmpty(c *gin.Context) AppConfigVM {
configMu.Lock()
defer configMu.Unlock()
if configCache != nil && time.Since(configAt) < configInfoCacheTTL {
return *configCache
}
info, err := FetchConfigInfo(c)
if err != nil {
log.Error("fetch app config info failed", log.E(err))
return AppConfigVM{}
}
vm := BuildAppConfigVM(info)
configCache = &vm
configAt = time.Now()
return vm
}
+104
View File
@@ -0,0 +1,104 @@
package configser
import (
"fmt"
"html/template"
"net/url"
"strings"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const richtextListPath = "/api/app/config/richtext/list"
// RichTextPageVM 富文本页模板数据。
type RichTextPageVM struct {
Title string
Subtitle string
ContentHTML template.HTML
Found bool
}
// FetchRichTextList 查询富文本列表(ids / titles 逗号分隔)。
func FetchRichTextList(c *gin.Context, ids, titles string) ([]AppRichTextItemResp, error) {
params := url.Values{}
if s := strings.TrimSpace(ids); s != "" {
params.Set("ids", s)
}
if s := strings.TrimSpace(titles); s != "" {
params.Set("titles", s)
}
path := richtextListPath
if q := params.Encode(); q != "" {
path += "?" + q
}
var env APIEnvelope[AppRichTextListResp]
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("richtext list code=%d msg=%s", env.Code, env.Msg)
}
return env.Data.List, nil
}
// FetchRichTextByTitle 按标题取第一条启用内容;失败返回空页 VM。
func FetchRichTextByTitle(c *gin.Context, title string) RichTextPageVM {
title = strings.TrimSpace(title)
vm := RichTextPageVM{Title: title}
if title == "" {
return vm
}
list, err := FetchRichTextList(c, "", title)
if err != nil {
log.Error("fetch richtext by title failed", log.E(err), log.Any("title", title))
return vm
}
item := matchRichTextByTitle(list, title)
if item == nil {
return vm
}
displayTitle := strings.TrimSpace(item.Desc)
if displayTitle == "" {
displayTitle = strings.TrimSpace(item.Title)
}
if displayTitle == "" {
displayTitle = title
}
vm.Found = true
vm.Title = displayTitle
vm.Subtitle = strings.TrimSpace(item.Desc)
if vm.Subtitle == displayTitle {
vm.Subtitle = ""
}
vm.ContentHTML = template.HTML(item.Content)
return vm
}
func matchRichTextByTitle(list []AppRichTextItemResp, title string) *AppRichTextItemResp {
if len(list) == 0 {
return nil
}
for i := range list {
if strings.TrimSpace(list[i].Title) == title || strings.TrimSpace(list[i].Desc) == title {
return &list[i]
}
}
return &list[0]
}
// RichTextHref 页脚导航跳转一律进入富文本页。
func RichTextHref(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "/richtext/"
}
return "/richtext/" + title + "/"
}
+19
View File
@@ -0,0 +1,19 @@
package configser
import "testing"
func TestRichTextHrefUsesRichtextRoutes(t *testing.T) {
tests := map[string]string{
"关于我们": "/richtext/关于我们/",
"用户协议": "/richtext/用户协议/",
"隐私政策": "/richtext/隐私政策/",
"帮助中心": "/richtext/帮助中心/",
"问题反馈": "/richtext/问题反馈/",
"自定义内容": "/richtext/自定义内容/",
}
for title, want := range tests {
if got := RichTextHref(title); got != want {
t.Fatalf("RichTextHref(%q) = %q, want %q", title, got, want)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package configser
import (
"bytes"
"encoding/json"
"strings"
)
// UnmarshalJSON 兼容 seoNavInfo 为空字符串,以及 dhKeyworld 为字符串/数组。
func (s *SeoNavInfo) UnmarshalJSON(raw []byte) error {
if s == nil {
return nil
}
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil
}
if raw[0] == '"' {
var encoded string
if err := json.Unmarshal(raw, &encoded); err != nil {
return err
}
encoded = strings.TrimSpace(encoded)
if encoded == "" {
return nil
}
inner := bytes.TrimSpace([]byte(encoded))
if len(inner) == 0 || inner[0] != '{' {
return nil
}
return s.UnmarshalJSON(inner)
}
var aux struct {
DhTitle string `json:"dhTitle"`
DhDescription string `json:"dhDescription"`
DhSiteName string `json:"dhSiteName"`
DhKeyworld json.RawMessage `json:"dhKeyworld"`
Keywords json.RawMessage `json:"keywords"`
}
if err := json.Unmarshal(raw, &aux); err != nil {
return err
}
s.DhTitle = strings.TrimSpace(aux.DhTitle)
s.DhDescription = strings.TrimSpace(aux.DhDescription)
s.DhSiteName = strings.TrimSpace(aux.DhSiteName)
s.DhKeyworld = decodeNavKeywords(aux.DhKeyworld, aux.Keywords)
return nil
}
func decodeNavKeywords(values ...json.RawMessage) []string {
for _, raw := range values {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
continue
}
if raw[0] == '[' {
var list []string
if err := json.Unmarshal(raw, &list); err != nil {
continue
}
out := make([]string, 0, len(list))
for _, item := range list {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, item)
}
}
if len(out) > 0 {
return out
}
continue
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
continue
}
s = strings.TrimSpace(s)
if s == "" {
continue
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
if len(out) > 0 {
return out
}
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package configser
import (
"strings"
"videoweb/app/appg"
"videoweb/pub/pubdef"
)
// ResolveTrackerAppID 优先 yaml,否则固定 JHA-1043。
func ResolveTrackerAppID() string {
if id := strings.TrimSpace(appg.Conf.Tracker.AppID); id != "" {
return id
}
return pubdef.TrackerAppId()
}
// ResolveReportSDKConfig 上游 reportSdkConfig 优先,本地 yaml 兜底。
func ResolveReportSDKConfig(fromAPI string) string {
if v := strings.TrimSpace(fromAPI); v != "" {
return v
}
return strings.TrimSpace(appg.Conf.Tracker.EncryptedConfig)
}
// ApplyTrackerFallback 补齐 AppConfigVM 中的上报密文。
func ApplyTrackerFallback(vm AppConfigVM) AppConfigVM {
vm.ReportSDKConfig = ResolveReportSDKConfig(vm.ReportSDKConfig)
return vm
}
+164
View File
@@ -0,0 +1,164 @@
package configser
// FooterInfoResp 对应 swagger proto.FooterInfoResp
type FooterInfoResp struct {
FooterInfo FooterInfo `json:"footerInfo"`
}
// FooterInfo 对应 swagger proto.FooterInfo
// GET /api/app/config/footer/info — 模板直接使用本结构,不做自定义 VM 映射。
type FooterInfo struct {
Brand FooterBrand `json:"brand"`
NavList []TranslationCategory `json:"navList"`
ContactList []FooterContact `json:"contactList"`
}
// FooterBrand 对应 swagger proto.FooterBrand
type FooterBrand struct {
Title string `json:"title"`
Content string `json:"content"`
}
// TranslationCategory 对应 swagger proto.TranslationCategory(footer.navList 元素)
type TranslationCategory struct {
ID int64 `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
URLValue string `json:"urlValue"`
CategoryType int `json:"categoryType"`
TranslationList []TranslationInfo `json:"translationList"`
SeoInfo SeoValue `json:"seoInfo"`
}
// SeoValue 对应 swagger pubdef.SeoValue(导航分类 seoInfo)
type SeoValue struct {
Title string `json:"title"`
Keyworld []string `json:"keyworld"`
Description string `json:"description"`
}
// TranslationInfo 对应 swagger proto.TranslationInfo
type TranslationInfo struct {
Name string `json:"name"`
URLValue string `json:"urlValue"`
IsFriendAds bool `json:"isFriendAds"`
}
// FooterContact 对应 swagger proto.FooterContact
type FooterContact struct {
Name string `json:"name"`
Value string `json:"value"`
}
// ConfigInfoResp 对应 swagger proto.ConfigInfoResp
// GET /api/app/config/info
// reportSdkConfig 与 proto.ConfigInfo 对齐;上游若尚未下发则为空,可由本地 yaml 兜底。
// config 为上游实际下发的完整站点配置(swagger 未完整声明,但含 potaoGroup 等字段)。
type ConfigInfoResp struct {
NewAddressInfo NewAddressInfo `json:"newAddressInfo"`
Config ConfigInfo `json:"config"`
Advitise AdvitiseData `json:"advitise"`
SeoInfo SeoInfo `json:"seoInfo"`
SeoNavInfo SeoNavInfo `json:"seoNavInfo"`
NavPageList []NavPageItem `json:"navPageList"`
ReportSDKConfig string `json:"reportSdkConfig"`
}
// ConfigInfo 对应上游 data.config(proto.ConfigInfo 子集,按页面需要取用)。
type ConfigInfo struct {
PotaoGroup string `json:"potaoGroup"`
}
// AdvitiseData 对应 GET /api/app/config/info 返回的完整 advitise 数据。
type AdvitiseData struct {
HomeList []Advertisement `json:"homeList"`
DetailTopList []Advertisement `json:"detailTopList"`
NineGridList []Advertisement `json:"nineGridList"`
DetailBannerList []Advertisement `json:"detailbannerList"`
LaunchScreenList []Advertisement `json:"launchScreenList"`
FloatingList []Advertisement `json:"floatingList"`
AList []Advertisement `json:"AList"`
BList []Advertisement `json:"BList"`
DialogList []Advertisement `json:"dialogList"`
VideoInfoList []Advertisement `json:"videoInfoList"`
VideoList []Advertisement `json:"videoList"`
DownList []Advertisement `json:"downList"`
FriendList []Advertisement `json:"friendList"`
HomeBanner []Advertisement `json:"homeBanner"`
BottomBanner []Advertisement `json:"bottomBanner"`
TitleList []Advertisement `json:"titleList"`
PlayList []Advertisement `json:"playList"`
PauseyList []Advertisement `json:"pauseyList"`
}
// Advertisement 对应 swagger proxy.AdvitiseInfo。
type Advertisement struct {
ID string `json:"id"`
Images string `json:"images"`
Link string `json:"link"`
Location int `json:"location"`
Title string `json:"title"`
}
// NewAddressInfo 对应 swagger proto.NewAddressInfo(GET /api/app/config/info)。
type NewAddressInfo struct {
NewURLAddr string `json:"newUrlAddr"`
NewURLAddr1 string `json:"newUrlAddr1"`
NewURLAddr2 string `json:"newUrlAddr2"`
ForeverURLAddr string `json:"foreverUrlAddr"`
GmailAddr string `json:"gmailAddr"`
QQ string `json:"qq"`
TGGroup string `json:"tgGroup"`
Telegram string `json:"telegram"`
PotaoGroup string `json:"potaoGroup"`
CustomerServiceHost string `json:"customerServiceHost"`
AppAddr string `json:"appAddr"`
OfficialAppAddr string `json:"officialAppAddr"`
PDFAddr string `json:"pdfAddr"`
TwitterAddr string `json:"twitterAddr"`
}
// SeoInfo 对应 swagger proto.SeoInfo
type SeoInfo struct {
Title string `json:"title"`
Description string `json:"description"`
Keywords []string `json:"keywords"`
CanonicalURL string `json:"canonicalUrl"`
WebSiteName string `json:"webSiteName"`
}
// SeoNavInfo 对应 swagger proto.SeoNavInfo
// GET /api/app/config/info 的 seoNavInfo。
type SeoNavInfo struct {
DhTitle string `json:"dhTitle"`
DhDescription string `json:"dhDescription"`
DhKeyworld []string `json:"dhKeyworld"`
DhSiteName string `json:"dhSiteName"`
}
// NavPageItem 对应 swagger proto.NavPageItem(GET /api/app/config/info 的 navPageList)。
type NavPageItem struct {
Line int `json:"line"`
URL string `json:"url"`
}
// AppRichTextItemResp 对应 swagger proto.AppRichTextItemResp
// GET /api/app/config/richtext/list
type AppRichTextItemResp struct {
ID int64 `json:"id"`
Title string `json:"title"`
Desc string `json:"desc"`
Content string `json:"content"`
}
// AppRichTextListResp 对应 swagger proto.AppRichTextListResp
type AppRichTextListResp struct {
List []AppRichTextItemResp `json:"list"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
+159
View File
@@ -0,0 +1,159 @@
package configser
import (
"strconv"
"strings"
)
// AppConfigVM 注入全站页面的公共配置(字段名与接口 newAddressInfo / seoInfo 对齐)。
type AppConfigVM struct {
ForeverURLAddr string `json:"foreverUrlAddr"`
NewURLAddr string `json:"newUrlAddr"`
NewURLAddr1 string `json:"newUrlAddr1"`
NewURLAddr2 string `json:"newUrlAddr2"`
GmailAddr string `json:"gmailAddr"`
QQ string `json:"qq"`
TGGroup string `json:"tgGroup"`
Telegram string `json:"telegram"`
PotaoGroup string `json:"potaoGroup"`
CustomerServiceHost string `json:"customerServiceHost"`
AppAddr string `json:"appAddr"`
OfficialAppAddr string `json:"officialAppAddr"`
PDFAddr string `json:"pdfAddr"`
TwitterAddr string `json:"twitterAddr"`
SeoInfo SeoInfo `json:"seoInfo,omitempty"`
SeoNavInfo SeoNavInfo `json:"seoNavInfo,omitempty"`
NavPageList []NavPageItem `json:"navPageList,omitempty"`
ReportSDKConfig string `json:"reportSdkConfig,omitempty"`
Advitise AdvitiseData `json:"advitise"`
DetailTopList []Advertisement `json:"detailTopList,omitempty"`
NineGridList []Advertisement `json:"nineGridList,omitempty"`
DetailBannerList []Advertisement `json:"detailbannerList,omitempty"`
BottomBanner []Advertisement `json:"bottomBanner,omitempty"`
DialogList []Advertisement `json:"dialogList,omitempty"`
}
// BuildAppConfigVM 从上游配置组装前端可用字段。
func BuildAppConfigVM(info *ConfigInfoResp) AppConfigVM {
if info == nil {
return AppConfigVM{}
}
addr := info.NewAddressInfo
return AppConfigVM{
ForeverURLAddr: sanitizeURL(addr.ForeverURLAddr),
NewURLAddr: sanitizeURL(addr.NewURLAddr),
NewURLAddr1: sanitizeURL(addr.NewURLAddr1),
NewURLAddr2: sanitizeURL(addr.NewURLAddr2),
GmailAddr: strings.TrimSpace(addr.GmailAddr),
QQ: strings.TrimSpace(addr.QQ),
TGGroup: sanitizeURL(addr.TGGroup),
Telegram: sanitizeURL(addr.Telegram),
PotaoGroup: sanitizeURL(firstNonEmptyURL(info.Config.PotaoGroup, addr.PotaoGroup)),
CustomerServiceHost: sanitizeURL(addr.CustomerServiceHost),
AppAddr: sanitizeURL(addr.AppAddr),
OfficialAppAddr: sanitizeURL(addr.OfficialAppAddr),
PDFAddr: sanitizeURL(addr.PDFAddr),
TwitterAddr: sanitizeURL(addr.TwitterAddr),
ReportSDKConfig: strings.TrimSpace(info.ReportSDKConfig),
NavPageList: buildNavPageList(info.NavPageList),
Advitise: info.Advitise,
DetailTopList: buildAdvertisementList(info.Advitise.DetailTopList),
NineGridList: buildAdvertisementList(info.Advitise.NineGridList),
DetailBannerList: buildAdvertisementList(info.Advitise.DetailBannerList),
BottomBanner: buildAdvertisementList(info.Advitise.BottomBanner),
DialogList: buildAdvertisementList(info.Advitise.DialogList),
SeoInfo: SeoInfo{
Title: strings.TrimSpace(info.SeoInfo.Title),
Description: strings.TrimSpace(info.SeoInfo.Description),
Keywords: info.SeoInfo.Keywords,
CanonicalURL: strings.TrimSpace(info.SeoInfo.CanonicalURL),
WebSiteName: strings.TrimSpace(info.SeoInfo.WebSiteName),
},
SeoNavInfo: SeoNavInfo{
DhTitle: strings.TrimSpace(info.SeoNavInfo.DhTitle),
DhDescription: strings.TrimSpace(info.SeoNavInfo.DhDescription),
DhKeyworld: info.SeoNavInfo.DhKeyworld,
DhSiteName: strings.TrimSpace(info.SeoNavInfo.DhSiteName),
},
}
}
func buildAdvertisementList(list []Advertisement) []Advertisement {
if len(list) == 0 {
return nil
}
out := make([]Advertisement, 0, len(list))
for _, item := range list {
item.ID = strings.TrimSpace(item.ID)
item.Images = sanitizeURL(item.Images)
item.Link = sanitizeURL(item.Link)
item.Title = strings.TrimSpace(item.Title)
if item.Images == "" {
continue
}
out = append(out, item)
}
return out
}
// NineGridRow 按首页设计每行返回两个 banner,超出当前行时返回空切片。
func (c AppConfigVM) NineGridRow(row int) []Advertisement {
if row < 0 {
return nil
}
start := row * 2
if start >= len(c.NineGridList) {
return nil
}
end := start + 2
if end > len(c.NineGridList) {
end = len(c.NineGridList)
}
return c.NineGridList[start:end]
}
// QRText 下载弹窗二维码、跳转及复制链接内容:仅使用应用下载地址 appAddr。
func (c AppConfigVM) QRText() string {
return strings.TrimSpace(c.AppAddr)
}
func buildNavPageList(list []NavPageItem) []NavPageItem {
if len(list) == 0 {
return nil
}
out := make([]NavPageItem, 0, len(list))
for _, item := range list {
u := sanitizeURL(item.URL)
if u == "" {
continue
}
out = append(out, NavPageItem{Line: item.Line, URL: u})
}
return out
}
func (n NavPageItem) Label() string {
digits := []string{"", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"}
if n.Line >= 1 && n.Line <= 10 {
return "线路" + digits[n.Line]
}
if n.Line > 0 {
return "线路" + strconv.Itoa(n.Line)
}
return "线路"
}
func sanitizeURL(v string) string {
v = strings.TrimSpace(v)
v = strings.TrimRight(v, "\\")
return strings.TrimSpace(v)
}
func firstNonEmptyURL(values ...string) string {
for _, v := range values {
if sanitizeURL(v) != "" {
return v
}
}
return ""
}
+91
View File
@@ -0,0 +1,91 @@
package configser
import "testing"
func TestAppConfigVMQRTextUsesAppAddr(t *testing.T) {
cfg := AppConfigVM{
AppAddr: " https://download.example.com/app ",
ForeverURLAddr: "https://www.example.com/",
}
if got, want := cfg.QRText(), "https://download.example.com/app"; got != want {
t.Fatalf("QRText() = %q, want %q", got, want)
}
}
func TestBuildAppConfigVMIncludesValidAdvertisements(t *testing.T) {
vm := BuildAppConfigVM(&ConfigInfoResp{
NewAddressInfo: NewAddressInfo{Telegram: " https://t.me/business ", QQ: " 2168045324 ", OfficialAppAddr: " https://app.example.com/download\\ "},
Config: ConfigInfo{PotaoGroup: " https://potato.example.com/group\\ "},
Advitise: AdvitiseData{
HomeList: []Advertisement{{ID: "home", Images: "https://cdn.example.com/home.gif"}},
DetailTopList: []Advertisement{{ID: "top", Images: " https://cdn.example.com/top.gif "}},
NineGridList: []Advertisement{
{ID: "banner-1", Images: "https://cdn.example.com/banner-1.gif"},
{ID: "banner-empty", Images: " "},
{ID: "banner-2", Images: "https://cdn.example.com/banner-2.gif"},
{ID: "banner-3", Images: "https://cdn.example.com/banner-3.gif"},
},
DetailBannerList: []Advertisement{{ID: "detail", Images: "https://cdn.example.com/detail.gif"}},
BottomBanner: []Advertisement{
{ID: " bottom ", Images: " https://cdn.example.com/bottom.gif ", Link: " https://example.com/bottom "},
{ID: "bottom-empty", Images: " "},
},
DialogList: []Advertisement{
{ID: " 1 ", Images: " https://cdn.example.com/ad.webp\\ ", Link: " https://example.com ", Title: " 广告一 "},
{ID: "2", Images: " ", Title: "无图广告"},
},
},
})
if vm.Telegram != "https://t.me/business" {
t.Fatalf("Telegram = %q, want sanitized business Telegram URL", vm.Telegram)
}
if vm.PotaoGroup != "https://potato.example.com/group" {
t.Fatalf("PotaoGroup = %q, want sanitized config.potaoGroup", vm.PotaoGroup)
}
if vm.QQ != "2168045324" {
t.Fatalf("QQ = %q, want trimmed QQ group", vm.QQ)
}
if vm.OfficialAppAddr != "https://app.example.com/download" {
t.Fatalf("OfficialAppAddr = %q, want sanitized official app URL", vm.OfficialAppAddr)
}
if len(vm.Advitise.HomeList) != 1 || vm.Advitise.HomeList[0].ID != "home" {
t.Fatalf("Advitise.HomeList = %#v", vm.Advitise.HomeList)
}
if len(vm.Advitise.NineGridList) != 4 {
t.Fatalf("Advitise.NineGridList length = %d, want raw length 4", len(vm.Advitise.NineGridList))
}
if len(vm.DetailTopList) != 1 || vm.DetailTopList[0].ID != "top" {
t.Fatalf("DetailTopList = %#v", vm.DetailTopList)
}
if len(vm.NineGridList) != 3 {
t.Fatalf("NineGridList length = %d, want 3", len(vm.NineGridList))
}
if len(vm.DetailBannerList) != 1 || vm.DetailBannerList[0].ID != "detail" {
t.Fatalf("DetailBannerList = %#v", vm.DetailBannerList)
}
if len(vm.BottomBanner) != 1 || vm.BottomBanner[0].ID != "bottom" || vm.BottomBanner[0].Images != "https://cdn.example.com/bottom.gif" {
t.Fatalf("BottomBanner = %#v", vm.BottomBanner)
}
if len(vm.DialogList) != 1 {
t.Fatalf("DialogList length = %d, want 1", len(vm.DialogList))
}
got := vm.DialogList[0]
if got.ID != "1" || got.Images != "https://cdn.example.com/ad.webp" || got.Link != "https://example.com" || got.Title != "广告一" {
t.Fatalf("DialogList[0] = %#v", got)
}
}
func TestNineGridRowReturnsTwoItemsPerRow(t *testing.T) {
cfg := AppConfigVM{NineGridList: []Advertisement{{ID: "1"}, {ID: "2"}, {ID: "3"}}}
if got := cfg.NineGridRow(0); len(got) != 2 || got[0].ID != "1" || got[1].ID != "2" {
t.Fatalf("NineGridRow(0) = %#v", got)
}
if got := cfg.NineGridRow(1); len(got) != 1 || got[0].ID != "3" {
t.Fatalf("NineGridRow(1) = %#v", got)
}
if got := cfg.NineGridRow(2); len(got) != 0 {
t.Fatalf("NineGridRow(2) = %#v, want empty", got)
}
}
+30
View File
@@ -0,0 +1,30 @@
package feedbackser
import (
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const addPath = "/api/app/feedback/add"
const contactUsAddPath = "/api/app/contactus/add"
// Add 调用上游提交意见反馈。
func Add(c *gin.Context, req FeedbackReq) (*authser.APIEnvelope, error) {
var env authser.APIEnvelope
if _, err := upstream.PostJSON(c, addPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// AddContactUs 调用上游联系我们接口。
func AddContactUs(c *gin.Context, req ContactUsAddReq) (*authser.APIEnvelope, error) {
var env authser.APIEnvelope
if _, err := upstream.PostJSON(c, contactUsAddPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+23
View File
@@ -0,0 +1,23 @@
package feedbackser
// FeedbackReq 对应 swagger proto.FeedbackReq
// type: 1-功能建议、2-播放问题、3-账号问题、4-其他(与接口 description 一致)
type FeedbackReq struct {
Type int `json:"type"`
Content string `json:"content"`
Contact string `json:"contact,omitempty"`
Screenshots []string `json:"screenshots,omitempty"`
}
// ContactUsAddReq 对应 swagger proto.ContactUsAddReq。
type ContactUsAddReq struct {
Type int `json:"type"`
Title string `json:"title"`
Content string `json:"content"`
Email string `json:"email"`
Attachments []string `json:"attachments,omitempty"`
}
type ContactUsAddResp struct {
Message string `json:"message"`
}
+81
View File
@@ -0,0 +1,81 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
categoryDetailPath = "/api/app/playlet/recommend/list"
defaultCategoryPageSize = 25
)
// CategoryDetailQuery 分类详情分页参数。categorySlug 非空时优先使用 slug。
type CategoryDetailQuery struct {
PageNum int
PageSize int
CategoryID int64
CategorySlug string
}
func (q *CategoryDetailQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultCategoryPageSize
}
if q.PageSize > 100 {
q.PageSize = 100
}
if q.CategoryID < 0 {
q.CategoryID = 0
}
q.CategorySlug = strings.TrimSpace(q.CategorySlug)
}
func categoryDetailListURL(q CategoryDetailQuery) string {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
if q.CategorySlug != "" {
params.Set("categorySlug", q.CategorySlug)
} else if q.CategoryID > 0 {
params.Set("categoryId", strconv.FormatInt(q.CategoryID, 10))
}
return categoryDetailPath + "?" + params.Encode()
}
// FetchCategoryDetail 根据分类 slug 或 ID 获取分类详情列表。
func FetchCategoryDetail(c *gin.Context, q CategoryDetailQuery) (*PlayletRecommendListResp, error) {
var env APIEnvelope[PlayletRecommendListResp]
if err := upstream.GetPublicJSON(c, categoryDetailListURL(q), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet category detail code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchCategoryDetailOrEmpty 保持页面可渲染,接口失败时返回当前分页的空结果。
func FetchCategoryDetailOrEmpty(c *gin.Context, q CategoryDetailQuery) *PlayletRecommendListResp {
q.Normalize()
data, err := FetchCategoryDetail(c, q)
if err != nil {
log.Error("fetch playlet category detail failed", log.E(err))
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
if data == nil {
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data.SeoInfo.Normalize()
return data
}
@@ -0,0 +1,49 @@
package playletser
import (
"strings"
"testing"
)
func TestCategoryDetailListURLUsesSlugBeforeID(t *testing.T) {
got := categoryDetailListURL(CategoryDetailQuery{
PageNum: 2,
CategoryID: 18,
CategorySlug: "latest-video",
})
for _, want := range []string{"/api/app/playlet/recommend/list?", "categorySlug=latest-video", "pageNum=2", "pageSize=25"} {
if !strings.Contains(got, want) {
t.Fatalf("categoryDetailListURL() = %q, missing %q", got, want)
}
}
if strings.Contains(got, "categoryId=") {
t.Fatalf("categoryDetailListURL() = %q, categoryId must be omitted when slug exists", got)
}
}
func TestParseCategoryDetailQueryAndCanonicalPath(t *testing.T) {
slugQuery := ParseCategoryDetailQuery("latest-video", "3")
if slugQuery.CategorySlug != "latest-video" || slugQuery.PageNum != 3 || slugQuery.PageSize != 25 {
t.Fatalf("ParseCategoryDetailQuery(slug) = %#v", slugQuery)
}
if got := CategoryDetailCanonicalPath(slugQuery); got != "/category/latest-video/page/3/" {
t.Fatalf("CategoryDetailCanonicalPath(slug) = %q", got)
}
idQuery := ParseCategoryDetailQuery("42", "")
if idQuery.CategoryID != 42 || idQuery.CategorySlug != "" {
t.Fatalf("ParseCategoryDetailQuery(id) = %#v", idQuery)
}
if got := CategoryDetailCanonicalPath(idQuery); got != "/category/42/" {
t.Fatalf("CategoryDetailCanonicalPath(id) = %q", got)
}
}
func TestCategoryDetailURLPrefersSlugAndFallsBackToID(t *testing.T) {
if got := categoryDetailURL("latest-video", 42); got != "/category/latest-video/" {
t.Fatalf("categoryDetailURL(slug) = %q", got)
}
if got := categoryDetailURL("", 42); got != "/category/42/" {
t.Fatalf("categoryDetailURL(id) = %q", got)
}
}
@@ -0,0 +1,63 @@
package playletser
import (
"strconv"
"strings"
)
func categoryDetailPageURL(categoryID int64, categorySlug string, pageNum int) string {
if pageNum <= 0 {
pageNum = 1
}
path := "/category"
if segment := pathSegment(categorySlug, categoryID); segment != "" {
path += "/" + segment
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path + "/"
}
func categoryDetailURL(categorySlug string, categoryID int64) string {
return categoryDetailPageURL(categoryID, categorySlug, 1)
}
// ParseCategoryDetailQuery 从语义化路由读取分类标识和页码。
func ParseCategoryDetailQuery(identifier, pageStr string) CategoryDetailQuery {
q := CategoryDetailQuery{PageSize: defaultCategoryPageSize}
identifier = strings.TrimSpace(identifier)
if id, err := strconv.ParseInt(identifier, 10, 64); err == nil && id > 0 {
q.CategoryID = id
} else {
q.CategorySlug = identifier
}
if page, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = page
}
q.Normalize()
return q
}
func CategoryDetailCanonicalPath(q CategoryDetailQuery) string {
q.Normalize()
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, q.PageNum)
}
func BuildCategoryDetailPagination(q CategoryDetailQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(page int) string {
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, page)
}, q.PageNum, q.PageSize, totalCount)
}
func CategoryDetailPageTitle(categoryName string, pageNum int, seo SeoInfo) string {
name := strings.TrimSpace(categoryName)
if name == "" {
name = strings.TrimSpace(seo.Title)
}
if name == "" {
name = "分类短剧"
}
return name + PageTitleSuffix(pageNum)
}
+198
View File
@@ -0,0 +1,198 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
chapterDetailPath = "/api/app/playlet/chapter/detail"
chapterListPath = "/api/app/playlet/chapter/list"
)
// PlayQuery 播放页参数。
type PlayQuery struct {
PlayletID int64
PlayletSlug string
ChapterID int64
}
// ParsePlayQuery 解析播放页的路径参数。
func ParsePlayQuery(playletKey, chapterIDRaw string) PlayQuery {
q := PlayQuery{PlayletSlug: strings.TrimSpace(playletKey)}
if n, err := strconv.ParseInt(strings.TrimSpace(playletKey), 10, 64); err == nil && n > 0 {
q.PlayletID = n
q.PlayletSlug = ""
}
if n, err := strconv.ParseInt(strings.TrimSpace(chapterIDRaw), 10, 64); err == nil && n > 0 {
q.ChapterID = n
}
return q
}
// FetchChapterDetail 查询剧集播放详情。
func FetchChapterDetail(c *gin.Context, chapterID, playletID int64, playletSlug string) (*PlayletChapterInfoResp, error) {
if chapterID <= 0 {
return nil, fmt.Errorf("chapter detail invalid chapterId")
}
params := url.Values{}
params.Set("chapterId", strconv.FormatInt(chapterID, 10))
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
params.Set("playletSlug", playletSlug)
} else if playletID > 0 {
params.Set("playletId", strconv.FormatInt(playletID, 10))
}
authser.EnsureGuestOnEnter(c)
var env APIEnvelope[PlayletChapterInfoResp]
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("chapter detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
env.Data.SeoInfo.Normalize()
return &env.Data, nil
}
// FetchChapterDetailOrNil 失败返回 nil。
func FetchChapterDetailOrNil(c *gin.Context, chapterID, playletID int64, playletSlug string) *PlayletChapterInfoResp {
data, err := FetchChapterDetail(c, chapterID, playletID, playletSlug)
if err != nil {
log.Error("fetch chapter detail failed", log.E(err))
return nil
}
if data == nil || data.ID <= 0 {
return nil
}
return data
}
// FetchChapterList 根据短剧 slug 或 ID 查询选集列表,slug 优先。
func FetchChapterList(c *gin.Context, playletID int64, playletSlug string) (*PlayletChapterListResp, error) {
params := url.Values{}
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
params.Set("playletSlug", playletSlug)
} else if playletID > 0 {
params.Set("playletId", strconv.FormatInt(playletID, 10))
} else {
return nil, fmt.Errorf("chapter list missing playletSlug or playletId")
}
authser.EnsureGuestOnEnter(c)
var env APIEnvelope[PlayletChapterListResp]
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet chapter list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
return &env.Data, nil
}
// ResolvePlayChapterID 无 chapterId 时取短剧第一章。
func ResolvePlayChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
if chapterID > 0 {
return chapterID
}
if playlet == nil {
return 0
}
for _, ch := range playlet.PlayletChapterInfo {
if ch.ID > 0 {
return ch.ID
}
}
return 0
}
// AllowWatch 是否可播放(优先 canWatch;兼容仅返回 videoUrl 的旧数据)。
func (c *PlayletChapterInfoResp) AllowWatch() bool {
if c == nil {
return false
}
if c.CanWatch {
return true
}
if c.NeedUnlock || c.NeedVip {
return false
}
return strings.TrimSpace(c.VideoURL) != "" && c.PayType == 0
}
// UnlockCost 金币解锁消耗:优先 costCoins。
func (c *PlayletChapterInfoResp) UnlockCost() int64 {
if c == nil {
return 0
}
if c.CostCoins > 0 {
return c.CostCoins
}
if c.SingleCoins > 0 {
return int64(c.SingleCoins)
}
return 0
}
// AllowWatch 详情选集是否可直达播放(优先 canWatch)。
func (c PlayletChapterData) AllowWatch() bool {
if c.CanWatch {
return true
}
if c.NeedUnlock || c.NeedVip {
return false
}
if c.IsUnlocked {
return true
}
return c.PayType == 0
}
// NeedCoinGate 是否弹出金币解锁。
func (c PlayletChapterData) NeedCoinGate() bool {
if c.AllowWatch() {
return false
}
if c.NeedUnlock {
return true
}
return c.PayType == 1
}
// NeedVipGate 是否弹出 VIP 开通。
func (c PlayletChapterData) NeedVipGate() bool {
if c.AllowWatch() || c.NeedCoinGate() {
return false
}
if c.NeedVip {
return true
}
return c.PayType == 2
}
// UnlockCost 金币解锁消耗:优先 costCoins。
func (c PlayletChapterData) UnlockCost() int {
if c.CostCoins > 0 {
return int(c.CostCoins)
}
return c.SingleCoins
}
+84
View File
@@ -0,0 +1,84 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const chapterCollectListPath = "/api/app/playlet/chapter/collect/list"
// PlayletChapterCollectData 对应 swagger proto.PlayletChapterCollectData
type PlayletChapterCollectData struct {
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
PlayletID int64 `json:"playletId"`
PlayletSlug string `json:"playletSlug"`
PlayletTitle string `json:"playletTitle"`
PlayletCover string `json:"playletCover"`
CurrentEpisode int `json:"currentEpisode"`
TotalEpisode int `json:"totalEpisode"`
UpdateStatus int `json:"updateStatus"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
IsCollect int `json:"isCollect"`
CollectedAt string `json:"collectedAt"`
MediaTime int `json:"mediaTime"`
PayType int `json:"payType"`
SingleCoins int `json:"singleCoins"`
VideoURL string `json:"videoUrl"`
}
// PlayletChapterCollectListResp 对应 swagger proto.PlayletChapterCollectListResp
type PlayletChapterCollectListResp struct {
List []PlayletChapterCollectData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// FetchChapterCollectList 拉取当前用户剧集收藏列表。
// 第二个返回值为上游业务 code(网络错误时为 0)。
func FetchChapterCollectList(c *gin.Context, q CollectListQuery) (*PlayletChapterCollectListResp, int, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[PlayletChapterCollectListResp]
if err := upstream.GetJSON(c, chapterCollectListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet chapter collect list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
// FetchChapterCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
func FetchChapterCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletChapterCollectListResp {
data, code, err := FetchChapterCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
data, _, err = FetchChapterCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
}
log.Error("fetch playlet chapter collect list failed", log.E(err))
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
+63
View File
@@ -0,0 +1,63 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const (
chapterLikeSetPath = "/api/app/playlet/chapter/like/set"
chapterCollectSetPath = "/api/app/playlet/chapter/collect/set"
)
// PlayletChapterLikeSetReq 对应 swagger proto.PlayletChapterLikeSetReq
type PlayletChapterLikeSetReq struct {
ChapterID int64 `json:"chapterId"`
IsLike bool `json:"isLike"`
}
// PlayletChapterLikeSetResp 对应 swagger proto.PlayletChapterLikeSetResp
type PlayletChapterLikeSetResp struct {
ChapterID int64 `json:"chapterId"`
CountLike int64 `json:"countLike"`
IsLike int `json:"isLike"`
}
// ChapterLikeSet 设置剧集点赞/取消点赞。
func ChapterLikeSet(c *gin.Context, req PlayletChapterLikeSetReq) (*APIEnvelope[PlayletChapterLikeSetResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter like invalid chapterId")
}
var env APIEnvelope[PlayletChapterLikeSetResp]
if _, err := upstream.PostJSON(c, chapterLikeSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// PlayletChapterCollectSetReq 对应 swagger proto.PlayletChapterCollectSetReq
type PlayletChapterCollectSetReq struct {
ChapterID int64 `json:"chapterId"`
IsCollect bool `json:"isCollect"`
}
// PlayletChapterCollectSetResp 对应 swagger proto.PlayletChapterCollectSetResp
type PlayletChapterCollectSetResp struct {
ChapterID int64 `json:"chapterId"`
CountCollect int64 `json:"countCollect"`
IsCollect int `json:"isCollect"`
}
// ChapterCollectSet 设置剧集收藏/取消收藏。
func ChapterCollectSet(c *gin.Context, req PlayletChapterCollectSetReq) (*APIEnvelope[PlayletChapterCollectSetResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter collect invalid chapterId")
}
var env APIEnvelope[PlayletChapterCollectSetResp]
if _, err := upstream.PostJSON(c, chapterCollectSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+40
View File
@@ -0,0 +1,40 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const chapterUnlockPath = "/api/app/playlet/chapter/unlock"
// PlayletChapterUnlockReq 对应 swagger proto.PlayletChapterUnlockReq
// POST /api/app/playlet/chapter/unlock
type PlayletChapterUnlockReq struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
// PlayletChapterUnlockResp 对应 swagger proto.PlayletChapterUnlockResp
type PlayletChapterUnlockResp struct {
Balance int64 `json:"balance"`
CanWatch bool `json:"canWatch"`
ChangeCoin int64 `json:"changeCoin"`
ChapterID int64 `json:"chapterId"`
CostCoins int64 `json:"costCoins"`
PlayletID int64 `json:"playletId"`
Unlocked bool `json:"unlocked"`
}
// ChapterUnlock 使用金币解锁剧集(幂等)。
func ChapterUnlock(c *gin.Context, req PlayletChapterUnlockReq) (*APIEnvelope[PlayletChapterUnlockResp], error) {
if req.ChapterID <= 0 || req.PlayletID <= 0 {
return nil, fmt.Errorf("chapter unlock invalid chapterId/playletId")
}
var env APIEnvelope[PlayletChapterUnlockResp]
if _, err := upstream.PostJSON(c, chapterUnlockPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+39
View File
@@ -0,0 +1,39 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const chapterWatchReportPath = "/api/app/playlet/chapter/watch/report"
// PlayletChapterWatchReportReq 对应 swagger proto.PlayletChapterWatchReportReq
type PlayletChapterWatchReportReq struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId,omitempty"`
}
// PlayletChapterWatchReportResp 对应 swagger proto.PlayletChapterWatchReportResp
type PlayletChapterWatchReportResp struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
CurrentEpisode int `json:"currentEpisode"`
LastChapterID int64 `json:"lastChapterId"`
LastEpisode int `json:"lastEpisode"`
WatchedCount int `json:"watchedCount"`
UnwatchedCount int `json:"unwatchedCount"`
}
// ChapterWatchReport 上报短剧剧集观看。
func ChapterWatchReport(c *gin.Context, req PlayletChapterWatchReportReq) (*APIEnvelope[PlayletChapterWatchReportResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter watch report invalid chapterId")
}
var env APIEnvelope[PlayletChapterWatchReportResp]
if _, err := upstream.PostJSON(c, chapterWatchReportPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+155
View File
@@ -0,0 +1,155 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
collectSetPath = "/api/app/playlet/collect/set"
collectListPath = "/api/app/playlet/collect/list"
defaultCollectPageSize = 40
CollectTypePlaylet = "playlet"
CollectTypeChapter = "chapter"
)
// PlayletCollectSetReq 对应 swagger proto.PlayletCollectSetReq
type PlayletCollectSetReq struct {
ID int64 `json:"id"`
IsCollect bool `json:"isCollect"`
}
// PlayletCollectSetResp 对应 swagger proto.PlayletCollectSetResp
type PlayletCollectSetResp struct {
ID int64 `json:"id"`
CountCollect int64 `json:"countCollect"`
IsCollect int `json:"isCollect"`
}
// PlayletCollectData 对应 swagger proto.PlayletCollectData
type PlayletCollectData struct {
ID int64 `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Cover string `json:"cover"`
Desc string `json:"desc"`
TagIDs []int64 `json:"tagIds"`
TagNames []string `json:"tagNames"`
TagSlugs []string `json:"tagSlugs"`
ThemeIDs []int64 `json:"themeIds"`
ThemeNames []string `json:"themeNames"`
ThemeSlugs []string `json:"themeSlugs"`
TotalEpisode int `json:"totalEpisode"`
UpdateStatus int `json:"updateStatus"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
IsFeatured bool `json:"isFeatured"`
IsNew bool `json:"isNew"`
IsRecommend bool `json:"isRecommend"`
IsCollect int `json:"isCollect"`
CollectedAt string `json:"collectedAt"`
WatchedCount int `json:"watchedCount"`
UnwatchedCount int `json:"unwatchedCount"`
LastChapterID int64 `json:"lastChapterId"`
LastEpisode int `json:"lastEpisode"`
}
// PlayletCollectListResp 对应 swagger proto.PlayletCollectListResp
type PlayletCollectListResp struct {
List []PlayletCollectData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// CollectListQuery 收藏列表分页参数。
type CollectListQuery struct {
PageNum int
PageSize int
Type string // playlet | chapter
}
// NormalizeCollectType 归一化收藏类型。
func NormalizeCollectType(raw string) string {
switch strings.TrimSpace(strings.ToLower(raw)) {
case CollectTypeChapter, "2", "episode", "ep":
return CollectTypeChapter
default:
return CollectTypePlaylet
}
}
// Normalize 校正分页默认值。
func (q *CollectListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultCollectPageSize
}
if q.PageSize > 50 {
q.PageSize = 50
}
q.Type = NormalizeCollectType(q.Type)
}
// CollectSet 设置短剧收藏/取消收藏。
func CollectSet(c *gin.Context, req PlayletCollectSetReq) (*APIEnvelope[PlayletCollectSetResp], error) {
if req.ID <= 0 {
return nil, fmt.Errorf("playlet collect invalid id")
}
var env APIEnvelope[PlayletCollectSetResp]
if _, err := upstream.PostJSON(c, collectSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchCollectList 拉取当前用户短剧收藏列表。
// 第二个返回值为上游业务 code(网络错误时为 0)。
func FetchCollectList(c *gin.Context, q CollectListQuery) (*PlayletCollectListResp, int, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[PlayletCollectListResp]
if err := upstream.GetJSON(c, collectListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet collect list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
// FetchCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
func FetchCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletCollectListResp {
data, code, err := FetchCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
data, _, err = FetchCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
}
log.Error("fetch playlet collect list failed", log.E(err))
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
+89
View File
@@ -0,0 +1,89 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
)
func collectPageURL(pageNum int, collectType string) string {
collectType = NormalizeCollectType(collectType)
path := "/collect"
if collectType != CollectTypePlaylet {
path += "/" + url.PathEscape(collectType)
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path + "/"
}
func CollectCanonicalPath(q CollectListQuery) string {
q.Normalize()
return collectPageURL(q.PageNum, q.Type)
}
// BuildCollectPagination 收藏列表分页。
func BuildCollectPagination(q CollectListQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(p int) string {
return collectPageURL(p, q.Type)
}, q.PageNum, q.PageSize, totalCount)
}
// ParseCollectListQuery 从 query 解析分页与类型(playlet 短剧 / chapter 剧集)。
func ParseCollectListQuery(pageStr, typeStr string) CollectListQuery {
q := CollectListQuery{
PageSize: defaultCollectPageSize,
Type: NormalizeCollectType(typeStr),
}
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = n
}
q.Normalize()
return q
}
// collectProgressText 进度文案:优先最近观看集,其次未看/已看完。
func collectProgressText(item PlayletCollectData) string {
if item.LastEpisode > 0 {
return fmt.Sprintf("第%d集", item.LastEpisode)
}
if item.TotalEpisode > 0 && item.UnwatchedCount <= 0 {
return "已看完"
}
if item.UnwatchedCount > 0 {
return fmt.Sprintf("%d集未看", item.UnwatchedCount)
}
return ""
}
// collectTagText 收藏卡片标签:仅展示 TagNames 第一项,无标签时不显示。
func collectTagText(item PlayletCollectData) string {
return firstName(item.TagNames)
}
// chapterCollectEpisodeLabel 剧集收藏角标:总集数。
func chapterCollectEpisodeLabel(item PlayletChapterCollectData) string {
return episodeText(item.TotalEpisode)
}
// chapterCollectDesc 剧集收藏副标题。
func chapterCollectDesc(item PlayletChapterCollectData) string {
if t := strings.TrimSpace(item.ChapterTitle); t != "" {
return t
}
if item.CurrentEpisode > 0 {
return fmt.Sprintf("第%d集", item.CurrentEpisode)
}
return ""
}
// chapterCollectMeta 剧集收藏右侧文案:第 N 集。
func chapterCollectMeta(item PlayletChapterCollectData) string {
if item.CurrentEpisode > 0 {
return fmt.Sprintf("第%d集", item.CurrentEpisode)
}
return updateStatusText(item.UpdateStatus)
}
+133
View File
@@ -0,0 +1,133 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
detailPath = "/api/app/playlet/detail"
defaultEpisodeRange = 30
)
// EpisodeRange 剧集分段(仅 UI 分页展示,非接口字段映射)。
type EpisodeRange struct {
Start int
End int
Label string
}
// FetchDetail 根据短剧 ID 查询详情。
func FetchDetail(c *gin.Context, id int64) (*PlayletInfoResp, error) {
if id <= 0 {
return nil, fmt.Errorf("playlet detail invalid id")
}
return fetchDetail(c, "id", strconv.FormatInt(id, 10))
}
// FetchDetailByKey 优先按 slug 查询;纯数字路由段按 ID 查询。
func FetchDetailByKey(c *gin.Context, key string) (*PlayletInfoResp, error) {
key = strings.TrimSpace(key)
if key == "" {
return nil, fmt.Errorf("playlet detail missing route key")
}
if id := ParseDetailID(key); id > 0 {
return FetchDetail(c, id)
}
return fetchDetail(c, "slug", key)
}
func fetchDetail(c *gin.Context, queryKey, value string) (*PlayletInfoResp, error) {
authser.EnsureGuestOnEnter(c)
env, err := fetchDetailOnce(c, queryKey, value)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
env, err = fetchDetailOnce(c, queryKey, value)
if err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
env.Data.SeoInfo.Normalize()
return &env.Data, nil
}
func fetchDetailOnce(c *gin.Context, queryKey, value string) (*APIEnvelope[PlayletInfoResp], error) {
params := url.Values{}
params.Set(queryKey, value)
var env APIEnvelope[PlayletInfoResp]
if err := upstream.GetJSON(c, detailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchDetailOrNilByKey 失败或无效时返回 nil。
func FetchDetailOrNilByKey(c *gin.Context, key string) *PlayletInfoResp {
data, err := FetchDetailByKey(c, key)
if err != nil {
log.Error("fetch playlet detail by route key failed", log.E(err))
return nil
}
if data == nil || data.ID <= 0 {
return nil
}
return data
}
// ParseDetailID 解析纯数字路由段。
func ParseDetailID(raw string) int64 {
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || id <= 0 {
return 0
}
return id
}
// BuildEpisodeRanges 按集数分段(默认每段 30 集)。
func BuildEpisodeRanges(chapters []PlayletChapterData, totalEpisode, size int) []EpisodeRange {
if size <= 0 {
size = defaultEpisodeRange
}
maxEp := 0
for _, ch := range chapters {
if ch.CurrentEpisode > maxEp {
maxEp = ch.CurrentEpisode
}
}
if totalEpisode > maxEp {
maxEp = totalEpisode
}
if maxEp <= 0 {
maxEp = len(chapters)
}
if maxEp <= 0 {
return nil
}
out := make([]EpisodeRange, 0, (maxEp+size-1)/size)
for start := 1; start <= maxEp; start += size {
end := start + size - 1
if end > maxEp {
end = maxEp
}
out = append(out, EpisodeRange{
Start: start,
End: end,
Label: fmt.Sprintf("%d–%d", start, end),
})
}
return out
}

Some files were not shown because too many files have changed in this diff Show More