Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-09-29 11:47:47 +07:00
co-authored by Claude Opus 5.5
commit 0fbdba5ccf
622 changed files with 72105 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+1
View File
@@ -0,0 +1 @@
.DS_Store
+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_91ys_h5
env GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_91ys_h5 -v
tar czf web_91ys_h5.tar.gz web_91ys_h5
rm -f web_91ys_h5
test:
@echo "test, GOPATH=$(GOPATH)"
GOOS=linux GOARCH=amd64 go build $(APP_BUILD_FLAG) -o web_91ys_h5
tar czf web_91ys_h5.tar.gz web_91ys_h5
rm -f web_91ys_h5
localtest:
@echo "test, GOPATH=$(GOPATH)"
go build $(APP_BUILD_FLAG) -o app
View File
+8
View File
@@ -0,0 +1,8 @@
package web
import "github.com/gin-gonic/gin"
// About renders the static About page.
func About(c *gin.Context) {
HTMLOK(c, "about.html", gin.H{})
}
+52
View File
@@ -0,0 +1,52 @@
package web
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
const maxAccountPage = 10000
type accountPage struct {
Current int
Total int
Count int64
PrevURL string
NextURL string
}
func accountPageNum(c *gin.Context) (int, bool) {
if c.Request.URL.Query().Has("page") {
return 0, false
}
raw := strings.TrimSpace(c.Param("page"))
if raw == "" {
return 1, true
}
page, err := strconv.Atoi(raw)
if err != nil || page < 1 || page > maxAccountPage {
return 0, false
}
return page, true
}
func makeAccountPage(path string, page, size int, count int64) accountPage {
total := int((count + int64(size) - 1) / int64(size))
p := accountPage{Current: page, Total: total, Count: count}
link := func(n int) string {
path = strings.TrimRight(path, "/")
if n <= 1 {
return path
}
return path + "/page/" + strconv.Itoa(n)
}
if page > 1 {
p.PrevURL = link(page - 1)
}
if page < total {
p.NextURL = link(page + 1)
}
return p
}
+290
View File
@@ -0,0 +1,290 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/authser"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
type registerBody struct {
AccountNo string `json:"accountNo"`
Email string `json:"email"`
Password string `json:"password"`
AgentInviteCode string `json:"agentInviteCode"`
DevID string `json:"devID"`
Channel string `json:"channel"`
}
type loginBody struct {
Account string `json:"account"`
Password string `json:"password"`
DevID string `json:"devID"`
Channel string `json:"channel"`
}
// Register 代理上游注册(POST /api/app/auth/register),成功后写入登录 cookie。
// 请求头带游客 Authorization 时升级该游客;邮箱缺省时用账号生成占位邮箱。
func Register(c *gin.Context) {
var body registerBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.AccountNo = strings.TrimSpace(body.AccountNo)
body.Email = strings.TrimSpace(body.Email)
body.AgentInviteCode = strings.TrimSpace(body.AgentInviteCode)
body.DevID = strings.TrimSpace(body.DevID)
body.Channel = authser.ResolveChannel(c, body.Channel)
if body.AccountNo == "" || body.Password == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请填写账号和密码",
"data": nil,
})
return
}
// 上游 registerReq 要求 email;前端无邮箱时用账号生成唯一占位邮箱。
if body.Email == "" {
body.Email = body.AccountNo + "@hgdj.local"
}
env, user, err := authser.Register(c, authser.RegisterReq{
AccountNo: body.AccountNo,
Email: body.Email,
Password: body.Password,
AgentInviteCode: body.AgentInviteCode,
DevID: body.DevID,
Channel: body.Channel,
})
if err != nil {
log.Error("register upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "register failed",
"tip": "注册失败,请稍后重试",
"data": nil,
})
return
}
if env == nil || env.Code != 200 || user == nil {
tip := "注册失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
if t := strings.TrimSpace(env.Tip); t != "" {
tip = t
} else if m := strings.TrimSpace(env.Msg); m != "" {
tip = m
}
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmptyAuth(env.Msg, "success"),
"tip": firstNonEmptyAuth(env.Tip, "注册成功"),
"data": user,
})
}
// Login 代理上游登录(POST /api/app/auth/login),成功后写入登录 cookie。
func Login(c *gin.Context) {
var body loginBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.Account = strings.TrimSpace(body.Account)
body.DevID = strings.TrimSpace(body.DevID)
body.Channel = authser.ResolveChannel(c, body.Channel)
if body.Account == "" || body.Password == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "请填写账号和密码",
"data": nil,
})
return
}
env, user, err := authser.Login(c, authser.LoginReq{
Account: body.Account,
Password: body.Password,
DevID: body.DevID,
Channel: body.Channel,
})
if err != nil {
log.Error("login upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "login failed",
"tip": "登录失败,请稍后重试",
"data": nil,
})
return
}
if env == nil || env.Code != 200 || user == nil {
tip := "登录失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
if t := strings.TrimSpace(env.Tip); t != "" {
tip = t
} else if m := strings.TrimSpace(env.Msg); m != "" {
tip = m
}
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmptyAuth(env.Msg, "success"),
"tip": firstNonEmptyAuth(env.Tip, "登录成功"),
"data": user,
})
}
// Guest 代理上游游客登录,成功后写入登录 cookie。
// 对应 POST /api/app/auth/guest;需传 devID,上游要求 X-User-Agent / X-Real-IP(由 BFF 组装)。
func Guest(c *gin.Context) {
var body struct {
DevID string `json:"devID"`
DeviceID string `json:"deviceId"`
Mac string `json:"mac"`
Channel string `json:"channel"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "参数错误",
"data": nil,
})
return
}
body.DevID = strings.TrimSpace(body.DevID)
body.DeviceID = strings.TrimSpace(body.DeviceID)
body.Mac = strings.TrimSpace(body.Mac)
body.Channel = authser.ResolveChannel(c, body.Channel)
if body.DevID == "" {
c.JSON(http.StatusOK, gin.H{
"code": 1001,
"msg": "参数错误",
"tip": "缺少设备标识",
"data": nil,
})
return
}
if body.DeviceID == "" {
body.DeviceID = body.DevID
}
env, user, err := authser.GuestLogin(c, authser.GuestLoginReq{
DevID: body.DevID,
DeviceID: body.DeviceID,
Mac: body.Mac,
Channel: body.Channel,
})
if err != nil {
log.Error("guest login upstream failed", log.E(err))
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "guest login failed",
"tip": "游客登录失败,请稍后重试",
"data": nil,
})
return
}
if env == nil || env.Code != 200 || user == nil {
tip := "游客登录失败"
msg := ""
code := 500
if env != nil {
code = env.Code
msg = env.Msg
if t := strings.TrimSpace(env.Tip); t != "" {
tip = t
} else if m := strings.TrimSpace(env.Msg); m != "" {
tip = m
}
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
authser.SetAuthCookies(c, user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": firstNonEmptyAuth(env.Msg, "success"),
"tip": firstNonEmptyAuth(env.Tip, "游客登录成功"),
"data": user,
})
}
// Logout 清除登录 cookie,并标记跳过紧接着的一次自动游客登录。
func Logout(c *gin.Context) {
authser.ClearAuthCookies(c)
authser.MarkGuestSkipAuto(c)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "success",
"tip": "已退出登录",
"data": nil,
})
}
func firstNonEmptyAuth(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+120
View File
@@ -0,0 +1,120 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// ChapterLikeSet BFF:代理 POST /api/app/playlet/chapter/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_like_set
func ChapterLikeSet(c *gin.Context) {
var body struct {
ChapterID int64 `json:"chapterId"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId/isLike required",
"tip": "缺少剧集 ID 或点赞状态",
"data": nil,
})
return
}
env, err := playletser.ChapterLikeSet(c, playletser.PlayletChapterLikeSetReq{
ChapterID: body.ChapterID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
// ChapterCollectSet BFF:代理 POST /api/app/playlet/chapter/collect/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_collect_set
func ChapterCollectSet(c *gin.Context) {
var body struct {
ChapterID int64 `json:"chapterId"`
IsCollect *bool `json:"isCollect"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId/isCollect required",
"tip": "缺少剧集 ID 或收藏状态",
"data": nil,
})
return
}
env, err := playletser.ChapterCollectSet(c, playletser.PlayletChapterCollectSetReq{
ChapterID: body.ChapterID,
IsCollect: *body.IsCollect,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// ChapterUnlock BFF:代理 POST /api/app/playlet/chapter/unlock。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_unlock
func ChapterUnlock(c *gin.Context) {
var body struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 || body.PlayletID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId/playletId required",
"tip": "缺少剧集或短剧 ID",
"data": nil,
})
return
}
env, err := playletser.ChapterUnlock(c, playletser.PlayletChapterUnlockReq{
ChapterID: body.ChapterID,
PlayletID: body.PlayletID,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "解锁失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "解锁失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+75
View File
@@ -0,0 +1,75 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// ChapterWatchReport BFF:代理 POST /api/app/playlet/chapter/watch/report。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_chapter_watch_report
func ChapterWatchReport(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{
"code": 401,
"msg": "unauthorized",
"tip": "请先登录",
"data": nil,
})
return
}
var body struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ChapterID <= 0 {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "chapterId required",
"tip": "缺少剧集 ID",
"data": nil,
})
return
}
env, err := playletser.ChapterWatchReport(c, playletser.PlayletChapterWatchReportReq{
ChapterID: body.ChapterID,
PlayletID: body.PlayletID,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "上报失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "上报失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+155
View File
@@ -0,0 +1,155 @@
package web
import (
"net/http"
"strconv"
"strings"
"videoweb/app/service/commentser"
"github.com/gin-gonic/gin"
)
// CommentList BFF:代理 GET /api/app/comment/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/get_api_app_comment_list
func CommentList(c *gin.Context) {
objectID, _ := strconv.ParseInt(c.Query("objectId"), 10, 64)
objectType, _ := strconv.Atoi(c.Query("objectType"))
pageNum, _ := strconv.Atoi(c.Query("pageNum"))
pageSize, _ := strconv.Atoi(c.Query("pageSize"))
sortOrder, _ := strconv.Atoi(c.Query("sortOrder"))
if objectID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "objectId required",
"tip": "缺少评论对象",
"data": nil,
})
return
}
data := commentser.FetchListOrEmpty(c, commentser.ListQuery{
ObjectID: objectID,
ObjectType: objectType,
PageNum: pageNum,
PageSize: pageSize,
SortOrder: sortOrder,
})
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "ok",
"tip": "成功",
"data": data,
})
}
// CommentSend BFF:代理 POST /api/app/comment/send。
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/post_api_app_comment_send
func CommentSend(c *gin.Context) {
var req commentser.CommentSendReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
env, err := commentser.Send(c, req)
if err != nil {
tip := "发布失败"
msg := err.Error()
switch {
case strings.Contains(msg, "empty text"):
tip = "请输入评论内容"
case strings.Contains(msg, "too long"):
tip = "评论不能超过 200 字"
case strings.Contains(msg, "invalid objectId"):
tip = "缺少评论对象"
}
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": msg,
"tip": tip,
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "发布失败",
"data": nil,
})
return
}
tip := env.Tip
if tip == "" {
tip = env.Msg
}
// 上游参数错误兜底提示(例如 objectType 不被支持)
if env.Code == 1001 && (tip == "" || tip == "参数错误") {
tip = "评论参数错误,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": tip,
"data": env.Data,
})
}
// CommentLikeSet BFF:代理 POST /api/app/comment/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/评论APP/post_api_app_comment_like_set
func CommentLikeSet(c *gin.Context) {
var body struct {
CommentID int64 `json:"commentId"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.CommentID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "commentId/isLike required",
"tip": "缺少评论 ID 或点赞状态",
"data": nil,
})
return
}
env, err := commentser.LikeSet(c, commentser.CommentLikeSetReq{
CommentID: body.CommentID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+8
View File
@@ -0,0 +1,8 @@
package web
import "github.com/gin-gonic/gin"
// Compliance2257 renders the static 18 U.S.C. 2257 compliance statement page.
func Compliance2257(c *gin.Context) {
HTMLOK(c, "2257.html", gin.H{})
}
+8
View File
@@ -0,0 +1,8 @@
package web
import "github.com/gin-gonic/gin"
// DMCA renders the static DMCA copyright notice page.
func DMCA(c *gin.Context) {
HTMLOK(c, "dmca.html", gin.H{})
}
+338
View File
@@ -0,0 +1,338 @@
package web
import (
"fmt"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type favoriteWork struct {
ID int64
Title string
Genre string
DesktopMeta string
Episodes string
Badge string
Image string
Kind string
URL string
DramaID int64
Episode int
VideoID int64
TotalEpisode int
}
// Favorites 我的收藏:短剧 / 剧集(视频) / 章节收藏。
// 文档:
// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_collect_list
// http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_collect_list
func Favorites(c *gin.Context) {
filter, page, validRoute := favoritesRoute(c)
if !validRoute {
NotFound(c)
return
}
data := gin.H{"title": "我的收藏 - 黄果短剧", "navActive": "favorites", "accountTab": "favorites"}
responses := gin.H{}
loadAccountSummary(c, data, responses)
status := 0
switch filter {
case "short", "chapter", "series":
case "following":
status = 1
case "finished":
status = 2
default:
filter = "all"
}
data["filter"] = filter
pageSize := 12
query := playletser.CollectListQuery{PageNum: page, PageSize: pageSize, WatchStatus: status}
if useragent.TokenFromRequest(c.Request) == "" {
responses["playlet/collect/list"] = gin.H{"error": "暂无登录会话"}
responses["video/collect/list"] = gin.H{"error": "暂无登录会话"}
data["favoritesResponse"] = responses["playlet/collect/list"]
data["playletCollectResponse"] = responses["playlet/collect/list"]
data["videoCollectResponse"] = responses["video/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
return
}
if filter == "chapter" {
list, code, err := playletser.FetchChapterCollectList(c, query)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, _, err = playletser.FetchChapterCollectList(c, query)
}
if err != nil {
log.Error("fetch chapter favorites page failed", log.E(err))
responses["playlet/chapter/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
data["favoritesUnavailable"] = true
} else if list != nil {
responses["playlet/chapter/collect/list"] = gin.H{"code": 200, "data": list}
data["paging"] = makeFavoritesPage(query.PageNum, query.PageSize, list.TotalCount, filter)
data["favorites"] = chapterFavoriteWorks(list.List)
} else {
responses["playlet/chapter/collect/list"] = gin.H{"code": code, "data": list}
}
data["favoritesResponse"] = responses["playlet/chapter/collect/list"]
data["playletCollectResponse"] = responses["playlet/chapter/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
return
}
if filter == "series" {
loadVideoFavorites(c, data, responses, page, pageSize)
data["favoritesResponse"] = responses["video/collect/list"]
data["playletCollectResponse"] = gin.H{}
data["videoCollectResponse"] = responses["video/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
return
}
var works []favoriteWork
var totalCount int64
playletFailed := false
videoFailed := false
if filter == "all" || filter == "short" || filter == "following" || filter == "finished" {
list, code, err := playletser.FetchCollectList(c, query)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, code, err = playletser.FetchCollectList(c, query)
}
if err == nil && list != nil {
responses["playlet/collect/list"] = gin.H{"code": 200, "data": list}
works = append(works, playletFavoriteWorks(list.List)...)
totalCount += list.TotalCount
} else if err != nil {
responses["playlet/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
log.Error("fetch favorites page failed", log.E(err))
playletFailed = true
} else {
responses["playlet/collect/list"] = gin.H{"code": code, "data": list}
}
}
if filter == "all" {
videoQuery := videoser.CollectListQuery{PageNum: page, PageSize: pageSize}
videoList, videoCode, videoErr := videoser.FetchCollectList(c, videoQuery)
if retry, _ := authser.OnSessionExpired(c, videoCode); retry {
videoList, videoCode, videoErr = videoser.FetchCollectList(c, videoQuery)
}
if videoErr == nil && videoList != nil {
responses["video/collect/list"] = gin.H{"code": 200, "data": videoList}
works = append(works, videoFavoriteWorks(videoList.List)...)
totalCount += videoList.TotalCount
} else if videoErr != nil {
responses["video/collect/list"] = gin.H{"error": "加载失败", "msg": videoErr.Error()}
log.Error("fetch video favorites page failed", log.E(videoErr))
videoFailed = true
} else {
responses["video/collect/list"] = gin.H{"code": videoCode, "data": videoList}
}
}
if playletFailed && (filter != "all" || videoFailed) {
data["favoritesUnavailable"] = true
}
if filter == "all" && playletFailed && videoFailed {
data["favoritesUnavailable"] = true
}
data["paging"] = makeFavoritesPage(page, pageSize, totalCount, filter)
data["favorites"] = works
data["favoritesResponse"] = responses["playlet/collect/list"]
data["playletCollectResponse"] = responses["playlet/collect/list"]
data["videoCollectResponse"] = responses["video/collect/list"]
data["favoritesDebug"] = responses
renderFavorites(c, data)
}
// renderFavorites 收藏为空时附带「大家都在看」后渲染。
func renderFavorites(c *gin.Context, data gin.H) {
attachFavoritesRecommended(c, data)
HTMLOK(c, "favorites.html", data)
}
// attachFavoritesRecommended 列表为空时拉取最热短剧(与搜索无结果一致)。
// 上游:GET /api/app/playlet/all/list sortType=2
func attachFavoritesRecommended(c *gin.Context, data gin.H) {
if works, ok := data["favorites"].([]favoriteWork); ok && len(works) > 0 {
return
}
watchingQ := playletser.AllListQuery{
PageNum: 1,
PageSize: 6,
SortType: 2, // 最热
UpdateStatus: 0,
}
watching := playletser.FetchAllListOrEmpty(c, watchingQ)
if watching == nil || len(watching.List) == 0 {
return
}
list := watching.List
if len(list) > 6 {
list = list[:6]
}
data["recommended"] = list
}
func loadVideoFavorites(c *gin.Context, data gin.H, responses gin.H, page, pageSize int) {
videoQuery := videoser.CollectListQuery{PageNum: page, PageSize: pageSize}
list, code, err := videoser.FetchCollectList(c, videoQuery)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, code, err = videoser.FetchCollectList(c, videoQuery)
}
if err != nil {
log.Error("fetch video favorites page failed", log.E(err))
responses["video/collect/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
data["favoritesUnavailable"] = true
return
}
if list == nil {
responses["video/collect/list"] = gin.H{"code": code, "data": list}
return
}
responses["video/collect/list"] = gin.H{"code": 200, "data": list}
data["paging"] = makeFavoritesPage(page, pageSize, list.TotalCount, "series")
data["favorites"] = videoFavoriteWorks(list.List)
}
func favoritesRoute(c *gin.Context) (filter string, page int, valid bool) {
if c.Request.URL.Query().Has("filter") {
return "", 0, false
}
filter = strings.TrimSpace(c.Param("filter"))
switch filter {
case "":
filter = "all"
case "short", "chapter", "series", "following", "finished":
default:
return "", 0, false
}
page, valid = accountPageNum(c)
if !valid {
return "", 0, false
}
return filter, page, true
}
func favoritesPagePath(filter string, page int) string {
path := "/favorites"
if filter != "" && filter != "all" {
path += "/" + filter
}
if page > 1 {
path += "/page/" + strconv.Itoa(page)
}
return path
}
func makeFavoritesPage(page, size int, count int64, filter string) accountPage {
total := int((count + int64(size) - 1) / int64(size))
pagination := accountPage{Current: page, Total: total, Count: count}
if page > 1 {
pagination.PrevURL = favoritesPagePath(filter, page-1)
}
if page < total {
pagination.NextURL = favoritesPagePath(filter, page+1)
}
return pagination
}
func playletFavoriteWorks(items []playletser.PlayletCollectData) []favoriteWork {
works := make([]favoriteWork, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.PlayName)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
continue
}
work := favoriteWork{
ID: item.ID, Title: title, Genre: strings.Join(item.ThemeNames, " "),
Image: profileCover(item.Cover), Kind: "short",
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
DramaID: item.ID, Episode: item.LastEpisode, VideoID: item.LastChapterID,
TotalEpisode: item.TotalEpisode,
}
if work.Episode <= 0 {
work.Episode = 1
}
if item.TotalEpisode > 0 {
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
work.DesktopMeta = work.Episodes
}
if item.IsFeatured {
work.Badge = "爆剧"
}
works = append(works, work)
}
return works
}
func chapterFavoriteWorks(items []playletser.PlayletChapterCollectData) []favoriteWork {
works := make([]favoriteWork, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.PlayletTitle)
if title == "" {
continue
}
work := favoriteWork{
ID: item.ChapterID, Title: title, Image: profileCover(item.PlayletCover), Kind: "chapter",
URL: playletser.ChapterPlayPath(item.PlayletSlug, item.PlayletID, item.ChapterID), DesktopMeta: item.ChapterTitle,
DramaID: item.PlayletID, Episode: item.CurrentEpisode, VideoID: item.ChapterID,
TotalEpisode: item.TotalEpisode,
}
if work.Episode <= 0 {
work.Episode = 1
}
if item.CurrentEpisode > 0 {
work.Genre = fmt.Sprintf("第 %d 集", item.CurrentEpisode)
}
if item.TotalEpisode > 0 {
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
}
works = append(works, work)
}
return works
}
func videoFavoriteWorks(items []videoser.VideoCollectData) []favoriteWork {
works := make([]favoriteWork, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.Title)
if title == "" {
continue
}
work := favoriteWork{
ID: item.ID, Title: title, Genre: strings.Join(item.Keyworld, " "),
Image: profileCover(item.CoverImg), Kind: "series",
URL: videoser.VideoPlayURL(item.Slug, item.ID),
}
if item.VideoTime > 0 {
work.Episodes = formatVideoDuration(item.VideoTime)
work.DesktopMeta = work.Episodes
} else {
work.Episodes = "剧集"
work.DesktopMeta = "剧集"
}
works = append(works, work)
}
return works
}
+154
View File
@@ -0,0 +1,154 @@
package web
import (
"net/http"
"path/filepath"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/feedbackser"
"github.com/gin-gonic/gin"
)
// Feedback 意见反馈页:渲染 template/pages/feedback.html。
func Feedback(c *gin.Context) {
HTMLOK(c, "feedback.html", gin.H{
"title": "意见反馈 - 黄果短剧",
})
}
// FeedbackAdd BFF:代理 POST /api/app/feedback/add。
// 文档:http://172.104.181.99:25319/swagger/index.html#/意见反馈相关接口/post_api_app_feedback_add
func FeedbackAdd(c *gin.Context) {
var body feedbackser.FeedbackReq
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
body.Content = strings.TrimSpace(body.Content)
body.Contact = strings.TrimSpace(body.Contact)
if body.Type < 1 || body.Type > 4 {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "invalid type",
"tip": "请选择问题类型",
"data": nil,
})
return
}
if body.Content == "" {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "content required",
"tip": "请填写问题描述",
"data": nil,
})
return
}
if runes := []rune(body.Content); len(runes) > 500 {
body.Content = string(runes[:500])
}
if len(body.Screenshots) > 3 {
body.Screenshots = body.Screenshots[:3]
}
env, err := feedbackser.Add(c, body)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "提交失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "提交失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
// UploadImage BFF:代理 POST /api/app/upload/image。
// 文档:http://172.104.181.99:25319/swagger/index.html#/上传/post_api_app_upload_image
func UploadImage(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil || fileHeader == nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "file required",
"tip": "请选择图片文件",
"data": nil,
})
return
}
f, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "读取图片失败",
"data": nil,
})
return
}
defer f.Close()
name := filepath.Base(fileHeader.Filename)
if name == "" || name == "." || name == "/" {
name = "image.jpg"
}
resp, err := authser.UploadImage(c, name, f)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "上传失败,请稍后重试",
"data": nil,
})
return
}
if resp == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "上传失败,请稍后重试",
"data": nil,
})
return
}
var data gin.H
if resp.Data != nil {
url := strings.TrimSpace(resp.Data.SourceNameFs)
if url == "" {
url = strings.TrimSpace(resp.Data.ID)
}
data = gin.H{
"id": resp.Data.ID,
"sourceNameFs": resp.Data.SourceNameFs,
"url": url,
}
}
c.JSON(http.StatusOK, gin.H{
"code": resp.Code,
"msg": resp.Msg,
"tip": resp.Tip,
"data": data,
})
}
+35
View File
@@ -0,0 +1,35 @@
package web
import (
"net/http"
"strconv"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// GuessLikeList BFF:代理 GET /api/app/playlet/guess-like/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_guess_like_list
func GuessLikeList(c *gin.Context) {
playletID, _ := strconv.ParseInt(c.Query("playletId"), 10, 64)
pageNum, _ := strconv.Atoi(c.Query("pageNum"))
pageSize, _ := strconv.Atoi(c.Query("pageSize"))
if playletID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "playletId required",
"data": nil,
})
return
}
data := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
PlayletID: playletID,
PageNum: pageNum,
PageSize: pageSize,
})
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "ok",
"data": data,
})
}
+23
View File
@@ -0,0 +1,23 @@
package web
import (
"videoweb/app/service/seoser"
"github.com/gin-gonic/gin"
)
// Help renders the static Help page.
func Help(c *gin.Context) {
HTMLOK(c, "help.html", gin.H{
"faqItems": []seoser.FAQItem{
{Question: "黄果短剧网页版可以看全集吗?", Answer: "可以。免费集直接观看,付费集可用金币按集解锁,解锁后永久有效。"},
{Question: "解锁过的剧集还需要再付费吗?", Answer: "不需要。已解锁的剧集可在登录后继续观看,无需重复付费。"},
{Question: "如何充值金币?", Answer: "登录后进入个人中心,选择金币充值,即可查看可用的充值方式。"},
{Question: "支持哪些支付方式?", Answer: "可用的支付方式会在充值页面列出,请以页面显示为准。"},
{Question: "忘记密码怎么办?", Answer: "请通过意见反馈联系我们,提交账号信息后由客服协助处理。"},
{Question: "内容多久更新一次?", Answer: "黄果短剧持续更新内容,欢迎关注首页的新剧和最近上新。"},
{Question: "如何联系客服或洽谈合作?", Answer: "请发送邮件至 support@huangguo.com,我们会尽快回复。"},
{Question: "如何注销账号?", Answer: "请通过意见反馈或客服邮箱提交注销申请。"},
},
})
}
+375
View File
@@ -0,0 +1,375 @@
package web
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type historyEntry struct {
ID int64
Title string
Current int
Total int
EpisodeText string
Genre string
Time string
Image string
Progress string
Kind string
URL string
sortAt time.Time
}
type historyGroup struct {
Title string
Entries []historyEntry
}
// History 观看历史页:短剧 GET /api/app/playlet/history/list + 剧集 GET /api/app/video/history/list。
// 文档:
// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_history_list
// http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_history_list
func History(c *gin.Context) {
page, validPage := accountPageNum(c)
if !validPage {
NotFound(c)
return
}
data := gin.H{"title": "观看历史 - 黄果短剧", "navActive": "history", "accountTab": "history"}
responses := gin.H{}
loadAccountSummary(c, data, responses)
pageSize := 20
var entries []historyEntry
var totalCount int64
if useragent.TokenFromRequest(c.Request) == "" {
responses["playlet/history/list"] = gin.H{"error": "暂无登录会话"}
responses["video/history/list"] = gin.H{"error": "暂无登录会话"}
} else {
playletQuery := playletser.HistoryListQuery{PageNum: page, PageSize: pageSize}
playletList, playletCode, playletErr := playletser.FetchHistoryList(c, playletQuery)
if retry, _ := authser.OnSessionExpired(c, playletCode); retry {
playletList, playletCode, playletErr = playletser.FetchHistoryList(c, playletQuery)
}
if playletErr == nil && playletList != nil {
responses["playlet/history/list"] = gin.H{"code": 200, "data": playletList}
entries = append(entries, playletHistoryEntries(playletList.List)...)
totalCount += playletList.TotalCount
} else if playletErr != nil {
responses["playlet/history/list"] = gin.H{"error": "加载失败", "msg": playletErr.Error()}
log.Error("fetch playlet history page failed", log.E(playletErr))
} else {
responses["playlet/history/list"] = gin.H{"code": playletCode, "data": playletList}
}
videoQuery := videoser.HistoryListQuery{PageNum: page, PageSize: pageSize}
videoList, videoCode, videoErr := videoser.FetchHistoryList(c, videoQuery)
if retry, _ := authser.OnSessionExpired(c, videoCode); retry {
videoList, videoCode, videoErr = videoser.FetchHistoryList(c, videoQuery)
}
if videoErr == nil && videoList != nil {
responses["video/history/list"] = gin.H{"code": 200, "data": videoList}
entries = append(entries, videoHistoryEntries(videoList.List)...)
totalCount += videoList.TotalCount
} else if videoErr != nil {
responses["video/history/list"] = gin.H{"error": "加载失败", "msg": videoErr.Error()}
log.Error("fetch video history page failed", log.E(videoErr))
} else {
responses["video/history/list"] = gin.H{"code": videoCode, "data": videoList}
}
if playletErr != nil && videoErr != nil {
data["historyUnavailable"] = true
}
}
data["paging"] = makeAccountPage("/history", page, pageSize, totalCount)
data["groups"] = groupHistoryEntries(entries)
data["historyResponse"] = responses["playlet/history/list"]
data["playletHistoryResponse"] = responses["playlet/history/list"]
data["videoHistoryResponse"] = responses["video/history/list"]
data["historyDebug"] = responses
HTMLOK(c, "history.html", data)
}
// HistoryClear BFF:代理 POST /api/app/playlet/history/clear。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_clear
func HistoryClear(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
env, err := playletser.HistoryClear(c)
if err != nil {
log.Error("clear playlet history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "清空失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
// VideoHistoryClear BFF:代理 POST /api/app/video/history/clear。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_clear
func VideoHistoryClear(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
env, err := videoser.HistoryClear(c)
if err != nil {
log.Error("clear video history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "清空失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "清空失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
// HistoryDelete BFF:代理 POST /api/app/playlet/history/delete。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_delete
func HistoryDelete(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
var body playletser.PlayletHistoryDeleteReq
if err := c.ShouldBindJSON(&body); err != nil || body.PlayletID <= 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "playletId required", "tip": "参数错误", "data": nil})
return
}
env, err := playletser.HistoryDelete(c, body)
if err != nil {
log.Error("delete playlet history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "删除失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
// VideoHistoryDelete BFF:代理 POST /api/app/video/history/delete。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_delete
func VideoHistoryDelete(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
var body videoser.VideoHistoryDeleteReq
if err := c.ShouldBindJSON(&body); err != nil || body.VideoID <= 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "videoId required", "tip": "参数错误", "data": nil})
return
}
env, err := videoser.HistoryDelete(c, body)
if err != nil {
log.Error("delete video history failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "empty response", "tip": "删除失败,请稍后重试", "data": nil})
return
}
if env.Code != 200 {
tip := strings.TrimSpace(env.Tip)
if tip == "" {
tip = strings.TrimSpace(env.Msg)
}
if tip == "" {
tip = "删除失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": tip, "data": env.Data})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "ok", "tip": "", "data": env.Data})
}
func playletHistoryEntries(items []playletser.PlayletHistoryData) []historyEntry {
out := make([]historyEntry, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.PlayName)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
continue
}
progress := 0
if item.TotalEpisode > 0 {
progress = item.WatchedCount * 100 / item.TotalEpisode
}
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
out = append(out, historyEntry{
ID: item.ID,
Title: title,
Current: item.LastEpisode,
Total: item.TotalEpisode,
Genre: strings.Join(item.ThemeNames, " "),
Time: playletser.FormatHistoryListTime(item.HistoryAt),
Image: profileCover(item.Cover),
Progress: fmt.Sprintf("%d%%", progress),
Kind: "short",
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
sortAt: historySortAt(item.HistoryAt),
})
}
return out
}
func videoHistoryEntries(items []videoser.VideoHistoryData) []historyEntry {
out := make([]historyEntry, 0, len(items))
for _, item := range items {
title := strings.TrimSpace(item.Title)
if title == "" {
continue
}
out = append(out, historyEntry{
ID: item.ID,
Title: title,
EpisodeText: videoHistoryEpisodeText(item),
Genre: strings.Join(item.Keyworld, " "),
Time: playletser.FormatHistoryListTime(item.HistoryAt),
Image: profileCover(item.CoverImg),
Progress: "",
Kind: "series",
URL: videoser.VideoPlayURL(item.Slug, item.ID),
sortAt: historySortAt(item.HistoryAt),
})
}
return out
}
func videoHistoryEpisodeText(item videoser.VideoHistoryData) string {
if item.VideoTime > 0 {
return formatVideoDuration(item.VideoTime)
}
return "剧集"
}
func formatVideoDuration(sec int) string {
if sec <= 0 {
return ""
}
h := sec / 3600
m := (sec % 3600) / 60
s := sec % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}
func historySortAt(raw string) time.Time {
t, ok := playletser.ParseHistoryTime(raw)
if !ok {
return time.Time{}
}
return t
}
func groupHistoryEntries(entries []historyEntry) []historyGroup {
if len(entries) == 0 {
return nil
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].sortAt.Equal(entries[j].sortAt) {
return entries[i].ID > entries[j].ID
}
return entries[i].sortAt.After(entries[j].sortAt)
})
order := []string{"今天", "昨天", "更早"}
buckets := map[string][]historyEntry{
"今天": {},
"昨天": {},
"更早": {},
}
for _, entry := range entries {
bucket := historyBucketFromTime(entry.sortAt)
buckets[bucket] = append(buckets[bucket], entry)
}
groups := make([]historyGroup, 0, 3)
for _, title := range order {
if len(buckets[title]) == 0 {
continue
}
groups = append(groups, historyGroup{Title: title, Entries: buckets[title]})
}
return groups
}
func historyBucketFromTime(t time.Time) string {
if t.IsZero() {
return "更早"
}
now := time.Now()
startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
dayDiff := int(startToday.Sub(startThat).Hours() / 24)
switch {
case dayDiff <= 0:
return "今天"
case dayDiff == 1:
return "昨天"
default:
return "更早"
}
}
+59
View File
@@ -0,0 +1,59 @@
package web
import (
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// Index 首页 SSR:拉取 GET /api/app/playlet/home,按接口字段注入模板。
// 接口文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home
// 继续观看:GET /api/app/playlet/history/list?pageNum=1&pageSize=6
// 热播榜:短剧 hot/rank/list;剧集 video/all/list sortType=最热
func Index(c *gin.Context) {
raw := playletser.FetchHomeOrEmpty(c)
title := "黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
history := playletser.FetchHistoryListOrEmpty(c, playletser.HistoryListQuery{PageNum: 1, PageSize: 6})
continueWatch := history.List
if len(continueWatch) > 6 {
continueWatch = continueWatch[:6]
}
hotPlaylets := playletser.FetchHotRankListOrEmpty(c, playletser.HotRankQuery{
PageNum: 1,
PageSize: 10,
RankType: 1,
}).List
if len(hotPlaylets) > 10 {
hotPlaylets = hotPlaylets[:10]
}
hotVideos := videoser.FetchVideoAllListOrEmpty(c, videoser.SeriesListQuery{
PageNum: 1,
PageSize: 10,
SortType: videoser.SortHeat,
MediaType: videoser.MediaAll,
}).List
if len(hotVideos) > 10 {
hotVideos = hotVideos[:10]
}
HTMLOK(c, "index.html", gin.H{
"title": title,
"navActive": "home",
"seoInfo": raw.SeoInfo,
"announcements": raw.Announcements,
"advitise": raw.Advitise,
"homeBannerData": playletser.ActiveHomeBanners(raw.HomeBannerData),
"homeConfigData": playletser.ActiveHomeConfigs(raw.HomeConfigData),
// GET /api/app/playlet/tag/options → list(已上架标签)
"tagOptions": playletser.FetchTagOptionsOrEmpty(c),
"continueWatch": continueWatch,
"hotRankPlaylets": hotPlaylets,
"hotRankVideos": hotVideos,
})
}
+100
View File
@@ -0,0 +1,100 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// List 短剧列表页:题材 + GET /api/app/playlet/all/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_all_list
func List(c *gin.Context) {
q := playletser.ParseAllListQuery(c)
themes := playletser.FetchThemesOrEmpty(c, 0)
playletser.ResolveAllListTheme(&q, themes)
themeResolved := listThemeResolved(q, themes)
raw := playletser.FetchAllListOrEmpty(c, q)
pageHeading, seoSelections := listSEOContext(q, themes)
title := pageHeading + " - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
var advitise any
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
advitise = raw.Advitise
}
HTMLOK(c, "list.html", gin.H{
"title": title,
"navActive": "list",
"seoInfo": seo,
"seoSelections": seoSelections,
"pageHeading": pageHeading,
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"advitise": advitise,
"themeList": themes,
"themeSlug": q.ThemeSlug,
"themeId": q.ThemeID,
"updateStatus": q.UpdateStatus,
"sortType": q.SortType,
"seoEntityValid": themeResolved || total > 0,
"pagination": playletser.BuildAllListPagination(q, total),
"canonicalPath": q.URL(),
})
}
func listThemeResolved(q playletser.AllListQuery, themes []playletser.PlayletThemeItem) bool {
if q.ThemeID <= 0 && q.ThemeSlug == "" {
return true
}
for _, theme := range themes {
if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) {
return true
}
}
return false
}
func listSEOContext(q playletser.AllListQuery, themes []playletser.PlayletThemeItem) (string, []playletser.SEOSelection) {
heading := "全部短剧"
selections := make([]playletser.SEOSelection, 0, 3)
themeName := q.ThemeSlug
for _, theme := range themes {
if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) {
themeName = theme.Name
break
}
}
if themeName != "" {
heading = themeName + "短剧"
selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: themeName})
}
if q.UpdateStatus == 1 {
selections = append(selections, playletser.SEOSelection{Dimension: "状态", Value: "连载中"})
} else if q.UpdateStatus == 2 {
selections = append(selections, playletser.SEOSelection{Dimension: "状态", Value: "已完结"})
}
if q.SortType == 2 {
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "最热"})
} else if q.SortType == 3 {
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"})
}
return heading, selections
}
+11
View File
@@ -0,0 +1,11 @@
package web
import "github.com/gin-gonic/gin"
// LoginPage H5 登录页:渲染 template/pages/login.html。
// PC 端由页面内脚本回首页并打开 loginPop;H5 直接展示本页。
func LoginPage(c *gin.Context) {
HTMLOK(c, "login.html", gin.H{
"title": "登录 - 黄果短剧",
})
}
+96
View File
@@ -0,0 +1,96 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// MoreList 首页配置更多页:GET /api/app/playlet/home/config/detail/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home_config_detail_list
func MoreList(c *gin.Context) {
q := playletser.ParseMoreListQuery(c)
themes := playletser.FetchThemesOrEmpty(c, 0)
playletser.ResolveMoreListTheme(&q, themes)
raw := playletser.FetchHomeConfigDetailListOrEmpty(c, q)
if raw != nil {
playletser.ApplyHomeConfigDetailIdentity(&q, raw)
}
home := playletser.FetchHomeOrEmpty(c)
homeConfigData := playletser.ActiveHomeConfigs(home.HomeConfigData)
name := "更多短剧"
desc := ""
entityName := ""
var seo playletser.SeoInfo
if raw != nil {
if raw.Name != "" {
name = raw.Name
entityName = raw.Name
}
desc = raw.Desc
seo = raw.SeoInfo
}
if config, ok := playletser.FindHomeConfigByIdentity(homeConfigData, q.HomeConfigID, q.HomeConfigSlug); ok {
seo = playletser.MergeSeoInfo(config.SeoInfo, seo)
if raw == nil || raw.Name == "" {
if config.Name != "" {
name = config.Name
entityName = config.Name
}
}
if desc == "" {
desc = config.Desc
}
}
title := name + " - 黄果短剧"
if seo.Title != "" {
title = seo.Title
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var advitise any
entityValid := false
if raw != nil {
list = raw.List
total = raw.TotalCount
advitise = raw.Advitise
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
entityValid = playletser.MoreListEntityValid(q, entityName, total)
}
HTMLOK(c, "moreList.html", gin.H{
"title": title,
"seoInfo": seo,
"routeConfigSEO": true,
"homeConfigData": homeConfigData,
"seoSelections": moreListSEOSelections(q, themes),
"navActive": "home",
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"configId": q.HomeConfigID,
"configSlug": q.HomeConfigSlug,
"configName": name,
"configDesc": desc,
"timeRange": q.TimeRange,
"themeSlug": q.ThemeSlug,
"themeId": q.ThemeID,
"seoEntityValid": entityValid,
"themeList": themes,
"timeTabs": playletser.BuildMoreTimeTabs(q),
"advitise": advitise,
"pagination": playletser.BuildMoreListPagination(q, total),
"canonicalPath": playletser.MoreListCanonicalPath(q),
})
}
+70
View File
@@ -0,0 +1,70 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// MoreSeriesList 首页影集配置更多页(type=2):GET /api/app/playlet/home/config/detail/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home_config_detail_list
func MoreSeriesList(c *gin.Context) {
q := playletser.ParseMoreListQuery(c)
themes := playletser.FetchThemesOrEmpty(c, 0)
playletser.ResolveMoreListTheme(&q, themes)
raw := playletser.FetchHomeConfigDetailListOrEmpty(c, q)
name := "更多视频"
desc := ""
if raw != nil {
if raw.Name != "" {
name = raw.Name
}
desc = raw.Desc
}
title := name + " - 黄果短剧"
list := []playletser.VideoItemResp(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var advitise any
entityValid := false
if raw != nil {
list = raw.VideoList
total = raw.TotalCount
advitise = raw.Advitise
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
playletser.ApplyHomeConfigDetailIdentity(&q, raw)
entityValid = playletser.MoreListEntityValid(q, raw.Name, total)
}
HTMLOK(c, "moreSeriesList.html", gin.H{
"title": title,
"seoSelections": moreListSEOSelections(q, themes),
"navActive": "home",
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"configId": q.HomeConfigID,
"configSlug": q.HomeConfigSlug,
"configName": name,
"configDesc": desc,
"timeRange": q.TimeRange,
"themeSlug": q.ThemeSlug,
"themeId": q.ThemeID,
"seoEntityValid": entityValid,
"themeList": themes,
"timeTabs": playletser.BuildMoreSeriesTimeTabs(q),
"advitise": advitise,
"pagination": playletser.BuildMoreSeriesListPagination(q, total),
"canonicalPath": playletser.MoreSeriesListCanonicalPath(q),
})
}
+15
View File
@@ -0,0 +1,15 @@
package web
import (
"github.com/gin-gonic/gin"
)
// Nav 官方导航页 SSR:直接使用 config/info 注入的 appConfig。
func Nav(c *gin.Context) {
HTMLOK(c, "nav.html", gin.H{
"title": "黄果短剧 · 官方导航",
"isNavPage": true,
"pageKey": "nav",
"pageName": "官方导航",
})
}
+14
View File
@@ -0,0 +1,14 @@
package web
import (
"net/http"
"github.com/gin-gonic/gin"
)
// NotFound 404 页面:渲染 template/pages/404.html。
func NotFound(c *gin.Context) {
HTML(c, http.StatusNotFound, "404.html", gin.H{
"title": "页面不存在 - 黄果短剧",
})
}
+8
View File
@@ -0,0 +1,8 @@
package web
import "videoweb/app/service/playletser"
// mockPagination 为静态稿页生成分页数据;pageURL 暂统一为 #。
func mockPagination(page, pageSize int, total int64) playletser.PaginationVM {
return playletser.BuildPagination(func(int) string { return "#" }, page, pageSize, total)
}
+388
View File
@@ -0,0 +1,388 @@
package web
import (
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/commentser"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// Play 播放页:GET /api/app/playlet/detail + GET /api/app/playlet/chapter/detail。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_detail
func Play(c *gin.Context) {
if c.Request.URL.Query().Has("guessPage") {
NotFound(c)
return
}
q := playletser.ParsePlayQuery(c.Param("slug"), c.Param("ep"))
if strings.TrimSpace(c.Param("ep")) != "" && q.ChapterID <= 0 {
NotFound(c)
return
}
playletKey := c.Param("slug")
playlet := playletser.FetchDetailOrNilByKey(c, playletKey)
if playlet == nil {
NotFound(c)
return
}
chapterID := playletser.ResolvePlayChapterID(playlet, q.ChapterID)
listedChapter := playletser.FindChapter(playlet, chapterID)
if chapterID <= 0 || (q.ChapterID > 0 && listedChapter == nil) {
NotFound(c)
return
}
chapter := playletser.FetchChapterDetailOrNil(c, chapterID, playlet.ID, playlet.Slug)
if chapter == nil {
if listedChapter != nil {
chapter = playletser.ChapterFromList(*listedChapter)
}
}
if chapter == nil {
NotFound(c)
return
}
seo := playletser.ResolvePlayPageSEO(chapter, playlet)
title := playletser.ResolvePlayPageTitle(chapter, playlet)
if title == "" {
title = "在线播放 - 黄果短剧"
}
hot := playletser.FetchAllListOrEmpty(c, playletser.AllListQuery{
PageNum: 1,
PageSize: 8,
SortType: 2, // 最热
UpdateStatus: 0, // 全部
})
hotList := []playletser.CommonData(nil)
if hot != nil {
hotList = hot.List
}
guessPage := playletser.ParseGuessLikePage(c)
guessLike := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
PlayletID: playlet.ID,
PageNum: guessPage,
PageSize: 6,
})
if len(guessLike.List) == 0 && guessPage > 1 {
guessLike = playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
PlayletID: playlet.ID,
PageNum: 1,
PageSize: 6,
})
}
commentObjectID := playlet.ID
commentObjectType := commentser.ObjectTypePlaylet
if chapterID > 0 {
commentObjectID = chapterID
commentObjectType = commentser.ObjectTypeChapter
}
comments := commentser.FetchListOrEmpty(c, commentser.ListQuery{
ObjectID: commentObjectID,
ObjectType: commentObjectType,
PageNum: 1,
PageSize: 4,
SortOrder: commentser.SortHot,
})
unlockCost := int64(0)
balance := int64(0)
canWatch := false
episode := 0
if chapter != nil {
unlockCost = chapter.UnlockCost()
balance = chapter.Balance
canWatch = chapter.AllowWatch()
episode = chapter.CurrentEpisode
}
if episode <= 0 {
for i, item := range playlet.PlayletChapterInfo {
if item.ID != chapterID {
continue
}
episode = item.CurrentEpisode
if episode <= 0 {
episode = i + 1
}
break
}
}
if episode <= 0 {
episode = 1
}
if unlockCost <= 0 {
unlockCost = int64(playletser.FirstUnlockCost(playlet.PlayletChapterInfo))
}
previewURL := strings.TrimSpace(playlet.Preview)
mainURL := ""
if canWatch && chapter != nil {
mainURL = strings.TrimSpace(chapter.VideoURL)
}
data := gin.H{
"title": title,
"navActive": "list",
"seoInfo": seo,
"playlet": playlet,
"chapter": chapter,
"chapterID": chapterID,
"episode": episode,
"canWatch": canWatch,
"mainURL": mainURL,
"previewURL": previewURL,
"hasPreview": previewURL != "",
"unlockCost": unlockCost,
"balance": balance,
"balanceText": authser.FormatRecharge(balance),
"nextChapterID": playletser.NextChapterID(playlet, chapterID),
"advitise": playlet.Advitise,
"related": playlet.RelatedRecommendData,
"guessLike": guessLike.List,
"guessPage": guessLike.PageNum,
"guessPageSize": guessLike.PageSize,
"guessTotal": guessLike.TotalCount,
"hotList": hotList,
"hotMoreURL": playletser.AllListURL("all", 0, 0, 2, 1),
"comments": comments.List,
"commentTotal": comments.TotalCount,
"commentPage": comments.PageNum,
"commentSize": comments.PageSize,
"commentObjectID": commentObjectID,
"commentObjectType": commentObjectType,
"trackerDrama": dramaTrackingData(playlet, chapter, chapterID, episode),
"playResponse": gin.H{
"playlet": playlet,
"chapter": chapter,
"chapterID": chapterID,
"canWatch": canWatch,
"mainURL": mainURL,
"previewURL": previewURL,
"hasPreview": previewURL != "",
"guessLike": guessLike,
"hotList": hotList,
"comments": comments,
"commentObjectID": commentObjectID,
"commentObjectType": commentObjectType,
},
}
data["canonicalPath"] = playletser.PlayPath(playlet.Slug, playlet.ID)
attachRechargeModal(c, data)
HTMLOK(c, "play.html", data)
}
func dramaTrackingData(playlet *playletser.PlayletInfoResp, chapter *playletser.PlayletChapterInfoResp, chapterID int64, episode int) gin.H {
name := strings.TrimSpace(playlet.PlayName)
if name == "" {
name = strings.TrimSpace(playlet.Title)
}
totalEpisode := playlet.TotalEpisode
if totalEpisode <= 0 {
totalEpisode = len(playlet.PlayletChapterInfo)
}
if totalEpisode <= 0 {
totalEpisode = 1
}
data := gin.H{
"MediaID": "",
"DramaID": playlet.ID,
"DramaTitle": name,
"Episode": max(1, episode),
"TotalEpisode": totalEpisode,
"VideoID": chapterID,
"DramaTypeID": "default",
"DramaTypeName": "默认分类",
"DramaTagKey": "default",
"DramaTagName": "默认标签",
"DramaDuration": 0,
"RecommendTraceID": "",
}
if chapter != nil {
if chapter.ID > 0 {
data["VideoID"] = chapter.ID
}
if chapter.MediaTime > 0 {
data["DramaDuration"] = chapter.MediaTime
}
}
for i, id := range playlet.ThemeIDs {
if id <= 0 || i >= len(playlet.ThemeNames) {
continue
}
name := strings.TrimSpace(playlet.ThemeNames[i])
if name == "" {
continue
}
data["DramaTypeID"] = strconv.FormatInt(id, 10)
data["DramaTypeName"] = name
break
}
tagIDs := make([]string, 0, len(playlet.TagIDs))
tagNames := make([]string, 0, len(playlet.TagIDs))
for i, id := range playlet.TagIDs {
if id <= 0 || i >= len(playlet.TagNames) {
continue
}
name := strings.TrimSpace(playlet.TagNames[i])
if name == "" {
continue
}
tagIDs = append(tagIDs, strconv.FormatInt(id, 10))
tagNames = append(tagNames, name)
}
if len(tagIDs) > 0 {
data["DramaTagKey"] = strings.Join(tagIDs, ",")
data["DramaTagName"] = strings.Join(tagNames, ",")
}
return data
}
// SeriesPlay 视频播放页:GET /api/app/video/detail。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_detail
func SeriesPlay(c *gin.Context) {
if c.Request.URL.Query().Has("guessPage") {
NotFound(c)
return
}
key := c.Param("slug")
video := videoser.FetchVideoDetailOrNilByKey(c, key)
if video == nil {
NotFound(c)
return
}
title := videoser.ResolveVideoPageTitle(video)
if title == "" {
title = "在线播放 - 黄果短剧"
} else {
title = title + " - 黄果短剧"
}
cover := video.CoverImg
if cover == "" {
cover = video.Video.Cover
}
videoURL := strings.TrimSpace(video.Video.URL)
canWatch := videoURL != ""
commentObjectID := video.ID
// 视频评论:objectType=20(swagger:10-短剧 11-短剧剧集 20-视频)
commentObjectType := commentser.ObjectTypeVideo
comments := commentser.FetchListOrEmpty(c, commentser.ListQuery{
ObjectID: commentObjectID,
ObjectType: commentObjectType,
PageNum: 1,
PageSize: 4,
SortOrder: commentser.SortHot,
})
if comments.PageNum <= 0 {
comments.PageNum = 1
}
if comments.PageSize <= 0 {
comments.PageSize = 4
}
commentTotal := comments.TotalCount
if commentTotal <= 0 && video.CountComment > 0 {
commentTotal = video.CountComment
}
hot := playletser.FetchAllListOrEmpty(c, playletser.AllListQuery{
PageNum: 1,
PageSize: 8,
SortType: 2, // 最热
UpdateStatus: 0, // 全部
})
hotList := []playletser.CommonData(nil)
if hot != nil {
hotList = hot.List
}
// 视频详情无 recommendVideos:猜你喜欢与 play.html 一致,走短剧同题材推荐。
// 以热榜首部短剧为种子 playletId(排除该剧本身)。
guessPlayletID := int64(0)
if len(hotList) > 0 {
guessPlayletID = hotList[0].ID
}
guessPage := playletser.ParseGuessLikePage(c)
guessLike := playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
PlayletID: guessPlayletID,
PageNum: guessPage,
PageSize: 6,
})
if len(guessLike.List) == 0 && guessPage > 1 {
guessLike = playletser.FetchGuessLikeListOrEmpty(c, playletser.GuessLikeQuery{
PlayletID: guessPlayletID,
PageNum: 1,
PageSize: 6,
})
}
data := gin.H{
"title": title,
"navActive": "aiSeries",
"seoInfo": video.SeoInfo,
"video": video,
"videoID": video.ID,
"videoSlug": video.Slug,
"cover": cover,
"videoURL": videoURL,
"canWatch": canWatch,
"guessLike": guessLike.List,
"guessPage": guessLike.PageNum,
"guessPageSize": guessLike.PageSize,
"guessTotal": guessLike.TotalCount,
"guessPlayletID": guessPlayletID,
"advitise": video.Advitise,
"comments": comments.List,
"commentTotal": commentTotal,
"commentPage": comments.PageNum,
"commentSize": comments.PageSize,
"commentObjectID": commentObjectID,
"commentObjectType": commentObjectType,
"hotList": hotList,
"hotMoreURL": playletser.AllListURL("all", 0, 0, 2, 1),
"videoResponse": gin.H{
"video": video,
"videoURL": videoURL,
"canWatch": canWatch,
"comments": comments,
"hotList": hotList,
"guessLike": guessLike,
"guessPlayletID": guessPlayletID,
"commentObjectID": commentObjectID,
"commentObjectType": commentObjectType,
},
"commentDebug": gin.H{
"path": "/api/app/comment/list",
"objectId": commentObjectID,
"objectType": commentObjectType, // 20-视频
"pageNum": comments.PageNum,
"pageSize": comments.PageSize,
"totalCount": commentTotal,
"list": comments.List,
"apis": []string{
"GET /api/app/comment/list",
"POST /api/app/comment/send",
"POST /api/app/comment/like/set",
},
},
"playDebug": gin.H{
"canWatch": canWatch,
"videoURL": videoURL,
"cover": cover,
"videoTime": video.Video.VideoTime,
},
}
if strings.TrimSpace(c.Param("guessPage")) != "" {
data["canonicalPath"] = videoser.VideoPlayURL(video.Slug, video.ID)
}
attachRechargeModal(c, data)
HTMLOK(c, "seriesPlay.html", data)
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// PlayletLikeSet BFF:代理 POST /api/app/playlet/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_like_set
func PlayletLikeSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isLike required",
"tip": "缺少短剧 ID 或点赞状态",
"data": nil,
})
return
}
env, err := playletser.LikeSet(c, playletser.PlayletLikeSetReq{
ID: body.ID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"net/http"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// PlayletCollectSet BFF:代理 POST /api/app/playlet/collect/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_collect_set
func PlayletCollectSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsCollect *bool `json:"isCollect"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isCollect required",
"tip": "缺少短剧 ID 或收藏状态",
"data": nil,
})
return
}
env, err := playletser.CollectSet(c, playletser.PlayletCollectSetReq{
ID: body.ID,
IsCollect: *body.IsCollect,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// PopularRecommend 热播榜页:GET /api/app/playlet/hot/rank/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_hot_rank_list
func PopularRecommend(c *gin.Context) {
q := playletser.ParseHotRankQuery(c)
raw := playletser.FetchHotRankListOrEmpty(c, q)
tabTitle := playletser.HotRankTabTitle(q.RankType)
title := tabTitle + " - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
HTMLOK(c, "popularRecommend.html", gin.H{
"title": title,
"navActive": "popular",
"seoInfo": seo,
"seoSelections": rankingSEOSelections(tabTitle),
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"rankType": q.RankType,
"tabTitle": tabTitle,
"rankTabs": playletser.BuildHotRankTabs(q),
"listCount": fmt.Sprintf("共 %d 部", total),
"pagination": playletser.BuildHotRankPagination(q, total),
"canonicalPath": playletser.HotRankCanonicalPath(q),
})
}
+8
View File
@@ -0,0 +1,8 @@
package web
import "github.com/gin-gonic/gin"
// Privacy renders the static Privacy Policy page.
func Privacy(c *gin.Context) {
HTMLOK(c, "privacy.html", gin.H{})
}
+229
View File
@@ -0,0 +1,229 @@
package web
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/app/service/usercenterser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type profileWork struct {
Title, Meta, Episodes, Badge, Image, Progress, Remaining, URL string
}
// Profile 从当前会话拉取用户信息、观看历史和收藏,不展示演示账号数据。
func Profile(c *gin.Context) {
data := gin.H{
"title": "个人中心 - 黄果短剧", "navActive": "profile", "accountTab": "profile",
}
responses := gin.H{}
loadAccountSummary(c, data, responses)
if useragent.TokenFromRequest(c.Request) != "" {
fetchProfileLists(c, data, responses)
}
if pretty, err := json.MarshalIndent(responses, "", " "); err == nil {
data["responseJSON"] = string(pretty)
}
HTMLOK(c, "profile.html", data)
}
func loadAccountSummary(c *gin.Context, data, responses gin.H) {
data["userName"] = "未登录"
data["balance"] = "—"
data["historyCount"] = "—"
data["favoriteCount"] = "—"
data["chasingCount"] = "—"
data["boughtChapterCount"] = "—"
user := authser.SyncUserOnEnter(c)
if user == nil {
if useragent.TokenFromRequest(c.Request) != "" {
responses["auth/getInfo"] = gin.H{"error": "加载失败"}
fetchProfileStats(c, data, responses)
return
}
responses["auth/getInfo"] = gin.H{"error": "暂无登录会话"}
return
}
applyProfileInfo(data, responses, user)
if useragent.TokenFromRequest(c.Request) != "" {
fetchProfileStats(c, data, responses)
}
}
func fetchProfileStats(c *gin.Context, data, responses gin.H) {
stats, code, err := usercenterser.Fetch(c)
if retry, _ := authser.OnSessionExpired(c, code); retry {
stats, code, err = usercenterser.Fetch(c)
}
if err != nil || stats == nil {
responses["user/center/data"] = gin.H{"error": "加载失败"}
if err != nil {
log.Error("fetch user center data failed", log.E(err))
}
return
}
responses["user/center/data"] = gin.H{"code": code, "data": stats}
data["historyCount"] = fmt.Sprint(stats.WatchCount)
data["favoriteCount"] = fmt.Sprint(stats.CollectCount)
data["chasingCount"] = fmt.Sprint(stats.ChasingCount)
data["boughtChapterCount"] = fmt.Sprint(stats.BoughtChapterCount)
}
func fetchProfileLists(c *gin.Context, data, responses gin.H) {
query := playletser.HistoryListQuery{PageNum: 1, PageSize: 6}
history, code, err := playletser.FetchHistoryList(c, query)
if retry, _ := authser.OnSessionExpired(c, code); retry {
history, _, err = playletser.FetchHistoryList(c, query)
}
if err == nil && history != nil {
responses["playlet/history/list"] = gin.H{"code": 200, "data": history}
data["history"] = profileHistoryWorks(history.List)
} else if err != nil {
responses["playlet/history/list"] = gin.H{"error": "加载失败"}
log.Error("fetch profile history failed", log.E(err))
data["historyUnavailable"] = true
}
collectQuery := playletser.CollectListQuery{PageNum: 1, PageSize: 6, WatchStatus: 0}
favorites, code, err := playletser.FetchCollectList(c, collectQuery)
if retry, _ := authser.OnSessionExpired(c, code); retry {
favorites, _, err = playletser.FetchCollectList(c, collectQuery)
}
if err == nil && favorites != nil {
responses["playlet/collect/list"] = gin.H{"code": 200, "data": favorites}
data["favorites"] = profileFavoriteWorks(favorites.List)
} else if err != nil {
responses["playlet/collect/list"] = gin.H{"error": "加载失败"}
log.Error("fetch profile favorites failed", log.E(err))
data["favoritesUnavailable"] = true
}
}
func applyProfileInfo(data, responses gin.H, user *authser.LoginResp) {
if user == nil {
return
}
// 登录响应可能包含 token;调试区域只展示非凭证字段。
rawUser, _ := json.Marshal(user)
var safeUser map[string]any
_ = json.Unmarshal(rawUser, &safeUser)
delete(safeUser, "token")
responses["auth/getInfo"] = gin.H{"code": 200, "data": safeUser}
name := strings.TrimSpace(user.NickName)
if name == "" {
name = strings.TrimSpace(user.AccountNo)
}
if name == "" {
name = "用户"
}
data["userName"] = name
data["setting"] = authser.BuildSettingVM(user)
data["userID"] = user.UserID
data["userId"] = fmt.Sprint(user.UserID)
data["balance"] = fmt.Sprint(user.Recharge)
data["recharge"] = user.Recharge
data["rechargeText"] = authser.FormatRecharge(user.Recharge)
if avatar := safeProfileImage(user.Avatar); avatar != "" {
data["avatar"] = avatar
}
}
func safeProfileImage(raw string) string {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "/static/") {
return raw
}
u, err := url.Parse(raw)
if err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" {
return raw
}
return ""
}
func profileHistoryWorks(items []playletser.PlayletHistoryData) []profileWork {
works := make([]profileWork, 0, len(items))
for _, item := range items {
if len(works) == 6 {
break
}
name := strings.TrimSpace(item.Title)
if name == "" {
name = strings.TrimSpace(item.PlayName)
}
if name == "" {
continue
}
progress := 0
if item.TotalEpisode > 0 {
progress = item.WatchedCount * 100 / item.TotalEpisode
}
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
meta := "观看记录"
if item.LastEpisode > 0 {
meta = fmt.Sprintf("看至第 %d 集", item.LastEpisode)
}
remaining := ""
if item.UnwatchedCount > 0 {
remaining = fmt.Sprintf("剩 %d 集", item.UnwatchedCount)
}
works = append(works, profileWork{Title: name, Meta: meta, Image: profileCover(item.Cover), Progress: fmt.Sprintf("%d%%", progress), Remaining: remaining, URL: profilePlayURL(item.Slug, item.ID, item.LastEpisode)})
}
return works
}
func profileFavoriteWorks(items []playletser.PlayletCollectData) []profileWork {
works := make([]profileWork, 0, len(items))
for _, item := range items {
if len(works) == 6 {
break
}
name := strings.TrimSpace(item.Title)
if name == "" {
name = strings.TrimSpace(item.PlayName)
}
if name == "" {
continue
}
work := profileWork{Title: name, Meta: strings.Join(item.ThemeNames, " "), Image: profileCover(item.Cover), URL: profilePlayURL(item.Slug, item.ID, 1)}
if item.TotalEpisode > 0 {
work.Episodes = fmt.Sprintf("全 %d 集", item.TotalEpisode)
}
if item.IsFeatured {
work.Badge = "爆剧"
}
works = append(works, work)
}
return works
}
func profileCover(raw string) string {
if safe := safeProfileImage(raw); safe != "" {
return safe
}
return "/static/images/placeholder.png?v=2"
}
func profilePlayURL(slug string, id int64, episode int) string {
if slug == "" {
if id <= 0 {
return ""
}
slug = fmt.Sprint(id)
}
if episode < 1 {
episode = 1
}
return "/play/" + url.PathEscape(slug) + "/" + fmt.Sprint(episode)
}
+87
View File
@@ -0,0 +1,87 @@
package web
import (
"fmt"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/playletser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
type purchasedWork struct {
Title, Image, Info, Date, Progress, URL string
}
// PurchasedSeries 已购剧集:GET /api/app/playlet/bought/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_bought_list
func PurchasedSeries(c *gin.Context) {
page, validPage := accountPageNum(c)
if !validPage {
NotFound(c)
return
}
data := gin.H{"title": "已购剧集 - 黄果短剧", "navActive": "purchased", "accountTab": "purchased"}
responses := gin.H{}
loadAccountSummary(c, data, responses)
size := 20
if useragent.TokenFromRequest(c.Request) == "" {
responses["playlet/bought/list"] = gin.H{"error": "暂无登录会话"}
data["purchasedResponse"] = responses["playlet/bought/list"]
HTMLOK(c, "purchased.html", data)
return
}
list, code, err := playletser.FetchBoughtList(c, page, size)
if retry, _ := authser.OnSessionExpired(c, code); retry {
list, _, err = playletser.FetchBoughtList(c, page, size)
}
if err == nil && list != nil {
responses["playlet/bought/list"] = gin.H{"code": 200, "data": list}
data["paging"] = makeAccountPage("/purchased", page, size, list.TotalCount)
works := make([]purchasedWork, 0, len(list.List))
for _, item := range list.List {
title := strings.TrimSpace(item.PlayName)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
continue
}
info := fmt.Sprintf("已购 %d 集", item.BoughtChapterCount)
if item.TotalEpisode > 0 {
info += fmt.Sprintf(" / 共 %d 集", item.TotalEpisode)
}
info += fmt.Sprintf(" · %d 金币", item.CostCoins)
progress := 0
if item.TotalEpisode > 0 {
progress = item.WatchedCount * 100 / item.TotalEpisode
}
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
works = append(works, purchasedWork{
Title: title, Image: profileCover(item.Cover), Info: info,
Date: playletser.FormatHistoryAt(item.BoughtAt), Progress: fmt.Sprintf("%d%%", progress),
URL: playletser.ChapterPlayPath(item.Slug, item.ID, item.LastChapterID),
})
}
data["purchased"] = works
} else if err != nil {
responses["playlet/bought/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
log.Error("fetch bought playlets failed", log.E(err))
data["purchasedUnavailable"] = true
} else {
responses["playlet/bought/list"] = gin.H{"code": code, "data": list}
}
data["purchasedResponse"] = responses["playlet/bought/list"]
HTMLOK(c, "purchased.html", data)
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Rank 排行榜页:GET /api/app/playlet/rank。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_rank
func Rank(c *gin.Context) {
q := playletser.ParseRankQuery(c)
raw := playletser.FetchRankOrEmpty(c, q)
tabTitle := playletser.RankTabTitle(q.SortOrder)
title := tabTitle + " - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
HTMLOK(c, "rank.html", gin.H{
"title": title,
"navActive": "rank",
"seoInfo": seo,
"seoSelections": rankingSEOSelections(tabTitle),
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"sortOrder": q.SortOrder,
"tabTitle": tabTitle,
"rankTabs": playletser.BuildRankTabs(q),
"rankOffset": playletser.RankOffset(q),
"isBinge": playletser.IsRankBinge(q.SortOrder),
"pagination": playletser.BuildRankPagination(q, total),
"canonicalPath": playletser.RankCanonicalPath(q),
})
}
+48
View File
@@ -0,0 +1,48 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// RecentlyAdded 最近上新页:GET /api/app/playlet/today-update。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_today_update
func RecentlyAdded(c *gin.Context) {
q := playletser.ParseTodayUpdateQuery(c.Param("page"))
raw := playletser.FetchTodayUpdateOrEmpty(c, q)
title := "最近上新 - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
HTMLOK(c, "recentlyAdded.html", gin.H{
"title": title,
"navActive": "todayUpdate",
"seoInfo": seo,
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"pagination": playletser.BuildTodayUpdatePagination(q, total),
"canonicalPath": playletser.TodayUpdateCanonicalPath(q),
})
}
+279
View File
@@ -0,0 +1,279 @@
package web
import (
"net/http"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/rechargeser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const rechargePageSize = 5
// Recharge 金币充值页:套餐 GET /api/app/recharge/package/list,购买 POST /api/app/coin/buy,流水 GET /api/app/coin/trans/list。
func Recharge(c *gin.Context) {
data := gin.H{
"title": "金币充值 - 黄果短剧", "pageHeading": "金币充值",
"backHref": "/profile", "helpHref": "/help",
}
responses := gin.H{}
loadAccountSummary(c, data, responses)
packages, err := rechargeser.FetchPackageList(c)
if err != nil {
log.Error("fetch recharge packages failed", log.E(err))
data["packageUnavailable"] = true
packages = &rechargeser.RechargePackageListResp{}
responses["recharge/package/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
} else {
responses["recharge/package/list"] = gin.H{"code": 200, "data": packages}
}
vm := rechargeser.BuildDepositPageVM(packages, "coin")
data["packages"] = vm.CoinList
data["paymentMethods"] = vm.PayMethods
data["rechargePackageResponse"] = packages
balance := authser.RechargeFromCookies(c)
if _, ok := data["recharge"]; ok {
balance = toInt64(data["recharge"])
}
data["balance"] = balance
data["balanceText"] = authser.FormatRecharge(balance)
page, _, err := fetchRechargeTransactions(c, 1, rechargePageSize, 0)
if err != nil {
log.Error("fetch recharge transactions failed", log.E(err))
data["transactionsUnavailable"] = true
page = &rechargeser.CoinTransPage{PageNum: 1, PageSize: rechargePageSize}
responses["coin/trans/list"] = gin.H{"error": "加载失败", "msg": err.Error()}
} else {
responses["coin/trans/list"] = gin.H{"code": 200, "data": page}
}
data["transactions"] = rechargeser.BuildTxListFromCoinTrans(page.List, 0)
data["transactionPage"] = page
data["transactionResponse"] = page
data["rechargeDebug"] = responses
HTMLOK(c, "recharge.html", data)
}
// RechargeCoinBuy BFF:代理 POST /api/app/coin/buy。
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/post_api_app_coin_buy
func RechargeCoinBuy(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
var body rechargeser.BuyPayReq
if err := c.ShouldBindJSON(&body); err != nil || body.PackageID <= 0 {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "packageId required", "tip": "请选择充值套餐", "data": nil})
return
}
body.PayMode = rechargeser.NormalizePayMode(body.PayMode)
if body.PayMode == "" {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "payMode required", "tip": "请选择支付方式", "data": nil})
return
}
if !validCoinPackagePayment(c, body.PackageID, body.PayMode) {
c.JSON(http.StatusOK, gin.H{"code": 400, "msg": "invalid package/payMode", "tip": "套餐或支付方式不可用,请刷新后重试", "data": nil})
return
}
env, err := rechargeser.BuyCoin(c, body)
if err != nil {
log.Error("buy coin failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "创建支付订单失败,请稍后重试", "data": nil})
return
}
if env != nil {
if retry, needLogin := authser.OnSessionExpired(c, env.Code); needLogin {
c.JSON(http.StatusOK, gin.H{"code": env.Code, "msg": env.Msg, "tip": "登录已过期,请重新登录", "data": nil})
return
} else if retry {
env, err = rechargeser.BuyCoin(c, body)
if err != nil {
log.Error("buy coin retry failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "创建支付订单失败,请稍后重试", "data": nil})
return
}
}
}
if env == nil || env.Code != 200 {
code, tip, msg := 500, "创建支付订单失败", "buy failed"
if env != nil {
code = env.Code
msg = env.Msg
tip = firstNonEmptyAuth(env.Tip, env.Msg, tip)
}
c.JSON(http.StatusOK, gin.H{"code": code, "msg": msg, "tip": tip, "data": nil})
return
}
safeURL := safePaymentURL(env.Data.PayURL)
if safeURL == "" {
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": "invalid payUrl", "tip": "支付地址无效,请稍后重试", "data": nil})
return
}
env.Data.PayURL = safeURL
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": env.Msg,
"tip": firstNonEmptyAuth(env.Tip, "订单创建成功"),
"data": env.Data,
})
}
// RechargeTransactions BFF:代理 GET /api/app/coin/trans/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/get_api_app_coin_trans_list
// query:page / pageSize / type(0全部 1充值 2消费 3奖励)
func RechargeTransactions(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
page := queryInt(c, "page", 1)
pageSize := queryInt(c, "pageSize", rechargePageSize)
typ := queryIntAllowZero(c, "type", 0)
if typ < 0 || typ > 3 {
typ = 0
}
result, code, err := fetchRechargeTransactions(c, page, pageSize, typ)
if err != nil {
log.Error("fetch recharge transactions failed", log.E(err))
tip := "交易明细加载失败"
if code > 0 {
c.JSON(http.StatusOK, gin.H{"code": code, "msg": err.Error(), "tip": tip, "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": tip, "data": nil})
return
}
if result == nil {
result = &rechargeser.CoinTransPage{PageNum: page, PageSize: pageSize}
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "ok",
"tip": "",
"data": gin.H{
"list": rechargeser.BuildTxListFromCoinTrans(result.List, 0),
"rawList": result.List,
"pageNum": result.PageNum,
"pageSize": result.PageSize,
"totalCount": result.TotalCount,
},
})
}
// RechargeCoinTransExport BFF:代理 GET /api/app/coin/trans/export。
// 文档:http://172.104.181.99:25319/swagger/index.html#/金币/get_api_app_coin_trans_export
// query:type(0全部 1充值 2消费 3奖励),返回 CSV。
func RechargeCoinTransExport(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusOK, gin.H{"code": 401, "msg": "unauthorized", "tip": "请先登录", "data": nil})
return
}
typ := queryIntAllowZero(c, "type", 0)
if typ < 0 || typ > 3 {
typ = 0
}
result, err := rechargeser.FetchCoinTransExport(c, typ)
if err != nil {
log.Error("export coin trans failed", log.E(err))
c.JSON(http.StatusOK, gin.H{"code": 500, "msg": err.Error(), "tip": "导出失败,请稍后重试", "data": nil})
return
}
ct := strings.TrimSpace(result.ContentType)
if ct == "" || strings.Contains(strings.ToLower(ct), "json") {
ct = "text/csv; charset=utf-8"
}
disposition := strings.TrimSpace(result.ContentDisposition)
if disposition == "" {
disposition = `attachment; filename="coin-trans.csv"`
}
c.Header("Content-Type", ct)
c.Header("Content-Disposition", disposition)
c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, ct, result.Body)
}
func fetchRechargeTransactions(c *gin.Context, page, pageSize, typ int) (*rechargeser.CoinTransPage, int, error) {
result, code, err := rechargeser.FetchCoinTransPage(c, page, pageSize, typ)
if retry, _ := authser.OnSessionExpired(c, code); retry {
result, code, err = rechargeser.FetchCoinTransPage(c, page, pageSize, typ)
}
return result, code, err
}
// attachRechargeModal 注入充值弹窗数据:GET /api/app/recharge/package/list → coinList。
func attachRechargeModal(c *gin.Context, data gin.H) {
if data == nil {
return
}
packages := rechargeser.FetchPackageListOrEmpty(c)
vm := rechargeser.BuildDepositPageVM(packages, "coin")
data["rechargePackages"] = vm.CoinList
data["rechargePayMethods"] = vm.PayMethods
data["rechargePackageResponse"] = packages
if _, ok := data["unlockCost"]; !ok {
data["unlockCost"] = int64(0)
}
if _, ok := data["balance"]; !ok {
data["balance"] = authser.RechargeFromCookies(c)
}
if _, ok := data["balanceText"]; !ok {
data["balanceText"] = authser.FormatRecharge(toInt64(data["balance"]))
}
}
func validCoinPackagePayment(c *gin.Context, packageID int64, payMode string) bool {
data, err := rechargeser.FetchPackageList(c)
if err != nil || data == nil {
return false
}
for _, item := range data.CoinList {
if item.ID != packageID {
continue
}
for _, method := range item.PayMethods {
if rechargeser.NormalizePayMode(method.Type) == payMode {
return true
}
}
return false
}
return false
}
func safePaymentURL(raw string) string {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return ""
}
return raw
}
func queryInt(c *gin.Context, name string, fallback int) int {
v, err := strconv.Atoi(strings.TrimSpace(c.Query(name)))
if err != nil || v <= 0 {
return fallback
}
return v
}
// queryIntAllowZero 允许 0(如 type=0 全部)。
func queryIntAllowZero(c *gin.Context, name string, fallback int) int {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return v
}
+11
View File
@@ -0,0 +1,11 @@
package web
import "github.com/gin-gonic/gin"
// RegisterPage H5 注册页:渲染 template/pages/register.html。
// PC 端由页面内脚本回首页并打开 registerPop;H5 直接展示本页。
func RegisterPage(c *gin.Context) {
HTMLOK(c, "register.html", gin.H{
"title": "注册账号 - 黄果短剧",
})
}
+660
View File
@@ -0,0 +1,660 @@
package web
import (
"net/http"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/configser"
"videoweb/app/service/playletser"
"videoweb/app/service/seoser"
"videoweb/app/service/translationser"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// HTML 渲染页面并注入登录态(以 cookie 为准)。
// 无 token 时自动游客登录(POST /api/app/auth/guest),保证默认进入即有会话。
func HTML(c *gin.Context, code int, name string, data gin.H) {
if data == nil {
data = gin.H{}
}
// 所有服务端页面先完成渠道首次归因;已有会话也不会漏记落地页渠道。
if channel := authser.ResolveChannel(c, ""); channel != "" {
data["channel"] = channel
}
authser.SyncUserOnEnter(c)
loggedIn, userName, avatar, email := authser.AuthFromCookies(c)
registered := authser.IsRegisteredFromCookies(c)
data["isLoggedIn"] = loggedIn
data["isRegistered"] = registered
// 已登录(含游客)展示用户菜单;未登录才展示「登录」按钮。
if _, ok := data["guestHeader"]; !ok {
data["guestHeader"] = !loggedIn
}
if _, ok := data["isVip"]; !ok {
data["isVip"] = loggedIn && authser.IsActiveVipFromCookies(c)
}
isVip, _ := data["isVip"].(bool)
data["isVip"] = isVip
if loggedIn {
if _, provided := data["userName"]; !provided && userName != "" {
data["userName"] = userName
}
if _, provided := data["avatar"]; !provided && avatar != "" {
data["avatar"] = avatar
}
if email != "" {
data["email"] = email
}
if _, ok := data["userId"]; !ok {
if uid := authser.UserIDFromCookies(c); uid > 0 {
data["userId"] = authser.FormatUserID(uid)
}
}
if _, ok := data["recharge"]; !ok {
recharge := authser.RechargeFromCookies(c)
data["recharge"] = recharge
data["rechargeText"] = authser.FormatRecharge(recharge)
} else if _, ok := data["rechargeText"]; !ok {
data["rechargeText"] = authser.FormatRecharge(toInt64(data["recharge"]))
}
if _, ok := data["vipType"]; !ok {
data["vipType"] = authser.VipTypeFromCookies(c)
}
} else {
delete(data, "userName")
delete(data, "avatar")
delete(data, "email")
delete(data, "userId")
delete(data, "recharge")
delete(data, "rechargeText")
delete(data, "vipType")
}
// 导航分类:GET /api/app/translation/category/list → categoryList(包含分类 seoInfo)
if _, ok := data["categoryList"]; !ok {
data["categoryList"] = translationser.FetchHeaderCategoryListOrDefault(c)
}
if _, ok := data["requestPath"]; !ok {
data["requestPath"] = c.Request.URL.Path
}
// 抽屉题材:GET /api/app/theme/list
if _, ok := data["themeList"]; !ok {
data["themeList"] = playletser.FetchThemesOrEmpty(c, 0)
}
// 顶栏短剧下拉快捷入口:homeConfigData type=1
if _, ok := data["homeConfigPlaylets"]; !ok {
if configs, ok := data["homeConfigData"].([]playletser.HomeConfigData); ok {
data["homeConfigPlaylets"] = playletser.HomeConfigsByType(configs, 1)
} else {
home := playletser.FetchHomeOrEmpty(c)
data["homeConfigPlaylets"] = playletser.HomeConfigsByType(home.HomeConfigData, 1)
}
}
// PC 搜索下拉热搜:GET /api/app/playlet/hot-like
if _, ok := data["searchTrends"]; !ok {
trends := hotSearchTrends(playletser.FetchHotLikeOrEmpty(c).HotSearch)
if len(trends) > 8 {
trends = trends[:8]
}
data["searchTrends"] = trends
}
// 公共配置:GET /api/app/config/info → config / appConfig
if _, ok := data["appConfig"]; !ok {
vm := configser.FetchAppConfigVMOrEmpty(c)
data["appConfig"] = vm
if _, ok := data["config"]; !ok {
data["config"] = vm.Config
}
} else if _, ok := data["config"]; !ok {
if vm, ok := data["appConfig"].(configser.AppConfigVM); ok {
data["config"] = vm.Config
}
}
data["trackerAppId"] = configser.ResolveTrackerAppID()
attachTrackerPage(c.Request.URL.Path, code, data)
attachPageSEO(c, code, data)
c.HTML(code, name, data)
}
// HTMLOK 以 200 渲染页面。
func HTMLOK(c *gin.Context, name string, data gin.H) {
if pageRepresentsMissingResource(c, data) {
NotFound(c)
return
}
HTML(c, http.StatusOK, name, data)
}
func toInt64(v any) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case int32:
return int64(n)
case float64:
return int64(n)
case string:
i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
return i
default:
return 0
}
}
// attachPageSEO 在一个入口统一生成页面级 SEO 信号,避免各模板各自维护后产生冲突。
func attachPageSEO(c *gin.Context, code int, data gin.H) {
if c == nil || c.Request == nil {
return
}
cfg, ok := data["appConfig"].(configser.AppConfigVM)
if !ok {
cfg = configser.FetchAppConfigVMOrEmpty(c)
data["appConfig"] = cfg
}
path := c.Request.URL.Path
pageTitle, _ := data["title"].(string)
pageTitle = strings.TrimSpace(pageTitle)
seoInfo := pageSEOFromData(data["seoInfo"])
siteName := firstNonEmptySEO(seoInfo.WebSiteName, cfg.SeoInfo.WebSiteName, "黄果短剧")
routeConfigSEO, _ := data["routeConfigSEO"].(bool)
if !routeConfigSEO {
seoInfo = replaceSitewideSEOWithRouteSEO(path, data, seoInfo, pageSEOFromData(cfg.SeoInfo), siteName)
}
navigationSEOApplied := false
if categories, ok := data["categoryList"].([]translationser.TranslationCategory); ok {
headerNav := translationser.BuildHeaderNav(&translationser.AppTranslationInfo{CategoryList: categories})
activeKey, _ := data["navActive"].(string)
if navSEO, found := translationser.FindHeaderNavSEOByPath(headerNav, path); found {
// 与 jha-1040-cgdj-web 一致:导航分类页面以分类接口 TDK 为主,页面接口只补缺。
seoInfo = playletser.MergeSeoInfo(playletser.SeoInfo{
Title: navSEO.Title,
Description: navSEO.Description,
Keywords: navSEO.Keyworld,
}, seoInfo)
navigationSEOApplied = true
} else if !routeConfigSEO {
// 当前项目的详情/播放页会复用 navActive;这类页面保持内容 TDK 为主,导航只补缺。
if navSEO, found := translationser.FindHeaderNavSEO(headerNav, activeKey, path); found {
seoInfo = playletser.MergeSeoInfo(seoInfo, playletser.SeoInfo{
Title: navSEO.Title,
Description: navSEO.Description,
Keywords: navSEO.Keyworld,
})
}
}
}
if selections, ok := data["seoSelections"].([]playletser.SEOSelection); ok && len(selections) > 0 {
seoInfo = playletser.DifferentiateSeoInfo(seoInfo, selections)
}
if seoInfo.Title == "" {
seoInfo.Title = firstNonEmptySEO(pageTitle, defaultPageTitle(path, siteName))
}
if seoInfo.Description == "" {
seoInfo.Description = defaultMetaDescription(path, seoInfo.Title, data, siteName)
}
if len(seoInfo.Keywords) == 0 {
seoInfo.Keywords = defaultMetaKeywords(path, siteName)
}
if pageNum := paginationPage(data["pagination"]); pageNum > 1 {
seoInfo = playletser.PaginateSeoInfo(seoInfo, seoInfo.Description, pageNum)
}
metaTitle := seoInfo.Title
if !navigationSEOApplied {
metaTitle = ensureBrandTitle(metaTitle, siteName)
}
data["title"] = metaTitle
data["metaTitle"] = metaTitle
data["metaDescription"] = seoInfo.Description
data["metaKeywords"] = strings.Join(seoInfo.Keywords, ",")
data["siteName"] = siteName
data["seoInfo"] = seoInfo
siteURL := seoser.ResolveSiteURL(c, cfg)
data["siteURL"] = siteURL
data["foreverUrlAddr"] = siteURL
data["seoVerify"] = seoser.VerifyFromConfig()
data["ogType"] = pageOGType(path)
data["ogImage"] = pageOGImage(siteURL, path, data)
attachPaginationLinks(data, siteURL)
robots, _ := data["robots"].(string)
robots = strings.TrimSpace(robots)
if robots == "" {
robots = defaultRobotsForPath(path, code)
}
if !robotsHasNoIndex(robots) && pageShouldNotIndex(c, data) {
robots = "noindex,follow"
}
data["robots"] = robots
noindex := robotsHasNoIndex(robots)
if noindex {
data["noindex"] = true
c.Header("X-Robots-Tag", strings.ReplaceAll(robots, ",", ", "))
} else {
delete(data, "noindex")
}
canonicalEligible := code >= http.StatusOK && code < http.StatusMultipleChoices && !noindex && seoser.IsIndexablePath(path)
if canonicalEligible {
canonicalURL := seoser.CanonicalWithoutQuery(seoser.PageCanonicalURL(siteURL, cfg.ForeverURLAddr, c))
if canonicalPath, _ := data["canonicalPath"].(string); strings.HasPrefix(strings.TrimSpace(canonicalPath), "/") {
canonicalURL = seoser.CanonicalWithoutQuery(seoser.AbsURL(siteURL, strings.TrimSpace(canonicalPath)))
}
canonicalURL = seoser.CanonicalWithoutQuery(seoser.ContentCanonicalURL(siteURL, canonicalURL, map[string]any(data)))
data["canonicalURL"] = canonicalURL
data["jsonLD"] = seoser.PageJSONLD(siteURL, canonicalURL, c.Request.URL.RequestURI(), map[string]any(data), cfg)
} else {
delete(data, "canonicalURL")
delete(data, "jsonLD")
}
}
func pageShouldNotIndex(c *gin.Context, data gin.H) bool {
if isNavPage, _ := data["isNavPage"].(bool); isNavPage {
return true
}
if c != nil && c.Request != nil {
path := c.Request.URL.Path
if isEpisodePlayPath(path, c.Param("ep")) {
return true
}
if strings.HasPrefix(path, "/series-play/") {
if canWatch, ok := data["canWatch"].(bool); ok && !canWatch {
return true
}
}
}
return pageRepresentsMissingResource(c, data)
}
// pageRepresentsMissingResource 识别不存在的实体、非法分页与越界分页。
// 这些请求必须返回真实 404,不能仅依赖 noindex 掩盖 200 软 404。
func pageRepresentsMissingResource(c *gin.Context, data gin.H) bool {
if valid, exists := data["seoEntityValid"].(bool); exists && !valid {
return true
}
pageNum := int(toInt64(data["pageNum"]))
if c != nil {
if rawPage := strings.TrimSpace(c.Param("page")); rawPage != "" {
requestedPage, err := strconv.Atoi(rawPage)
// 分页二级路由只允许第 2 页及以后;第 1 页使用更短的规范地址。
if err != nil || requestedPage <= 1 {
return true
}
pageNum = requestedPage
}
}
pageSize := int(toInt64(data["pageSize"]))
totalCount, hasTotal := data["totalCount"]
return hasTotal && playletser.PageOutOfRange(pageNum, pageSize, toInt64(totalCount))
}
func pageSEOFromData(value any) playletser.SeoInfo {
var seo playletser.SeoInfo
switch value := value.(type) {
case playletser.SeoInfo:
seo = value
case *playletser.SeoInfo:
if value != nil {
seo = *value
}
case videoser.SeoInfo:
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
case *videoser.SeoInfo:
if value != nil {
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
}
case configser.SeoInfo:
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
case *configser.SeoInfo:
if value != nil {
seo = playletser.SeoInfo{Title: value.Title, Description: value.Description, Keywords: value.Keywords, CanonicalURL: value.CanonicalURL, WebSiteName: value.WebSiteName}
}
}
seo.Normalize()
return seo
}
// replaceSitewideSEOWithRouteSEO 只替换接口误复用的全站首页 TDK。
// 页面接口真正下发的独立 SEO 仍保持最高优先级。
func replaceSitewideSEOWithRouteSEO(path string, data gin.H, seo, siteDefault playletser.SeoInfo, siteName string) playletser.SeoInfo {
routeSEO, ok := routeSpecificSEO(path, data, siteName)
if !ok {
return seo
}
if sameNonEmptySEOValue(seo.Title, siteDefault.Title) {
seo.Title = routeSEO.Title
}
if sameNonEmptySEOValue(seo.Description, siteDefault.Description) {
seo.Description = routeSEO.Description
}
if sameSEOKeywords(seo.Keywords, siteDefault.Keywords) {
seo.Keywords = append([]string(nil), routeSEO.Keywords...)
}
seo.Normalize()
return seo
}
func isEpisodePlayPath(path, epParam string) bool {
if strings.TrimSpace(epParam) != "" {
return true
}
segments := strings.Split(strings.Trim(path, "/"), "/")
return len(segments) >= 3 && segments[0] == "play" && segments[1] != "" && segments[2] != ""
}
func routeSpecificSEO(path string, data gin.H, siteName string) (playletser.SeoInfo, bool) {
path = strings.TrimRight(strings.TrimSpace(path), "/")
title := ""
switch {
case path == "/list" || strings.HasPrefix(path, "/list/"):
title = firstNonEmptySEO(dataString(data, "pageHeading"), "全部短剧")
case path == "/series" || strings.HasPrefix(path, "/series/"):
title = firstNonEmptySEO(dataString(data, "pageHeading"), "剧集与动画")
case path == "/topic" || strings.HasPrefix(path, "/topic/"):
title = "专题剧单"
case strings.HasPrefix(path, "/topic-detail/"):
if name := dataString(data, "name"); name != "" {
title = name + "专题短剧"
} else {
title = "短剧专题"
}
case strings.HasPrefix(path, "/tags/"):
if name := dataString(data, "tagName"); name != "" {
title = name + "标签短剧"
} else {
title = "标签短剧"
}
case strings.HasPrefix(path, "/more-list/"):
title = firstNonEmptySEO(dataString(data, "configName"), "精选短剧")
case strings.HasPrefix(path, "/more-series-list/"):
title = firstNonEmptySEO(dataString(data, "configName"), "精选剧集")
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
title = "短剧排行榜"
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
title = "热门短剧推荐"
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
title = "最近上新短剧"
default:
return playletser.SeoInfo{}, false
}
return playletser.SeoInfo{
Title: title,
Description: defaultMetaDescription(path, title, data, siteName),
Keywords: defaultMetaKeywords(path, siteName),
}, true
}
func dataString(data gin.H, key string) string {
value, _ := data[key].(string)
return strings.TrimSpace(value)
}
func sameNonEmptySEOValue(value, generic string) bool {
value = strings.TrimSpace(value)
generic = strings.TrimSpace(generic)
return generic != "" && strings.EqualFold(value, generic)
}
func sameSEOKeywords(value, generic []string) bool {
if len(generic) == 0 || len(value) != len(generic) {
return false
}
for i := range value {
if !strings.EqualFold(strings.TrimSpace(value[i]), strings.TrimSpace(generic[i])) {
return false
}
}
return true
}
func paginationPage(value any) int {
switch value := value.(type) {
case playletser.PaginationVM:
return value.Page
case *playletser.PaginationVM:
if value != nil {
return value.Page
}
}
return 0
}
func attachPaginationLinks(data gin.H, siteURL string) {
var pagination playletser.PaginationVM
switch value := data["pagination"].(type) {
case playletser.PaginationVM:
pagination = value
case *playletser.PaginationVM:
if value == nil {
return
}
pagination = *value
default:
return
}
if !pagination.Visible {
return
}
if pagination.HasPrev && strings.TrimSpace(pagination.PrevURL) != "" {
data["paginationPrevURL"] = seoser.AbsURL(siteURL, pagination.PrevURL)
}
if pagination.HasNext && strings.TrimSpace(pagination.NextURL) != "" {
data["paginationNextURL"] = seoser.AbsURL(siteURL, pagination.NextURL)
}
}
func defaultRobotsForPath(path string, code int) string {
if code >= http.StatusBadRequest {
return "noindex,follow"
}
if seoser.IsIndexablePath(path) {
return "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1"
}
if seoser.IsPrivatePath(path) {
return "noindex,nofollow"
}
return "noindex,follow"
}
func robotsHasNoIndex(robots string) bool {
for _, directive := range strings.Split(strings.ToLower(robots), ",") {
if strings.TrimSpace(directive) == "noindex" {
return true
}
}
return false
}
func defaultPageTitle(path, siteName string) string {
path = strings.TrimRight(strings.TrimSpace(path), "/")
if siteName == "" {
siteName = "黄果短剧"
}
switch {
case path == "":
return siteName + " - 热门短剧在线看"
case path == "/about":
return "关于我们"
case path == "/nav" || path == "/dh":
return siteName + " · 官方导航"
case path == "/help":
return "常见问题"
case path == "/terms":
return "用户条款"
case path == "/privacy":
return "隐私政策"
case path == "/2257":
return "2257 合规声明"
case path == "/dmca":
return "DMCA 版权声明"
case path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile":
return "搜索"
case path == "/list" || strings.HasPrefix(path, "/list/"):
return "全部短剧"
case path == "/series" || strings.HasPrefix(path, "/series/"):
return "剧集与动画"
case path == "/topic" || strings.HasPrefix(path, "/topic/"):
return "专题剧单"
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
return "短剧排行榜"
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
return "最近上新"
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
return "热门推荐"
case strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/"):
return "在线播放"
default:
return siteName
}
}
func defaultMetaDescription(path, title string, data gin.H, siteName string) string {
for _, key := range []string{"description", "desc", "heroDesc", "categoryDescription", "tagDescription", "topicDescription"} {
if value, _ := data[key].(string); strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
if siteName == "" {
siteName = "黄果短剧"
}
cleanTitle := strings.TrimSpace(strings.TrimSuffix(title, " - "+siteName))
path = strings.TrimRight(strings.TrimSpace(path), "/")
if isNavPage, _ := data["isNavPage"].(bool); isNavPage {
return siteName + "官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。"
}
switch {
case path == "":
return siteName + "汇集热门短剧、最近上新、排行榜与精选专题推荐,支持高清在线播放。"
case path == "/about":
return "了解" + siteName + "的平台理念、内容特色与联系方式。"
case path == "/nav" || path == "/dh":
return siteName + "官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。"
case path == "/help":
return "查看" + siteName + "常见问题、播放帮助、账号使用与客服支持信息。"
case path == "/terms":
return "查看" + siteName + "用户服务条款及平台使用规则。"
case path == "/privacy":
return "查看" + siteName + "隐私政策与个人信息保护说明。"
case path == "/2257":
return "查看" + siteName + " 18 U.S.C. 第 2257 章合规声明、年龄限制与内容反馈处理原则。"
case path == "/dmca":
return "查看" + siteName + " DMCA 版权声明、投诉材料要求与处理流程说明。"
case path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile":
return "在" + siteName + "搜索剧名、演员、标签与相关作品。"
case strings.HasPrefix(path, "/play/"):
return "在线观看《" + cleanTitle + "》,查看选集、剧情信息与相关推荐。"
case strings.HasPrefix(path, "/series-play/"):
return "在线观看《" + cleanTitle + "》,浏览视频信息与相关推荐。"
case path == "/list" || strings.HasPrefix(path, "/list/"):
return "浏览" + siteName + "全部短剧,按题材、更新状态和热度筛选精彩作品。"
case path == "/series" || strings.HasPrefix(path, "/series/"):
return "浏览" + siteName + "精选剧集与动画,按类型、专题和热度发现更多内容。"
case path == "/topic" || strings.HasPrefix(path, "/topic/") || strings.HasPrefix(path, "/topic-detail/"):
return "浏览" + siteName + "精选专题剧单,发现同题材热门作品与持续更新内容。"
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
return "查看" + siteName + "实时更新的短剧排行榜,浏览热播榜、新剧榜与追剧榜。"
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
return "浏览" + siteName + "热门推荐,发现近期高热度、广受欢迎的精选短剧。"
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
return "查看" + siteName + "最近上新的短剧与剧集,及时追更最新内容。"
default:
return cleanTitle + ",浏览" + siteName + "相关内容与推荐。"
}
}
func defaultMetaKeywords(path, siteName string) []string {
path = strings.TrimRight(strings.TrimSpace(path), "/")
if siteName == "" {
siteName = "黄果短剧"
}
switch {
case path == "":
return []string{siteName, "短剧", "在线短剧", "热门短剧"}
case path == "/list" || strings.HasPrefix(path, "/list/"):
return []string{"短剧列表", "短剧分类", "在线短剧", siteName}
case path == "/series" || strings.HasPrefix(path, "/series/"):
return []string{"剧集", "动画", "在线观看", siteName}
case strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/"):
return []string{"在线播放", "短剧播放", siteName}
case path == "/topic" || strings.HasPrefix(path, "/topic/") || strings.HasPrefix(path, "/topic-detail/"):
return []string{"短剧专题", "专题剧单", siteName}
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
return []string{"短剧排行榜", "热门短剧", siteName}
case path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
return []string{"热门短剧", "短剧推荐", siteName}
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/"):
return []string{"最新短剧", "最近上新", siteName}
case strings.HasPrefix(path, "/tags/"):
return []string{"标签短剧", "短剧分类", siteName}
case strings.HasPrefix(path, "/more-list/"):
return []string{"精选短剧", "热门短剧", siteName}
case strings.HasPrefix(path, "/more-series-list/"):
return []string{"精选剧集", "剧集推荐", siteName}
default:
return []string{siteName, "在线短剧"}
}
}
func ensureBrandTitle(title, siteName string) string {
title = strings.TrimSpace(title)
siteName = strings.TrimSpace(siteName)
if title == "" {
return siteName
}
if siteName == "" || strings.Contains(title, siteName) || strings.Contains(title, "黄果短剧") {
return title
}
return title + " - " + siteName
}
func pageOGType(path string) string {
path = strings.TrimRight(strings.TrimSpace(path), "/")
if strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/") {
return "video.episode"
}
if path == "/terms" || path == "/privacy" || path == "/2257" || path == "/dmca" {
return "article"
}
return "website"
}
func pageOGImage(siteURL string, path string, data gin.H) string {
path = strings.TrimRight(strings.TrimSpace(path), "/")
// 播放页分享小卡片统一用品牌 OG 图,避免微信等抓取剧集封面。
if strings.HasPrefix(path, "/play/") || strings.HasPrefix(path, "/series-play/") {
return seoser.AbsURL(siteURL, "/static/og-default.png")
}
if playlet, ok := data["playlet"].(*playletser.PlayletInfoResp); ok && playlet != nil {
if cover := firstNonEmptySEO(playlet.Cover, playlet.Banner); cover != "" {
return seoser.AbsURL(siteURL, cover)
}
}
if cover, _ := data["cover"].(string); strings.TrimSpace(cover) != "" {
return seoser.AbsURL(siteURL, cover)
}
return seoser.AbsURL(siteURL, "/static/og-default.png")
}
func firstNonEmptySEO(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
+176
View File
@@ -0,0 +1,176 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
"videoweb/app/service/configser"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
func TestAttachPageSEOUsesPageTypeInsteadOfRequestDomain(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
requestURL string
foreverURL string
status int
data gin.H
wantRobots string
wantRobotsHeader string
wantCanonical string
wantJSONLD bool
}{
{
name: "public page on alternate domain remains indexable",
requestURL: "https://alternate.example/",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{},
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
wantCanonical: "https://primary.example/",
wantJSONLD: true,
},
{
name: "public page without permanent address remains indexable",
requestURL: "https://alternate.example/about",
status: http.StatusOK,
data: gin.H{},
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
wantCanonical: "https://alternate.example/about",
wantJSONLD: true,
},
{
name: "search page is not indexable",
requestURL: "https://alternate.example/search/short-drama",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
{
name: "private page is neither indexed nor followed",
requestURL: "https://alternate.example/profile",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{},
wantRobots: "noindex,nofollow",
wantRobotsHeader: "noindex, nofollow",
},
{
name: "navigation page is not indexable",
requestURL: "https://alternate.example/",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{"isNavPage": true},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
{
name: "missing public entity is not indexable",
requestURL: "https://alternate.example/tags/missing",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{"seoEntityValid": false},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
{
name: "error page is not indexable",
requestURL: "https://alternate.example/about",
foreverURL: "https://primary.example",
status: http.StatusNotFound,
data: gin.H{},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
{
name: "canonical play page stays indexable",
requestURL: "https://alternate.example/play/zhuazou",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{},
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
wantCanonical: "https://primary.example/play/zhuazou",
wantJSONLD: true,
},
{
name: "play page with chapter still canonicalizes to two-segment path",
requestURL: "https://alternate.example/play/zhuazou",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{
"playlet": playletser.PlayletInfoResp{ID: 12, Slug: "zhuazou"},
"chapter": playletser.PlayletChapterInfoResp{ID: 901, PlayletID: 12},
},
wantRobots: "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
wantCanonical: "https://primary.example/play/zhuazou",
wantJSONLD: true,
},
{
name: "episode play page is not indexable",
requestURL: "https://alternate.example/play/zhuazou/12",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
{
name: "unwatchable series play page is not indexable",
requestURL: "https://alternate.example/series-play/film-1",
foreverURL: "https://primary.example",
status: http.StatusOK,
data: gin.H{"canWatch": false},
wantRobots: "noindex,follow",
wantRobotsHeader: "noindex, follow",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
response := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(response)
ctx.Request = httptest.NewRequest(http.MethodGet, tt.requestURL, nil)
data := gin.H{
"appConfig": configser.AppConfigVM{
ForeverURLAddr: tt.foreverURL,
},
}
for key, value := range tt.data {
data[key] = value
}
attachPageSEO(ctx, tt.status, data)
if got := data["robots"]; got != tt.wantRobots {
t.Fatalf("robots = %q, want %q", got, tt.wantRobots)
}
if got := response.Header().Get("X-Robots-Tag"); got != tt.wantRobotsHeader {
t.Fatalf("X-Robots-Tag = %q, want %q", got, tt.wantRobotsHeader)
}
canonicalValue, hasCanonical := data["canonicalURL"]
if tt.wantCanonical == "" {
if hasCanonical {
t.Fatalf("canonicalURL must be absent, got %#v", canonicalValue)
}
} else {
canonical, ok := canonicalValue.(string)
if !hasCanonical || !ok || canonical != tt.wantCanonical {
t.Fatalf("canonicalURL = %#v, want %q", canonicalValue, tt.wantCanonical)
}
}
jsonLD, hasJSONLD := data["jsonLD"]
if hasJSONLD != tt.wantJSONLD {
t.Fatalf("jsonLD presence = %v, want %v (value %#v)", hasJSONLD, tt.wantJSONLD, jsonLD)
}
})
}
}
+284
View File
@@ -0,0 +1,284 @@
package web
import (
"fmt"
"net/url"
"strings"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
type searchTrend struct {
Rank int
Title string
Heat string
Badge string
}
// SearchMobile 移动端搜索入口。
// 热搜 + 猜你喜欢:GET /api/app/playlet/hot-like
// 联想:前端调用 GET /api/web/search/suggest → 上游 /api/app/playlet/search/suggest
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_hot_like
func SearchMobile(c *gin.Context) {
if hasLegacySearchQuery(c) {
NotFound(c)
return
}
raw := playletser.FetchHotLikeOrEmpty(c)
trends := hotSearchTrends(raw.HotSearch)
recommended := raw.GuessLike
if len(recommended) > 15 {
recommended = recommended[:15]
}
HTMLOK(c, "searchMobile.html", gin.H{
"title": "搜索 - 黄果短剧",
"query": searchQueryFromRequest(c),
"trends": trends,
"recommended": recommended,
"hotLikeDebug": gin.H{
"path": "/api/app/playlet/hot-like",
"response": raw,
},
})
}
func hotSearchTrends(items []playletser.PlayletHotSearchItem) []searchTrend {
out := make([]searchTrend, 0, len(items))
for i, item := range items {
keyword := strings.TrimSpace(item.Keyword)
if keyword == "" {
continue
}
rank := i + 1
badge := ""
switch rank {
case 1:
badge = "热"
case 2:
badge = "新"
}
out = append(out, searchTrend{
Rank: rank,
Title: keyword,
Heat: formatSearchHeat(item.Count),
Badge: badge,
})
}
return out
}
func formatSearchHeat(n int64) string {
if n <= 0 {
return "0"
}
if n < 10000 {
return fmt.Sprintf("%d", n)
}
v := float64(n) / 10000
if v >= 100 {
return fmt.Sprintf("%.0f 万", v)
}
return fmt.Sprintf("%.1f 万", v)
}
// Search 搜索结果页:GET /api/app/playlet/search。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_search
// 无结果时「大家都在看」:GET /api/app/playlet/all/list(取 6 条最热)。
func Search(c *gin.Context) {
if hasLegacySearchQuery(c) {
NotFound(c)
return
}
keyword := searchQueryFromRequest(c)
pageStr := strings.TrimSpace(c.Param("page"))
q := playletser.ParseSearchQuery(keyword, pageStr)
list := []playletser.CommonData(nil)
recommended := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var searchDebug any
var watchingDebug any
if q.Keyword != "" {
raw := playletser.FetchSearchOrEmpty(c, q)
searchDebug = gin.H{
"path": "/api/app/playlet/search",
"keyword": q.Keyword,
"pageNum": q.PageNum,
"pageSize": q.PageSize,
"response": raw,
}
if raw != nil {
list = raw.List
total = raw.TotalCount
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
if strings.TrimSpace(raw.Keyword) != "" {
keyword = raw.Keyword
}
}
} else {
searchDebug = gin.H{
"path": "/api/app/playlet/search",
"keyword": "",
"note": "empty keyword, skip upstream",
}
}
if len(list) == 0 {
watchingQ := playletser.AllListQuery{
PageNum: 1,
PageSize: 6,
SortType: 2, // 最热
UpdateStatus: 0, // 全部
}
watching := playletser.FetchAllListOrEmpty(c, watchingQ)
watchingDebug = gin.H{
"path": "/api/app/playlet/all/list",
"pageNum": watchingQ.PageNum,
"pageSize": watchingQ.PageSize,
"sortType": watchingQ.SortType,
"response": watching,
}
if watching != nil {
recommended = watching.List
if len(recommended) > 6 {
recommended = recommended[:6]
}
}
}
HTMLOK(c, "search.html", gin.H{
"title": "搜索 - 黄果短剧",
"query": keyword,
"hasResults": len(list) > 0,
"resultCount": total,
"works": list,
"recommended": recommended,
"pageNum": pageNum,
"pageSize": pageSize,
"pagination": playletser.BuildSearchPagination(q, total),
"searchDebug": searchDebug,
"watchingDebug": watchingDebug,
})
}
type searchSuggestFeature struct {
Title string `json:"title"`
Cover string `json:"cover"`
URL string `json:"url"`
Meta string `json:"meta"`
}
type searchSuggestItem struct {
Title string `json:"title"`
Kind string `json:"kind"`
Query string `json:"query"`
URL string `json:"url"`
}
// SearchSuggest PC 顶栏输入联想:GET /api/web/search/suggest?keyword=
// 上游:GET /api/app/playlet/search/suggest → proto.PlayletSearchSuggestResp
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_search_suggest
func SearchSuggest(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("keyword"))
if keyword == "" {
keyword = strings.TrimSpace(c.Query("q"))
}
if keyword == "" {
c.JSON(200, gin.H{"code": 200, "keyword": "", "feature": nil, "items": []searchSuggestItem{}})
return
}
raw := playletser.FetchSearchSuggestOrEmpty(c, keyword)
if kw := strings.TrimSpace(raw.Keyword); kw != "" {
keyword = kw
}
var feature *searchSuggestFeature
items := make([]searchSuggestItem, 0, len(raw.List)+1)
for _, item := range raw.List {
title := playletser.PlayletDisplayName(item.PlayName, item.Title)
if title == "" {
continue
}
href := playletser.PlayPath(item.Slug, item.ID)
if feature == nil {
feature = &searchSuggestFeature{
Title: title,
Cover: strings.TrimSpace(item.Cover),
URL: href,
Meta: buildSearchSuggestMeta(item),
}
continue
}
items = append(items, searchSuggestItem{
Title: title,
Kind: "短剧 剧集",
Query: title,
URL: href,
})
}
items = append(items, searchSuggestItem{
Title: keyword,
Kind: "搜索",
Query: keyword,
URL: playletser.SearchResultsURL(keyword),
})
c.JSON(200, gin.H{
"code": 200,
"keyword": keyword,
"feature": feature,
"items": items,
})
}
func buildSearchSuggestMeta(item playletser.CommonData) string {
parts := make([]string, 0, 4)
for _, name := range item.ThemeNames {
name = strings.TrimSpace(name)
if name != "" {
parts = append(parts, name)
}
}
for _, name := range item.TagNames {
name = strings.TrimSpace(name)
if name != "" && len(parts) < 2 {
parts = append(parts, name)
}
}
if item.TotalEpisode > 0 {
parts = append(parts, fmt.Sprintf("全 %d 集", item.TotalEpisode))
} else if item.UpdateStatus == 1 {
parts = append(parts, "连载中")
}
if anchor := strings.TrimSpace(item.Anchor); anchor != "" && anchor != "暂无" && anchor != "原创" {
parts = append(parts, "主演 "+anchor)
}
return strings.Join(parts, " ")
}
func hasLegacySearchQuery(c *gin.Context) bool {
query := c.Request.URL.Query()
return query.Has("q") || query.Has("page")
}
func searchQueryFromRequest(c *gin.Context) string {
q := strings.TrimSpace(c.Param("q"))
if q == "" {
return ""
}
if decoded, err := url.PathUnescape(q); err == nil {
return strings.TrimSpace(decoded)
}
return q
}
+56
View File
@@ -0,0 +1,56 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/configser"
"videoweb/app/service/seoser"
"github.com/gin-gonic/gin"
)
// RobotsTXT 输出可抓取规则及绝对 sitemap 地址。
func RobotsTXT(c *gin.Context) {
cfg := configser.FetchAppConfigVMOrEmpty(c)
siteURL := seoser.ResolveSiteURL(c, cfg)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, seoser.BuildRobotsTXT(siteURL))
}
// LLMSTXT 输出简洁站点说明及核心页面的规范地址,供 AI 抓取工具发现内容。
func LLMSTXT(c *gin.Context) {
cfg := configser.FetchAppConfigVMOrEmpty(c)
writeLLMSTXT(c, cfg)
}
func writeLLMSTXT(c *gin.Context, cfg configser.AppConfigVM) {
siteURL := seoser.ResolveSiteURL(c, cfg)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, seoser.BuildLLMSTXT(siteURL, cfg))
}
// SEOVerifyFile 输出 IndexNow 所有权验证文本。
func SEOVerifyFile(c *gin.Context) {
key := strings.TrimSuffix(strings.TrimPrefix(c.Request.URL.Path, "/"), ".txt")
configured := seoser.VerifyFromConfig().IndexNowKey
if key == "" || key != configured {
NotFound(c)
return
}
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, configured)
}
// DownloadApp 将站内统一下载入口重定向到配置的官方地址,避免模板回退链接产生 404。
func DownloadApp(c *gin.Context) {
cfg := configser.FetchAppConfigVMOrEmpty(c)
target := strings.TrimSpace(cfg.AppAddr)
if target == "" {
target = strings.TrimSpace(cfg.OfficialAppAddr)
}
if target == "" {
target = "/"
}
c.Header("X-Robots-Tag", "noindex, nofollow")
c.Redirect(http.StatusFound, target)
}
+30
View File
@@ -0,0 +1,30 @@
package web
import "videoweb/app/service/playletser"
func rankingSEOSelections(value string) []playletser.SEOSelection {
if value == "" {
return nil
}
return []playletser.SEOSelection{{Dimension: "榜单", Value: value}}
}
func moreListSEOSelections(q playletser.MoreListQuery, themes []playletser.PlayletThemeItem) []playletser.SEOSelection {
selections := make([]playletser.SEOSelection, 0, 2)
if q.TimeRange == 1 {
selections = append(selections, playletser.SEOSelection{Dimension: "时间", Value: "本周"})
} else if q.TimeRange == 2 {
selections = append(selections, playletser.SEOSelection{Dimension: "时间", Value: "本月"})
}
themeName := q.ThemeSlug
for _, theme := range themes {
if (q.ThemeID > 0 && theme.ID == q.ThemeID) || (q.ThemeSlug != "" && theme.Slug == q.ThemeSlug) {
themeName = theme.Name
break
}
}
if themeName != "" {
selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: themeName})
}
return selections
}
+103
View File
@@ -0,0 +1,103 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// Series 剧集列表页:GET /api/app/video/all/list + topic/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_all_list
func Series(c *gin.Context) {
q := videoser.ParseSeriesListQuery(c)
topics := videoser.FetchVideoTopicListOrEmpty(c, q.MediaType)
topicResolved := seriesTopicResolved(q, topics)
raw := videoser.FetchVideoAllListOrEmpty(c, q)
pageHeading, seoSelections := seriesSEOContext(q, topics)
title := pageHeading + " - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
}
list := []videoser.VideoAllListItemResp(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo videoser.SeoInfo
var advitise any
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
advitise = raw.Advitise
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
HTMLOK(c, "series.html", gin.H{
"title": title,
"navActive": "series",
"seoInfo": seo,
"seoSelections": seoSelections,
"pageHeading": pageHeading,
"mediaType": q.MediaType,
"topicId": q.TopicID,
"sortType": q.SortType,
"seoEntityValid": topicResolved || total > 0,
"topicList": topics,
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"advitise": advitise,
"pagination": videoser.BuildSeriesListPagination(q, total),
"canonicalPath": q.URL(),
})
}
func seriesTopicResolved(q videoser.SeriesListQuery, topics []videoser.VideoTopicInfo) bool {
if q.TopicID <= 0 {
return true
}
for _, topic := range topics {
if topic.ID == q.TopicID {
return true
}
}
return false
}
func seriesSEOContext(q videoser.SeriesListQuery, topics []videoser.VideoTopicInfo) (string, []playletser.SEOSelection) {
heading := "剧集与动画"
selections := make([]playletser.SEOSelection, 0, 3)
if q.MediaType == videoser.MediaFilm || q.MediaType == videoser.MediaAnime {
mediaName := videoser.MediaTypeLabel(q.MediaType)
heading = mediaName
selections = append(selections, playletser.SEOSelection{Dimension: "类型", Value: mediaName})
}
if q.TopicID > 0 {
for _, topic := range topics {
if topic.ID == q.TopicID {
name := topic.DisplayName()
if name != "" {
heading = name + heading
selections = append(selections, playletser.SEOSelection{Dimension: "题材", Value: name})
}
break
}
}
}
if q.SortType == videoser.SortHeat {
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "最热"})
} else if q.SortType == videoser.SortCollect {
selections = append(selections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"})
}
return heading, selections
}
+85
View File
@@ -0,0 +1,85 @@
package web
import (
"net/http"
"strings"
"videoweb/app/service/authser"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
// Setting 展示账号设置,字段来自最新的用户信息接口。
func Setting(c *gin.Context) {
data := gin.H{"title": "账号设置 - 黄果短剧", "navActive": "setting", "accountTab": "setting"}
loadAccountSummary(c, data, gin.H{})
HTMLOK(c, "setting.html", data)
}
// UpdateProfile 代理账号资料修改。可选字段必须显式传入,避免覆盖未修改内容。
func UpdateProfile(c *gin.Context) {
if useragent.TokenFromRequest(c.Request) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "tip": "请先登录"})
return
}
var body authser.UpdateProfileReq
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "参数错误"})
return
}
if body.NickName != nil {
v := strings.TrimSpace(*body.NickName)
if v == "" || len([]rune(v)) > 40 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "昵称须为 1 至 40 个字符"})
return
}
body.NickName = &v
}
if body.Introduction != nil {
v := strings.TrimSpace(*body.Introduction)
if len([]rune(v)) > 300 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "简介不能超过 300 个字符"})
return
}
body.Introduction = &v
}
if body.Avatar != nil {
v := strings.TrimSpace(*body.Avatar)
if safeProfileImage(v) == "" || strings.HasPrefix(v, "/static/") {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "头像地址无效"})
return
}
body.Avatar = &v
}
if (body.OldPassword == nil) != (body.NewPassword == nil) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "请输入旧密码和新密码"})
return
}
if body.OldPassword != nil && (*body.OldPassword == "" || *body.NewPassword == "") {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "密码不能为空"})
return
}
if body.NickName == nil && body.Introduction == nil && body.Avatar == nil && body.OldPassword == nil && body.PrivacyMode == nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "tip": "没有需要保存的修改"})
return
}
env, err := authser.UpdateProfile(c, body)
if err != nil {
log.Error("update profile failed", log.E(err))
c.JSON(http.StatusBadGateway, gin.H{"code": 500, "tip": "保存失败,请稍后重试"})
return
}
if env == nil || env.Code != 200 {
if env == nil {
c.JSON(http.StatusBadGateway, gin.H{"code": 500, "tip": "保存失败,请稍后重试"})
return
}
c.JSON(http.StatusOK, gin.H{"code": env.Code, "tip": firstNonEmptyAuth(env.Tip, env.Msg, "保存失败")})
return
}
if infoEnv, user, err := authser.GetInfo(c); err == nil && infoEnv != nil && infoEnv.Code == 200 && user != nil {
authser.SetAuthCookies(c, user)
}
c.JSON(http.StatusOK, gin.H{"code": 200, "tip": firstNonEmptyAuth(env.Tip, "保存成功")})
}
+89
View File
@@ -0,0 +1,89 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Tags 标签作品列表页:GET /api/app/playlet/tag/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_tag_list
func Tags(c *gin.Context) {
q := playletser.ParseTagListQuery(c)
tagOptions := playletser.FetchTagOptionsOrEmpty(c)
tagName := playletser.ResolveTagListIdentity(&q, tagOptions)
tagResolved := tagName != ""
raw := playletser.FetchTagListOrEmpty(c, q)
if tagName == "" && raw != nil {
if raw.SeoInfo.Title != "" {
tagName = raw.SeoInfo.Title
} else {
tagName = playletser.FindTagNameBySlug(raw.List, q.TagSlug)
if tagName == "" {
tagName = playletser.FindTagName(raw.List, q.TagID)
}
}
}
title := "标签 - 黄果短剧"
if raw != nil {
title = playletser.TagListPageTitle(tagName, q.PageNum, raw.SeoInfo)
} else if tagName != "" {
title = playletser.TagListPageTitle(tagName, q.PageNum, playletser.SeoInfo{})
}
list := []playletser.CommonData(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
desc := ""
if seo.Description != "" {
desc = seo.Description
} else if total > 0 {
desc = fmt.Sprintf("%d 部作品带有此标签", total)
}
seoSelections := make([]playletser.SEOSelection, 0, 2)
if tagName != "" {
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "标签", Value: tagName})
}
if q.SortType == 2 {
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "排序", Value: "最热"})
} else if q.SortType == 3 {
seoSelections = append(seoSelections, playletser.SEOSelection{Dimension: "排序", Value: "收藏最多"})
}
HTMLOK(c, "tags.html", gin.H{
"title": title,
"navActive": "list",
"seoInfo": seo,
"seoSelections": seoSelections,
"tagName": tagName,
"tagSlug": q.TagSlug,
"tagId": q.TagID,
"sortType": q.SortType,
"seoEntityValid": tagResolved || total > 0,
"relatedTags": playletser.RelatedTagOptions(tagOptions, q.TagID, q.TagSlug),
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"listCount": fmt.Sprintf("共 %d 部", total),
"heroDesc": desc,
"pagination": playletser.BuildTagListPagination(q, total),
"canonicalPath": playletser.TagListCanonicalPath(q),
})
}
+8
View File
@@ -0,0 +1,8 @@
package web
import "github.com/gin-gonic/gin"
// Terms renders the static Terms of Service page.
func Terms(c *gin.Context) {
HTMLOK(c, "terms.html", gin.H{})
}
+58
View File
@@ -0,0 +1,58 @@
package web
import (
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// Topic 专题剧单列表页:GET /api/app/playlet/topic/list。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_topic_list
func Topic(c *gin.Context) {
q := playletser.ParseTopicListQuery(c)
raw := playletser.FetchTopicListOrEmpty(c, q)
home := playletser.FetchHomeOrEmpty(c)
homeConfigData := playletser.ActiveHomeConfigs(home.HomeConfigData)
list := []playletser.PlayletTopicPageItem(nil)
total := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
var seoSelections []playletser.SEOSelection
if q.SortType == 2 {
seoSelections = []playletser.SEOSelection{{Dimension: "排序", Value: "最热"}}
}
if raw != nil {
list = raw.List
total = raw.TotalCount
seo = raw.SeoInfo
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
// 首页“专题”区块的配置 TDK 优先;专题列表接口 SEO 仅补齐配置未填写的字段。
if configSEO, ok := playletser.FirstHomeConfigSEOByType(homeConfigData, 3); ok {
seo = playletser.MergeSeoInfo(configSEO, seo)
}
title := "专题剧单 - 黄果短剧"
if seo.Title != "" {
title = seo.Title
}
HTMLOK(c, "topic.html", gin.H{
"title": title,
"navActive": "topic",
"seoInfo": seo,
"routeConfigSEO": true,
"homeConfigData": homeConfigData,
"seoSelections": seoSelections,
"list": list,
"totalCount": total,
"pageNum": pageNum,
"pageSize": pageSize,
"sortType": q.SortType,
"pagination": playletser.BuildTopicListPagination(q, total),
"canonicalPath": q.URL(),
})
}
+97
View File
@@ -0,0 +1,97 @@
package web
import (
"fmt"
"videoweb/app/service/playletser"
"github.com/gin-gonic/gin"
)
// TopicDetail 专题详情页:GET /api/app/playlet/topic/detail。
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_topic_detail
func TopicDetail(c *gin.Context) {
q := playletser.ParseTopicDetailQuery(c)
playletser.ResolveTopicDetailID(c, &q)
raw := playletser.FetchTopicDetailOrEmpty(c, q)
if raw != nil {
if raw.Slug != "" {
q.Slug = raw.Slug
}
if raw.ID > 0 {
q.ID = raw.ID
}
}
name := ""
if raw != nil {
name = raw.Name
}
title := "专题 - 黄果短剧"
if raw != nil && raw.SeoInfo.Title != "" {
title = raw.SeoInfo.Title
} else if name != "" {
title = name + " - 黄果短剧"
}
list := []playletser.CommonData(nil)
more := []playletser.PlayletTopicPageItem(nil)
total := int64(0)
playletCount := int64(0)
pageNum, pageSize := q.PageNum, q.PageSize
var seo playletser.SeoInfo
desc := ""
heroCover := ""
covers := []string(nil)
if raw != nil {
list = raw.List
more = raw.MoreTopics
total = raw.TotalCount
playletCount = raw.PlayletCount
seo = raw.SeoInfo
desc = raw.Desc
heroCover = playletser.TopicDetailHeroCover(*raw)
covers = playletser.TopicDetailCoverURLs(*raw)
if raw.PageNum > 0 {
pageNum = raw.PageNum
}
if raw.PageSize > 0 {
pageSize = raw.PageSize
}
}
countText := "持续更新"
if playletCount > 0 {
countText = fmt.Sprintf("%d 部 持续更新", playletCount)
} else if total > 0 {
countText = fmt.Sprintf("%d 部 持续更新", total)
}
var seoSelections []playletser.SEOSelection
if q.SortType != 1 {
seoSelections = []playletser.SEOSelection{{Dimension: "排序", Value: playletser.TopicDetailSortLabel(q.SortType)}}
}
HTMLOK(c, "topicDetail.html", gin.H{
"title": title,
"navActive": "topic",
"seoInfo": seo,
"seoSelections": seoSelections,
"topic": raw,
"name": name,
"desc": desc,
"slug": q.Slug,
"topicId": q.ID,
"list": list,
"moreTopics": more,
"totalCount": total,
"playletCount": playletCount,
"countText": countText,
"pageNum": pageNum,
"pageSize": pageSize,
"sortType": q.SortType,
"sortLabel": playletser.TopicDetailSortLabel(q.SortType),
"seoEntityValid": q.ID > 0 && (name != "" || total > 0),
"heroCover": heroCover,
"covers": covers,
"pagination": playletser.BuildTopicDetailPagination(q, total),
"canonicalPath": q.URL(),
})
}
+80
View File
@@ -0,0 +1,80 @@
package web
import (
"net/http"
"strings"
)
// attachTrackerPage 为所有 HTML 页面补齐稳定的 pageKey/pageName,避免事件落入 unknown。
func attachTrackerPage(path string, code int, data map[string]any) {
if data == nil {
return
}
key, name := trackerPageFromRequest(path, code)
if current, _ := data["pageKey"].(string); strings.TrimSpace(current) == "" {
data["pageKey"] = key
}
if current, _ := data["pageName"].(string); strings.TrimSpace(current) == "" {
data["pageName"] = name
}
}
func trackerPageFromRequest(path string, code int) (string, string) {
if code >= http.StatusInternalServerError {
return "server_error", "服务异常页面"
}
if code >= http.StatusBadRequest {
return "not_found", "404页面"
}
return trackerPageFromPath(path)
}
func trackerPageFromPath(path string) (string, string) {
p := strings.TrimSuffix(strings.TrimSpace(path), "/")
if p == "" {
return "home", "首页"
}
rules := []struct {
prefix string
key string
name string
}{
{"/series-play", "series_play", "AI成人剧集播放页"},
{"/series", "series", "AI成人剧集"},
{"/play", "play", "短剧播放页"},
{"/detail", "detail", "短剧详情"},
{"/list", "list", "短剧列表"},
{"/recently-added", "recently_added", "最新短剧"},
{"/popular-recommend", "popular_recommend", "热门推荐"},
{"/rank", "rank", "排行榜"},
{"/topic-detail", "topic_detail", "专题详情"},
{"/topic", "topic", "专题列表"},
{"/tags", "tags", "标签详情"},
{"/more-series-list", "more_series_list", "成人剧集列表"},
{"/more-list", "more_list", "短剧扩展列表"},
{"/search-mobile", "search", "搜索结果页"},
{"/search", "search", "搜索结果页"},
{"/history", "history", "观看历史"},
{"/favorites", "favorites", "我的收藏"},
{"/purchased", "purchased", "已购剧集"},
{"/profile", "profile", "个人中心"},
{"/setting", "setting", "设置"},
{"/recharge", "recharge", "充值中心"},
{"/download-app", "download_app", "下载APP"},
{"/feedback", "feedback", "问题反馈"},
{"/help", "help", "帮助中心"},
{"/about", "about", "关于我们"},
{"/terms", "terms", "用户协议"},
{"/privacy", "privacy", "隐私政策"},
{"/2257", "compliance_2257", "2257合规声明"},
{"/dmca", "dmca", "DMCA版权声明"},
{"/login", "login", "登录"},
{"/register", "register", "注册"},
}
for _, rule := range rules {
if p == rule.prefix || strings.HasPrefix(p, rule.prefix+"/") {
return rule.key, rule.name
}
}
return "internal_other", "站内其他页面"
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"net/http"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// VideoCollectSet BFF:代理 POST /api/app/video/collect/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_collect_set
func VideoCollectSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsCollect *bool `json:"isCollect"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsCollect == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isCollect required",
"tip": "缺少视频 ID 或收藏状态",
"data": nil,
})
return
}
env, err := videoser.CollectSet(c, videoser.VideoCollectSetReq{
ID: body.ID,
IsCollect: *body.IsCollect,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+64
View File
@@ -0,0 +1,64 @@
package web
import (
"net/http"
"videoweb/app/service/videoser"
"github.com/gin-gonic/gin"
)
// VideoLikeSet BFF:代理 POST /api/app/video/like/set。
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_like_set
func VideoLikeSet(c *gin.Context) {
var body struct {
ID int64 `json:"id"`
IsLike *bool `json:"isLike"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid body",
"tip": "参数错误",
"data": nil,
})
return
}
if body.ID <= 0 || body.IsLike == nil {
c.JSON(http.StatusOK, gin.H{
"code": 400,
"msg": "id/isLike required",
"tip": "缺少视频 ID 或点赞状态",
"data": nil,
})
return
}
env, err := videoser.LikeSet(c, videoser.VideoLikeSetReq{
ID: body.ID,
IsLike: *body.IsLike,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": err.Error(),
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
if env == nil {
c.JSON(http.StatusOK, gin.H{
"code": 500,
"msg": "empty response",
"tip": "操作失败,请稍后重试",
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": env.Code,
"msg": env.Msg,
"tip": env.Tip,
"data": env.Data,
})
}
+46
View File
@@ -0,0 +1,46 @@
package appg
import (
"strings"
"videoweb/pub/pubdef"
"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"`
}
// TrackerAppID 返回页面 SDK 与 BFF 透传校验共用的可信应用 ID。
func TrackerAppID() string {
if id := strings.TrimSpace(Conf.Tracker.AppID); id != "" {
return id
}
return pubdef.TrackerAppId()
}
+11
View File
@@ -0,0 +1,11 @@
serverAddr: "http://172.104.181.99:25319"
listenAddr: "25121"
dhListenAddr: "26813"
runMod: "dev"
tsAuthKey: "fT5xSg4hltHpzVy6aV9rVECDJ1J1pN"
# 数据上报 SDK(web-sdk)。encryptedConfig 优先用上游 config/info.reportSdkConfig;
# 若上游暂未下发,可在此填写密文兜底。
tracker:
appId: "JHA-1045"
encryptedConfig: ""
+1
View File
@@ -0,0 +1 @@
—Џ€И’ љ БCЏ„
+28
View File
@@ -0,0 +1,28 @@
#!/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
tar zvcf $TAG_NAME ./$FILE_NAME yinsemd5.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
+10
View File
@@ -0,0 +1,10 @@
package main
import (
"os"
"videoweb/app/server"
)
func main() {
server.Run(os.Args)
}
View File
+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)
}
+264
View File
@@ -0,0 +1,264 @@
package router
import (
"encoding/json"
"html/template"
"net/http"
"os"
"strings"
"videoweb/app/api/web"
"videoweb/app/appg"
"videoweb/app/service/commentser"
"videoweb/app/service/playletser"
"videoweb/app/service/seoser"
"videoweb/app/service/translationser"
"videoweb/app/service/videoser"
staticassets "videoweb/app/static"
"github.com/gin-gonic/gin"
)
// Init 初始化 Gin,注册静态资源与业务路由。
func Init() *gin.Engine {
setGinMode()
engine := gin.Default()
engine.HandleMethodNotAllowed = true
setupEngine(engine)
registerM3U8Routes(engine)
registerSitemapRoutes(engine)
registerRoutes(engine)
engine.NoMethod(web.NotFound)
return engine
}
// DHInit 导航站独立端口:仅暴露官方导航页及静态资源。
func DHInit() *gin.Engine {
setGinMode()
engine := gin.Default()
engine.HandleMethodNotAllowed = true
setupEngine(engine)
engine.GET("/", web.Nav)
engine.GET("/nav/", web.Nav)
engine.GET("/dh/", web.Nav)
engine.NoRoute(web.NotFound)
engine.NoMethod(web.NotFound)
return engine
}
func setGinMode() {
mode := strings.ToLower(strings.TrimSpace(appg.Conf.RunMod))
if envMode := strings.ToLower(strings.TrimSpace(os.Getenv(gin.EnvGinMode))); envMode != "" {
mode = envMode
}
switch mode {
case "test":
gin.SetMode(gin.TestMode)
case "prod", "production", "release":
gin.SetMode(gin.ReleaseMode)
default:
gin.SetMode(gin.DebugMode)
}
}
func setupEngine(engine *gin.Engine) {
tplFuncs := playletser.TemplateFuncs()
for name, fn := range commentser.TemplateFuncs() {
tplFuncs[name] = fn
}
tplFuncs["InJSONScript"] = inJSONScript
tplFuncs["navHref"] = translationser.NavHref
tplFuncs["navIsDownload"] = translationser.NavIsDownload
tplFuncs["navIsActive"] = translationser.NavIsActive
tplFuncs["navDrawerIcon"] = translationser.NavDrawerIcon
tplFuncs["navHasMegaMenu"] = translationser.NavHasMegaMenu
tplFuncs["listThemeSegment"] = translationser.ListThemeSegmentFromPath
tplFuncs["listUpdateStatus"] = translationser.ListUpdateStatusFromPath
tplFuncs["seriesListURL"] = videoser.SeriesListURL
tplFuncs["videoDetailURL"] = videoser.VideoDetailURL
tplFuncs["videoPlayURL"] = videoser.VideoPlayURL
tplFuncs["mediaTypeLabel"] = videoser.MediaTypeLabel
tplFuncs["episodeLabel"] = videoser.EpisodeLabel
engine.SetFuncMap(tplFuncs)
engine.SetHTMLTemplate(mustHTMLTemplate(tplFuncs))
engine.Use(strictTransportSecurity(), staticCacheHeaders(), crawlerHeaders())
engine.StaticFS("/static", http.FS(staticassets.FS))
}
// strictTransportSecurity 仅在请求实际通过 HTTPS 到达,或可信代理标记首个协议为 HTTPS 时启用 HSTS。
// 不声明 includeSubDomains/preload,避免把尚未确认支持 HTTPS 的子域一并纳入。
func strictTransportSecurity() gin.HandlerFunc {
return func(c *gin.Context) {
if isHTTPSRequest(c.Request) {
c.Header("Strict-Transport-Security", "max-age=31536000")
}
c.Next()
}
}
func isHTTPSRequest(r *http.Request) bool {
if r == nil {
return false
}
if r.TLS != nil {
return true
}
proto := r.Header.Get("X-Forwarded-Proto")
if i := strings.IndexByte(proto, ','); i >= 0 {
proto = proto[:i]
}
return strings.EqualFold(strings.TrimSpace(proto), "https")
}
// crawlerHeaders 防止接口和媒体工具响应进入搜索索引。
func crawlerHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/h5/") ||
path == "/enkey" || path == "/newenkey" || path == "/healthz" {
c.Header("X-Robots-Tag", "noindex, nofollow")
}
c.Next()
}
}
// 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)
}
// 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()
}
}
func registerRoutes(r *gin.Engine) {
// 页面路由与 template/pages 文件名对应:camelCase → kebab-case(如 popularRecommend → /popular-recommend)
r.GET("/", web.Index)
r.GET("/search-mobile", web.SearchMobile)
r.GET("/search", web.Search)
r.GET("/search/:q", web.Search)
r.GET("/search/:q/page/:page", web.Search)
r.GET("/profile", web.Profile)
r.GET("/history", web.History)
r.GET("/history/page/:page", web.History)
r.POST("/api/playlet/history/clear", web.HistoryClear)
r.POST("/api/playlet/history/delete", web.HistoryDelete)
r.POST("/api/video/history/clear", web.VideoHistoryClear)
r.POST("/api/video/history/delete", web.VideoHistoryDelete)
r.GET("/favorites", web.Favorites)
r.GET("/favorites/page/:page", web.Favorites)
r.GET("/favorites/:filter", web.Favorites)
r.GET("/favorites/:filter/page/:page", web.Favorites)
r.GET("/purchased", web.PurchasedSeries)
r.GET("/purchased/page/:page", web.PurchasedSeries)
r.GET("/setting", web.Setting)
r.GET("/settings", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/setting")
})
r.GET("/recharge", web.Recharge)
r.GET("/about", web.About)
r.GET("/help", web.Help)
r.GET("/terms", web.Terms)
r.GET("/privacy", web.Privacy)
r.GET("/2257", web.Compliance2257)
r.GET("/dmca", web.DMCA)
r.GET("/download-app", web.DownloadApp)
r.GET("/nav", web.Nav)
r.GET("/nav/", web.Nav)
r.GET("/dh", web.Nav)
r.GET("/dh/", web.Nav)
r.GET("/list", web.List)
r.GET("/list/:theme", web.List)
r.GET("/list/:theme/:updateStatus/:sortType", web.List)
r.GET("/list/:theme/:updateStatus/:sortType/page/:page", web.List)
r.GET("/more-list/:configId/:timeRange/:theme/page/:page", web.MoreList)
r.GET("/more-list/:configId/:timeRange/:theme", web.MoreList)
r.GET("/more-list/:configId/page/:page", web.MoreList)
r.GET("/more-list/:configId", web.MoreList)
r.GET("/more-series-list/:configId/:timeRange/:theme/page/:page", web.MoreSeriesList)
r.GET("/more-series-list/:configId/:timeRange/:theme", web.MoreSeriesList)
r.GET("/more-series-list/:configId/page/:page", web.MoreSeriesList)
r.GET("/more-series-list/:configId", web.MoreSeriesList)
r.GET("/series", web.Series)
r.GET("/series/:media/:topic/:sortType", web.Series)
r.GET("/series/:media/:topic/:sortType/page/:page", web.Series)
r.GET("/topic", web.Topic)
r.GET("/topic/page/:page", web.Topic)
r.GET("/topic/sort/:sortType", web.Topic)
r.GET("/topic/sort/:sortType/page/:page", web.Topic)
r.GET("/topic-detail/:slug/sort/:sortType/page/:page", web.TopicDetail)
r.GET("/topic-detail/:slug/sort/:sortType", web.TopicDetail)
r.GET("/topic-detail/:slug/page/:page", web.TopicDetail)
r.GET("/topic-detail/:slug", web.TopicDetail)
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("/tags/:slug/sort/:sortType/page/:page", web.Tags)
r.GET("/tags/:slug/sort/:sortType", web.Tags)
r.GET("/tags/:slug/page/:page", web.Tags)
r.GET("/tags/:slug", web.Tags)
r.GET("/recently-added", web.RecentlyAdded)
r.GET("/recently-added/page/:page", web.RecentlyAdded)
r.GET("/popular-recommend", web.PopularRecommend)
r.GET("/popular-recommend/page/:page", web.PopularRecommend)
r.GET("/popular-recommend/:rankType", web.PopularRecommend)
r.GET("/popular-recommend/:rankType/page/:page", web.PopularRecommend)
r.GET("/play/:slug", web.Play)
r.GET("/play/:slug/:ep", web.Play)
r.GET("/play/:slug/recommendations/page/:guessPage", web.Play)
r.GET("/play/:slug/:ep/recommendations/page/:guessPage", web.Play)
r.GET("/series-play/:slug", web.SeriesPlay)
r.GET("/series-play/:slug/recommendations/page/:guessPage", web.SeriesPlay)
r.GET("/login", web.LoginPage)
r.GET("/register", web.RegisterPage)
r.GET("/feedback", web.Feedback)
// 认证 BFF:SSR 进页已自动游客;前端登录/注册/补游客会话走以下接口。
r.POST("/api/auth/guest", web.Guest)
r.POST("/api/auth/login", web.Login)
r.POST("/api/auth/register", web.Register)
r.POST("/api/auth/logout", web.Logout)
r.POST("/api/auth/profile", web.UpdateProfile)
r.POST("/api/recharge/coin/buy", web.RechargeCoinBuy)
r.POST("/api/coin/buy", web.RechargeCoinBuy)
r.GET("/api/recharge/transactions", web.RechargeTransactions)
r.GET("/api/coin/trans/list", web.RechargeTransactions)
r.GET("/api/coin/trans/export", web.RechargeCoinTransExport)
r.GET("/api/recharge/transactions/export", web.RechargeCoinTransExport)
r.POST("/api/upload/image", web.UploadImage)
r.POST("/api/feedback/add", web.FeedbackAdd)
// 播放页 BFF
r.GET("/api/playlet/guess-like/list", web.GuessLikeList)
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/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/chapter/unlock", web.ChapterUnlock)
r.POST("/api/playlet/like/set", web.PlayletLikeSet)
r.POST("/api/playlet/collect/set", web.PlayletCollectSet)
r.POST("/api/video/like/set", web.VideoLikeSet)
r.POST("/api/video/collect/set", web.VideoCollectSet)
r.GET("/api/web/search/suggest", web.SearchSuggest)
// 搜索引擎抓取、发现与站点所有权验证。
r.GET("/robots.txt", web.RobotsTXT)
r.GET("/llms.txt", web.LLMSTXT)
if key := seoser.VerifyFromConfig().IndexNowKey; key != "" {
r.GET("/"+key+".txt", web.SEOVerifyFile)
}
r.NoRoute(web.NotFound)
}
+34
View File
@@ -0,0 +1,34 @@
package router
import (
"html/template"
"io/fs"
"path/filepath"
"strings"
webtpl "videoweb/app/template"
)
func mustHTMLTemplate(funcMap template.FuncMap) *template.Template {
tmpl := template.New("").Funcs(funcMap)
err := fs.WalkDir(webtpl.FS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
if !strings.HasSuffix(strings.ToLower(d.Name()), ".html") {
return nil
}
body, err := webtpl.FS.ReadFile(path)
if err != nil {
return err
}
_, err = tmpl.New(filepath.Base(path)).Parse(string(body))
return err
})
if err != nil {
panic(err)
}
if tmpl.Lookup("index.html") == nil {
panic("embedded html templates are empty")
}
return tmpl
}
+187
View File
@@ -0,0 +1,187 @@
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
}
// 上游对 /enkey、/newenkey 会 301 到带尾斜杠路径;GetLargeFile 不跟随跳转。
if !strings.HasSuffix(keyPath, "/") {
keyPath += "/"
}
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
}
+80
View File
@@ -0,0 +1,80 @@
package router
import (
"os"
"path/filepath"
"strings"
"videoweb/app/api/web"
"github.com/gin-gonic/gin"
)
// 其他项目(hgdjnet-server)写入 H5 目录的 sitemap 产物路径。
var sitemapShardDirs = []string{
"./template/pages/sitemaps",
}
var sitemapIndexFiles = []string{
"./template/pages/sitemaps/sitemap.xml",
"./template/pages/sitemap.xml",
}
// registerSitemapRoutes 只暴露静态 sitemap,不在本项目生成。
func registerSitemapRoutes(r *gin.Engine) {
r.GET("/sitemap.xml", serveSitemapIndex)
r.GET("/sitemaps/:name", serveSitemapShard)
}
func serveSitemapIndex(c *gin.Context) {
path, ok := firstExistingFile(sitemapIndexFiles...)
if !ok {
web.NotFound(c)
return
}
c.File(path)
}
func serveSitemapShard(c *gin.Context) {
name := c.Param("name")
if !isValidSitemapShardName(name) {
web.NotFound(c)
return
}
var candidates []string
for _, dir := range sitemapShardDirs {
candidates = append(candidates, filepath.Join(dir, name))
}
path, ok := firstExistingFile(candidates...)
if !ok {
web.NotFound(c)
return
}
c.File(path)
}
func firstExistingFile(paths ...string) (string, bool) {
for _, path := range paths {
info, err := os.Stat(path)
if err == nil && !info.IsDir() {
return path, true
}
}
return "", false
}
// isValidSitemapShardName 校验分片文件名,防止路径遍历。
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
}
+182
View File
@@ -0,0 +1,182 @@
package server
import (
"fmt"
"io/ioutil"
"net/http"
"os"
"os/signal"
"path/filepath"
"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) {
chdirToAppRoot()
showVersion()
if err := initConfig(); err != nil {
log.Error("init config failed", log.E(err))
os.Exit(1)
}
initHttpServer()
initDHServer()
//tgbot.SendToProgram("启动", "app启动完毕")
shutdownHandler()
}
// chdirToAppRoot 发版后 supervisor 的工作目录经常不是二进制所在目录。
// 仅当二进制旁边已有 conf/app.yaml 时才切换,避免本地 go run 切到临时目录。
func chdirToAppRoot() {
exe, err := os.Executable()
if err != nil {
return
}
dir := filepath.Dir(exe)
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
dir = resolved
}
if _, err := os.Stat(filepath.Join(dir, "conf", "app.yaml")); err != nil {
return
}
if err := os.Chdir(dir); err != nil {
log.Warn("chdir app root failed", log.E(err), log.Any("dir", dir))
}
}
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
}
+290
View File
@@ -0,0 +1,290 @@
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 只持久化 SSR 与前端登录态实际依赖的字段。
// 个人简介、邮箱、邀请码、展示卡信息由 getInfo 即时提供,不再随每个页面响应重复写入;
// sid 已存在于请求时也无需原样回写。ClearAuthCookies 仍保留这些历史键用于兼容清理。
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, "avatar", user.Avatar, 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, "userType", strconv.Itoa(user.Type), secure)
isReg := "0"
if user.IsRegister || user.Type == 1 {
isReg = "1"
}
setCookie(c, "isRegister", isReg, secure)
// 后续 SSR 服务必须能立即读取本次刚建立/刷新的会话;同时记录为本请求的
// 权威结果,避免 HTML 渲染阶段再次调用 getInfo 并重复写整套 Cookie。
patchAuthCookiesToRequest(c, user)
rememberRequestAuthUser(c, user)
}
func setCookie(c *gin.Context, name, value string, secure bool) {
// Cookie Value 仅允许 ASCII;中文昵称等需 URL 编码。
// 使用 PathEscape(空格→%20),与前端 encodeURIComponent 一致;
// 避免 QueryEscape 的空格→+,否则 JS decodeURIComponent 会留下字面量 "+"。
writeCookie(c, &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),
})
}
// writeCookie 对同一响应中同名、同 Path、同 Domain 的 Cookie 做 upsert,避免会话恢复的
// 「先清除、再重建」产生两套响应头,同时保留其他作用域下可能存在的同名 Cookie。
func writeCookie(c *gin.Context, cookie *http.Cookie) {
if c == nil || c.Writer == nil || cookie == nil || cookie.Name == "" {
return
}
header := c.Writer.Header()
existing := header.Values("Set-Cookie")
if len(existing) > 0 {
header.Del("Set-Cookie")
for _, raw := range existing {
if sameCookieScope(raw, cookie) {
continue
}
header.Add("Set-Cookie", raw)
}
}
http.SetCookie(c.Writer, cookie)
}
func sameCookieScope(raw string, target *http.Cookie) bool {
response := &http.Response{Header: http.Header{"Set-Cookie": []string{raw}}}
for _, existing := range response.Cookies() {
if existing.Name == target.Name && existing.Path == target.Path && strings.EqualFold(existing.Domain, target.Domain) {
return true
}
}
return false
}
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) {
forgetRequestAuthUser(c)
for _, name := range authCookieNames() {
writeCookie(c, &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 ""
}
+314
View File
@@ -0,0 +1,314 @@
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 user := freshRequestAuthUser(c); user != nil {
return user
}
if requestAutoAuthSuppressed(c) {
return nil
}
// 已有任意会话(游客或正式账号)则跳过
if useragent.TokenFromRequest(c.Request) != "" {
return nil
}
// 手动退出:本次进页不自动游客,并清除标记,供下次进页恢复
if IsGuestSkipAuto(c) {
ClearGuestSkipAuto(c)
dropRequestCookie(c, guestSkipAutoCookie)
suppressRequestAutoAuth(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)
return user
}
// SyncUserOnEnter 是 SSR 页面统一的会话同步入口。同一请求已经通过游客登录、
// getInfo 或其他认证流程取得过最新用户时直接复用,避免再次访问上游和重复写 Cookie。
func SyncUserOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if user := freshRequestAuthUser(c); user != nil {
return user
}
if requestAutoAuthSuppressed(c) {
return nil
}
if useragent.TokenFromRequest(c.Request) == "" {
return EnsureGuestOnEnter(c)
}
return RefreshUserOnEnter(c)
}
// RefreshUserOnEnter 页面进入时用 GET /api/app/auth/getInfo 刷新用户信息并写回 cookie。
// 保证刷新后头部昵称、头像、金币等与上游一致(如解锁后余额变化)。
func RefreshUserOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if user := freshRequestAuthUser(c); user != nil {
return user
}
if requestAutoAuthSuppressed(c) {
return nil
}
token := useragent.TokenFromRequest(c.Request)
if token == "" {
return nil
}
env, user, err := GetInfo(c)
if err != nil {
log.Error("refresh user on enter failed", log.E(err))
return nil
}
if env != nil && IsSessionExpiredCode(env.Code) {
if retry, _ := OnSessionExpired(c, env.Code); retry {
// 游客重建已经返回完整用户并写好 Cookie,无需立刻再 getInfo 一次。
if fresh := freshRequestAuthUser(c); fresh != nil {
return fresh
}
env, user, err = GetInfo(c)
if err != nil {
log.Error("refresh user on enter retry failed", log.E(err))
return nil
}
} else {
return nil
}
}
if env == nil || env.Code != 200 || user == nil {
return nil
}
if strings.TrimSpace(user.Token) == "" {
user.Token = useragent.TokenFromRequest(c.Request)
if user.Token == "" {
user.Token = token
}
}
SetAuthCookies(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
}
forgetRequestAuthUser(c)
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
}
suppressRequestAutoAuth(c)
secure := c.Request != nil && c.Request.TLS != nil
writeCookie(c, &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
}
writeCookie(c, &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)
}
// ResolveChannel 统一解析渠道码并按首次归因落盘。
// 优先级:已有 channel cookie > 调用方传入值 > dc/ch/pc 查询参数;已有渠道不覆盖。
func ResolveChannel(c *gin.Context, candidate string) string {
channel := strings.TrimSpace(candidate)
if c == nil || c.Request == nil {
return channel
}
if stored := cookieValue(c, "channel"); stored != "" {
return stored
}
if channel == "" {
for _, key := range []string{"dc", "ch", "pc"} {
if value := strings.TrimSpace(c.Query(key)); value != "" {
channel = value
break
}
}
}
if channel == "" {
return ""
}
secure := c.Request.TLS != nil
setCookie(c, "channel", channel, secure)
patchRequestCookie(c, "channel", channel)
return channel
}
// guestChannel 为首屏服务端自动游客登录补渠道;cookie 用于复用前端首次归因结果。
func guestChannel(c *gin.Context) string {
return ResolveChannel(c, "")
}
func patchAuthCookiesToRequest(c *gin.Context, user *LoginResp) {
if c == nil || user == nil {
return
}
if strings.TrimSpace(user.Token) != "" {
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 ""
}
+345
View File
@@ -0,0 +1,345 @@
package authser
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"videoweb/app/appg"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
var authCookieNamesWritten = []string{
"token",
"userId",
"accountNo",
"nickName",
"avatar",
"recharge",
"vipType",
"currentVipExpire",
"userType",
"isRegister",
}
func TestSyncUserOnEnterCreatesGuestWithoutRefreshingIt(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case guestPath:
guestCalls.Add(1)
if r.Method != http.MethodPost {
t.Errorf("guest method = %s, want POST", r.Method)
}
var request GuestLoginReq
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode guest request: %v", err)
}
if request.DevID == "" || request.DeviceID != request.DevID {
t.Errorf("guest device ids = %#v, want matching non-empty ids", request)
}
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "new-guest"))
case getInfoPath:
getInfoCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "unexpected-refresh"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext()
user := SyncUserOnEnter(c)
if user == nil || user.Token != "guest-token" {
t.Fatalf("SyncUserOnEnter user = %#v, want guest-token", user)
}
if got := guestCalls.Load(); got != 1 {
t.Errorf("guest calls = %d, want 1", got)
}
if got := getInfoCalls.Load(); got != 0 {
t.Errorf("getInfo calls = %d, want 0", got)
}
assertRequestToken(t, c, "guest-token")
assertSingleAuthCookieSet(t, recorder)
}
func TestSyncUserOnEnterRefreshesExistingTokenOnlyOnce(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
authorization := make(chan string, 1)
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case guestPath:
guestCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("unexpected-guest-token", "unexpected-guest"))
case getInfoPath:
getInfoCalls.Add(1)
select {
case authorization <- r.Header.Get("Authorization"):
default:
}
// getInfo may omit token; the existing request token must be preserved.
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("", "fresh-user"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext(
&http.Cookie{Name: "token", Value: "existing-token"},
&http.Cookie{Name: "sid", Value: "existing-sid"},
)
first := SyncUserOnEnter(c)
second := SyncUserOnEnter(c)
if first == nil || first.Token != "existing-token" || first.NickName != "fresh-user" {
t.Fatalf("first SyncUserOnEnter user = %#v, want refreshed user with existing token", first)
}
if second == nil || second.Token != "existing-token" || second.NickName != "fresh-user" {
t.Fatalf("second SyncUserOnEnter user = %#v, want memoized refreshed user", second)
}
if got := guestCalls.Load(); got != 0 {
t.Errorf("guest calls = %d, want 0", got)
}
if got := getInfoCalls.Load(); got != 1 {
t.Errorf("getInfo calls = %d, want 1", got)
}
select {
case got := <-authorization:
if got != "existing-token" {
t.Errorf("Authorization = %q, want existing-token", got)
}
default:
t.Error("getInfo did not capture Authorization header")
}
assertRequestToken(t, c, "existing-token")
if got := cookieValue(c, "nickName"); got != "fresh-user" {
t.Errorf("request nickName cookie = %q, want fresh-user", got)
}
assertSingleAuthCookieSet(t, recorder)
}
func TestSyncUserOnEnterReusesGuestCreatedBeforeRender(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case guestPath:
guestCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "pre-render-guest"))
case getInfoPath:
getInfoCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "unexpected-refresh"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext()
guest := EnsureGuestOnEnter(c)
user := SyncUserOnEnter(c)
if guest == nil || guest.Token != "pre-render-token" {
t.Fatalf("EnsureGuestOnEnter user = %#v, want pre-render guest", guest)
}
if user == nil || user.Token != "pre-render-token" || user.NickName != "pre-render-guest" {
t.Fatalf("SyncUserOnEnter user = %#v, want memoized pre-render guest", user)
}
if got := guestCalls.Load(); got != 1 {
t.Errorf("guest calls = %d, want 1", got)
}
if got := getInfoCalls.Load(); got != 0 {
t.Errorf("getInfo calls = %d, want 0", got)
}
assertRequestToken(t, c, "pre-render-token")
assertSingleAuthCookieSet(t, recorder)
}
func TestRefreshUserOnEnterReturnsRebuiltGuestWithoutRefreshingItAgain(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
getInfoAuthorization := make(chan string, 1)
guestAuthorization := make(chan string, 1)
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case getInfoPath:
getInfoCalls.Add(1)
select {
case getInfoAuthorization <- r.Header.Get("Authorization"):
default:
}
writeAuthTestEnvelope(w, http.StatusOK, 6001, nil)
case guestPath:
guestCalls.Add(1)
select {
case guestAuthorization <- r.Header.Get("Authorization"):
default:
}
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("rebuilt-guest-token", "rebuilt-guest"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext(
&http.Cookie{Name: "token", Value: "expired-guest-token"},
&http.Cookie{Name: "isRegister", Value: "0"},
&http.Cookie{Name: "userType", Value: "0"},
)
user := RefreshUserOnEnter(c)
// The rebuilt guest must also be reusable by the page-level sync call.
synced := SyncUserOnEnter(c)
if user == nil || user.Token != "rebuilt-guest-token" || user.NickName != "rebuilt-guest" {
t.Fatalf("RefreshUserOnEnter user = %#v, want rebuilt guest", user)
}
if synced == nil || synced.Token != "rebuilt-guest-token" {
t.Fatalf("SyncUserOnEnter user = %#v, want memoized rebuilt guest", synced)
}
if got := getInfoCalls.Load(); got != 1 {
t.Errorf("getInfo calls = %d, want 1 (no refresh of rebuilt guest)", got)
}
if got := guestCalls.Load(); got != 1 {
t.Errorf("guest calls = %d, want 1", got)
}
select {
case got := <-getInfoAuthorization:
if got != "expired-guest-token" {
t.Errorf("getInfo Authorization = %q, want expired-guest-token", got)
}
default:
t.Error("getInfo did not capture Authorization header")
}
select {
case got := <-guestAuthorization:
if got != "" {
t.Errorf("guest Authorization = %q, want empty after expired token is removed", got)
}
default:
t.Error("guest login did not capture Authorization header")
}
assertRequestToken(t, c, "rebuilt-guest-token")
assertSingleAuthCookieSet(t, recorder)
}
func TestSetAuthCookiesUpsertsSameNameWithinResponse(t *testing.T) {
c, recorder := newAuthTestContext()
SetAuthCookies(c, testLoginUser("first-token", "first-user"))
SetAuthCookies(c, testLoginUser("latest-token", "latest-user"))
assertSingleAuthCookieSet(t, recorder)
assertRequestToken(t, c, "latest-token")
if got := cookieValue(c, "nickName"); got != "latest-user" {
t.Errorf("request nickName cookie = %q, want latest-user", got)
}
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == "token" && cookie.Value != "latest-token" {
t.Errorf("response token cookie = %q, want latest-token", cookie.Value)
}
}
}
func useAuthTestUpstream(t *testing.T, handler http.Handler) {
t.Helper()
server := httptest.NewServer(handler)
previousServerAddr := appg.Conf.ServerAddr
appg.Conf.ServerAddr = server.URL
t.Cleanup(func() {
appg.Conf.ServerAddr = previousServerAddr
server.Close()
})
}
func newAuthTestContext(cookies ...*http.Cookie) (*gin.Context, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "https://site.test/page", nil)
for _, cookie := range cookies {
c.Request.AddCookie(cookie)
}
return c, recorder
}
func writeAuthTestEnvelope(w http.ResponseWriter, status, code int, data *LoginResp) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *LoginResp `json:"data"`
}{
Code: code,
Msg: http.StatusText(status),
Data: data,
})
}
func testLoginUser(token, nickName string) *LoginResp {
return &LoginResp{
Token: token,
UserID: 101,
AccountNo: "account-101",
Email: "user101@example.test",
NickName: nickName,
// This field is intentionally large: it must stay out of Set-Cookie headers.
Introduction: strings.Repeat("简介", 150),
Avatar: "https://cdn.example.test/avatar.png",
InviteCode: "INVITE101",
VipType: 1,
CurrentVipExpire: "2099-01-01T00:00:00Z",
ShowCardID: 7,
ShowCardName: "monthly",
Type: 0,
Recharge: 88,
}
}
func assertRequestToken(t *testing.T, c *gin.Context, want string) {
t.Helper()
if got := useragent.TokenFromRequest(c.Request); got != want {
t.Errorf("request token = %q, want %q", got, want)
}
}
func assertSingleAuthCookieSet(t *testing.T, recorder *httptest.ResponseRecorder) {
t.Helper()
counts := make(map[string]int)
for _, cookie := range recorder.Result().Cookies() {
counts[cookie.Name]++
}
for name, count := range counts {
if count > 1 {
t.Errorf("Set-Cookie %q written %d times, want at most once", name, count)
}
}
for _, name := range authCookieNamesWritten {
if got := counts[name]; got != 1 {
t.Errorf("auth Set-Cookie %q written %d times, want exactly once", name, got)
}
}
for _, name := range []string{"introduction", "email", "inviteCode", "showCardId", "showCardName", "sid"} {
if got := counts[name]; got != 0 {
t.Errorf("non-session Set-Cookie %q written %d times, want none", name, got)
}
}
var headerBytes int
for _, raw := range recorder.Header().Values("Set-Cookie") {
headerBytes += len("Set-Cookie: ") + len(raw) + len("\r\n")
}
if headerBytes >= 4096 {
t.Errorf("Set-Cookie response headers = %d bytes, want below 4096", headerBytes)
}
}
+67
View File
@@ -0,0 +1,67 @@
package authser
import "github.com/gin-gonic/gin"
const requestAuthStateKey = "authser.request-auth-state"
// requestAuthState 只在单个 Gin 请求内有效,用来避免多个 SSR 服务重复刷新同一会话。
// freshUser 表示本请求已经从上游取得并写回过的最新用户;suppressAutoAuth
// 表示本请求明确不应再自动创建游客(例如用户刚退出或正式账号已过期)。
type requestAuthState struct {
freshUser *LoginResp
suppressAutoAuth bool
}
func authState(c *gin.Context) *requestAuthState {
if c == nil {
return nil
}
if value, ok := c.Get(requestAuthStateKey); ok {
if state, ok := value.(*requestAuthState); ok && state != nil {
return state
}
}
state := &requestAuthState{}
c.Set(requestAuthStateKey, state)
return state
}
func freshRequestAuthUser(c *gin.Context) *LoginResp {
state := authState(c)
if state == nil {
return nil
}
return state.freshUser
}
func rememberRequestAuthUser(c *gin.Context, user *LoginResp) {
state := authState(c)
if state == nil {
return
}
state.freshUser = user
state.suppressAutoAuth = false
}
func forgetRequestAuthUser(c *gin.Context) {
if state := authState(c); state != nil {
state.freshUser = nil
}
}
func suppressRequestAutoAuth(c *gin.Context) {
if state := authState(c); state != nil {
state.suppressAutoAuth = true
}
}
func allowRequestAutoAuth(c *gin.Context) {
if state := authState(c); state != nil {
state.suppressAutoAuth = false
}
}
func requestAutoAuthSuppressed(c *gin.Context) bool {
state := authState(c)
return state != nil && state.suppressAutoAuth
}
+58
View File
@@ -0,0 +1,58 @@
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_")
// 正式账号失效后,本请求保持退出态,避免后续 HTML 渲染又静默转成游客。
suppressRequestAutoAuth(c)
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)
// 内容读取接口明确允许降级为游客,因此覆盖本请求可能已有的自动认证抑制。
allowRequestAutoAuth(c)
return EnsureGuestOnEnter(c) != nil
}
+95
View File
@@ -0,0 +1,95 @@
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"` // 是否已注册
HasPassword *bool `json:"hasPassword,omitempty"` // 是否已设置密码;上游未返回时由账号状态推断
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"`
}
+97
View File
@@ -0,0 +1,97 @@
package authser
import (
"fmt"
"strings"
)
// SettingPageVM 账号设置页展示模型。
type SettingPageVM struct {
UserID int64
AccountNo string
HasAccount bool
HasPassword bool
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)
registered := user.IsRegister || user.Type == 1
hasAccount := registered && account != ""
hasPassword := hasAccount
if user.HasPassword != nil {
hasPassword = *user.HasPassword
}
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,
HasAccount: hasAccount,
HasPassword: hasPassword,
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
}
+143
View File
@@ -0,0 +1,143 @@
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
}
q.ObjectType = NormalizeObjectType(q.ObjectType)
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/profile/avatar.png"
}
return avatar
}
+43
View File
@@ -0,0 +1,43 @@
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")
}
req.ObjectType = NormalizeObjectType(req.ObjectType)
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,
}
}
+83
View File
@@ -0,0 +1,83 @@
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
// ObjectTypeVideo 视频评论
ObjectTypeVideo = 20
SortHot = 1 // 热门
SortLatest = 2 // 最新
)
// NormalizeObjectType 校正评论对象类型:10-短剧 11-短剧剧集 20-视频。
func NormalizeObjectType(objectType int) int {
switch objectType {
case ObjectTypeChapter, ObjectTypeVideo:
return objectType
default:
return ObjectTypePlaylet
}
}
+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}
}
+56
View File
@@ -0,0 +1,56 @@
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))
if configCache != nil {
return *configCache
}
return ApplyTrackerFallback(AppConfigVM{})
}
vm := ApplyTrackerFallback(BuildAppConfigVM(info))
configCache = &vm
configAt = time.Now()
return vm
}
+130
View File
@@ -0,0 +1,130 @@
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 返回富文本标题对应的站内 canonical 地址。
func RichTextHref(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "/richtext"
}
if href, ok := RichTextSemanticHref(title); ok {
return href
}
return "/richtext/" + url.PathEscape(title)
}
// RichTextSemanticHref 将固定栏目及上游常用别名收敛到唯一语义路由。
func RichTextSemanticHref(title string) (string, bool) {
normalized := strings.ToLower(strings.Join(strings.Fields(title), ""))
switch normalized {
case "关于我们", "aboutus":
return "/about", true
case "用户协议", "用户条款", "useragreement", "termsofservice":
return "/terms", true
case "隐私政策", "privacy", "privacypolicy":
return "/privacy", true
case "2257合规声明", "2257", "usc2257", "合规声明":
return "/2257", true
case "dmca版权声明", "dmca", "版权声明", "版权投诉":
return "/dmca", true
case "帮助中心", "疑问解答", "常见问题", "help", "helpcenter", "faq":
return "/help", true
case "问题反馈", "意见反馈", "联系我们", "feedback", "contact", "contactus":
return "/feedback", true
default:
return "", false
}
}
+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
}
+25
View File
@@ -0,0 +1,25 @@
package configser
import (
"strings"
"videoweb/app/appg"
)
// ResolveTrackerAppID 优先 yaml,否则固定 JHA-1045。
func ResolveTrackerAppID() string {
return appg.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
}
+185
View File
@@ -0,0 +1,185 @@
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 子集,按页面需要取用)。
// 文档:http://172.104.181.99:25319/swagger/index.html#/APP配置相关接口/get_api_app_config_info
type ConfigInfo struct {
AppAddr string `json:"appAddr"`
OfficialAppAddr string `json:"officialAppAddr"`
ForeverURLAddr string `json:"foreverUrlAddr"`
NewURLAddr string `json:"newUrlAddr"`
NewURLAddr1 string `json:"newUrlAddr1"`
NewURLAddr2 string `json:"newUrlAddr2"`
GmailAddr string `json:"gmailAddr"`
QQ string `json:"qq"`
WeiChart string `json:"weiChart"`
TGGroup string `json:"tgGroup"`
Telegram string `json:"telegram"`
TelegramAdd string `json:"telegramAdd"`
PotaoGroup string `json:"potaoGroup"`
CustomerServiceHost string `json:"customerServiceHost"`
PDFAddr string `json:"pdfAddr"`
TwitterAddr string `json:"twitterAddr"`
GitAddr string `json:"gitAddr"`
FreeVPN string `json:"freeVPN"`
}
// 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"`
AdvertiseCode string `json:"advertiseCode"`
AdvertiseLocationCode string `json:"advertiseLocationCode"`
AdvertiseLocationName string `json:"advertiseLocationName"`
}
// 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"`
}
+244
View File
@@ -0,0 +1,244 @@
package configser
import (
"strconv"
"strings"
)
// AppConfigVM 注入全站页面的公共配置(字段名与接口 newAddressInfo / seoInfo 对齐)。
type AppConfigVM struct {
// Config 为 data.config 原样字段(页脚社交等优先用此对象)。
Config ConfigInfo `json:"config"`
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"`
TelegramAdd string `json:"telegramAdd"`
PotaoGroup string `json:"potaoGroup"`
CustomerServiceHost string `json:"customerServiceHost"`
AppAddr string `json:"appAddr"`
OfficialAppAddr string `json:"officialAppAddr"`
PDFAddr string `json:"pdfAddr"`
TwitterAddr string `json:"twitterAddr"`
GitAddr string `json:"gitAddr"`
FreeVPN string `json:"freeVPN"`
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 从上游配置组装前端可用字段;社交/地址优先取 data.config。
func BuildAppConfigVM(info *ConfigInfoResp) AppConfigVM {
if info == nil {
return AppConfigVM{}
}
cfg := sanitizeConfigInfo(info.Config)
addr := info.NewAddressInfo
return AppConfigVM{
Config: cfg,
ForeverURLAddr: firstNonEmptyURL(cfg.ForeverURLAddr, addr.ForeverURLAddr),
NewURLAddr: firstNonEmptyURL(cfg.NewURLAddr, addr.NewURLAddr),
NewURLAddr1: firstNonEmptyURL(cfg.NewURLAddr1, addr.NewURLAddr1),
NewURLAddr2: firstNonEmptyURL(cfg.NewURLAddr2, addr.NewURLAddr2),
GmailAddr: firstNonEmpty(normalizeEmailAddress(cfg.GmailAddr), normalizeEmailAddress(addr.GmailAddr)),
QQ: firstNonEmpty(cfg.QQ, strings.TrimSpace(addr.QQ)),
TGGroup: firstNonEmptyURL(cfg.TGGroup, addr.TGGroup),
Telegram: firstNonEmptyURL(cfg.Telegram, addr.Telegram),
TelegramAdd: sanitizeURL(cfg.TelegramAdd),
PotaoGroup: firstNonEmptyURL(cfg.PotaoGroup, addr.PotaoGroup),
CustomerServiceHost: firstNonEmptyURL(cfg.CustomerServiceHost, addr.CustomerServiceHost),
AppAddr: firstNonEmptyURL(cfg.AppAddr, addr.AppAddr),
OfficialAppAddr: firstNonEmptyURL(cfg.OfficialAppAddr, addr.OfficialAppAddr),
PDFAddr: firstNonEmptyURL(cfg.PDFAddr, addr.PDFAddr),
TwitterAddr: firstNonEmptyURL(cfg.TwitterAddr, addr.TwitterAddr),
GitAddr: sanitizeURL(cfg.GitAddr),
FreeVPN: sanitizeURL(cfg.FreeVPN),
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 sanitizeConfigInfo(cfg ConfigInfo) ConfigInfo {
return ConfigInfo{
AppAddr: sanitizeURL(cfg.AppAddr),
OfficialAppAddr: sanitizeURL(cfg.OfficialAppAddr),
ForeverURLAddr: sanitizeURL(cfg.ForeverURLAddr),
NewURLAddr: sanitizeURL(cfg.NewURLAddr),
NewURLAddr1: sanitizeURL(cfg.NewURLAddr1),
NewURLAddr2: sanitizeURL(cfg.NewURLAddr2),
GmailAddr: normalizeEmailAddress(cfg.GmailAddr),
QQ: strings.TrimSpace(cfg.QQ),
WeiChart: strings.TrimSpace(cfg.WeiChart),
TGGroup: sanitizeURL(cfg.TGGroup),
Telegram: sanitizeURL(cfg.Telegram),
TelegramAdd: sanitizeURL(cfg.TelegramAdd),
PotaoGroup: sanitizeURL(cfg.PotaoGroup),
CustomerServiceHost: sanitizeURL(cfg.CustomerServiceHost),
PDFAddr: sanitizeURL(cfg.PDFAddr),
TwitterAddr: sanitizeURL(cfg.TwitterAddr),
GitAddr: sanitizeURL(cfg.GitAddr),
FreeVPN: sanitizeURL(cfg.FreeVPN),
}
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
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.AdvertiseCode = strings.TrimSpace(item.AdvertiseCode)
item.AdvertiseLocationCode = strings.TrimSpace(item.AdvertiseLocationCode)
item.AdvertiseLocationName = strings.TrimSpace(item.AdvertiseLocationName)
if item.AdvertiseCode != "" {
item.ID = item.AdvertiseCode
}
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)
}
// normalizeEmailAddress 兼容上游把邮箱误配为 mailto/http(s) 地址的情况。
func normalizeEmailAddress(value string) string {
value = strings.TrimSpace(value)
for {
lower := strings.ToLower(value)
switch {
case strings.HasPrefix(lower, "mailto:"):
value = strings.TrimSpace(value[len("mailto:"):])
case strings.HasPrefix(lower, "https://"):
value = strings.TrimSpace(value[len("https://"):])
case strings.HasPrefix(lower, "http://"):
value = strings.TrimSpace(value[len("http://"):])
default:
return value
}
}
}
func firstNonEmptyURL(values ...string) string {
for _, v := range values {
if u := sanitizeURL(v); u != "" {
return u
}
}
return ""
}
// TelegramURL 客服/商务 TG:优先 telegram,其次 telegramAdd、tgGroup。
func (c ConfigInfo) TelegramURL() string {
return firstNonEmptyURL(c.Telegram, c.TelegramAdd, c.TGGroup)
}
// TelegramHandle 从 TG 链接提取展示用 @用户名;无法解析时返回空。
func (c ConfigInfo) TelegramHandle() string {
raw := c.TelegramURL()
raw = strings.TrimRight(raw, "/")
if i := strings.LastIndex(raw, "/"); i >= 0 && i+1 < len(raw) {
raw = raw[i+1:]
}
raw = strings.TrimPrefix(strings.TrimSpace(raw), "@")
if raw == "" || strings.Contains(raw, ".") || strings.Contains(raw, "?") {
return ""
}
return "@" + raw
}
+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-其他
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"`
}
+56
View File
@@ -0,0 +1,56 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const boughtListPath = "/api/app/playlet/bought/list"
// PlayletBoughtData 对应 proto.PlayletBoughtData;只列出页面所需字段。
type PlayletBoughtData struct {
ID int64 `json:"id"`
Title string `json:"title"`
PlayName string `json:"playName"`
Slug string `json:"slug"`
Cover string `json:"cover"`
ThemeNames []string `json:"themeNames"`
TotalEpisode int `json:"totalEpisode"`
BoughtChapterCount int `json:"boughtChapterCount"`
CostCoins int64 `json:"costCoins"`
BoughtAt string `json:"boughtAt"`
LastChapterID int64 `json:"lastChapterId"`
LastEpisode int `json:"lastEpisode"`
WatchedCount int `json:"watchedCount"`
}
type PlayletBoughtListResp struct {
List []PlayletBoughtData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
func FetchBoughtList(c *gin.Context, pageNum, pageSize int) (*PlayletBoughtListResp, int, error) {
if pageNum < 1 {
pageNum = 1
}
if pageSize < 1 {
pageSize = 20
}
params := url.Values{}
params.Set("pageNum", strconv.Itoa(pageNum))
params.Set("pageSize", strconv.Itoa(pageSize))
var env APIEnvelope[PlayletBoughtListResp]
if err := upstream.GetJSON(c, boughtListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet bought list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
+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,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)
}
+272
View File
@@ -0,0 +1,272 @@
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 时取第 1 集(currentEpisode=1),否则取列表首条。
func ResolvePlayChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
if chapterID > 0 {
return chapterID
}
if playlet == nil {
return 0
}
var firstID int64
for _, ch := range playlet.PlayletChapterInfo {
if ch.ID <= 0 {
continue
}
if firstID == 0 {
firstID = ch.ID
}
if ch.CurrentEpisode == 1 {
return ch.ID
}
}
return firstID
}
// FindChapter 在详情选集中按 chapterId 查找。
func FindChapter(playlet *PlayletInfoResp, chapterID int64) *PlayletChapterData {
if playlet == nil || chapterID <= 0 {
return nil
}
for i := range playlet.PlayletChapterInfo {
if playlet.PlayletChapterInfo[i].ID == chapterID {
return &playlet.PlayletChapterInfo[i]
}
}
return nil
}
// NextChapterID 返回下一集 chapterId;没有则 0。
func NextChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
if playlet == nil || chapterID <= 0 {
return 0
}
found := false
for _, ch := range playlet.PlayletChapterInfo {
if found && ch.ID > 0 {
return ch.ID
}
if ch.ID == chapterID {
found = true
}
}
return 0
}
// FirstUnlockCost 取第一个需金币解锁的单集价格。
func FirstUnlockCost(chapters []PlayletChapterData) int {
for _, ch := range chapters {
if ch.NeedCoinGate() {
return ch.UnlockCost()
}
}
return 0
}
// ChapterFromList 将详情选集条目转成章节详情结构(chapter/detail 失败时的 UI 兜底)。
func ChapterFromList(ch PlayletChapterData) *PlayletChapterInfoResp {
return &PlayletChapterInfoResp{
ID: ch.ID,
PlayletID: ch.PlayletID,
CurrentEpisode: ch.CurrentEpisode,
Title: ch.Title,
MD5: ch.MD5,
Hash: ch.Hash,
Height: ch.Height,
Width: ch.Width,
Ratio: ch.Ratio,
VideoURL: ch.VideoURL,
MediaSize: ch.MediaSize,
MediaTime: ch.MediaTime,
PayType: ch.PayType,
SingleCoins: ch.SingleCoins,
CostCoins: ch.CostCoins,
Balance: ch.Balance,
CanWatch: ch.CanWatch,
IsUnlocked: ch.IsUnlocked,
NeedUnlock: ch.NeedUnlock,
NeedVip: ch.NeedVip,
VipValid: ch.VipValid,
}
}
// 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
}
+161
View File
@@ -0,0 +1,161 @@
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"`
PlayName string `json:"playName"`
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
WatchStatus int // 0 全部、1 追剧中、2 已看完;仅短剧收藏列表使用
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
}
if q.WatchStatus < 0 || q.WatchStatus > 2 {
q.WatchStatus = 0
}
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))
params.Set("watchStatus", strconv.Itoa(q.WatchStatus))
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}
}

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