Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-22 14:47:17 +07:00
co-authored by Claude Opus 5.5
commit 3639d2184a
574 changed files with 61690 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
package applistser
import (
"fmt"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const listPath = "/api/app/applist/list"
// FetchList 拉取应用分类列表。categoryId=0 表示默认。
func FetchList(c *gin.Context, categoryID int64) (*AppListResp, error) {
path := fmt.Sprintf("%s?categoryId=%d", listPath, categoryID)
var env APIEnvelope[AppListResp]
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("applist code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchListOrEmpty 失败时返回空结构,保证 SSR 可渲染。
func FetchListOrEmpty(c *gin.Context, categoryID int64) *AppListResp {
data, err := FetchList(c, categoryID)
if err != nil {
log.Error("fetch applist failed", log.E(err))
return &AppListResp{}
}
if data == nil {
return &AppListResp{}
}
return data
}
+38
View File
@@ -0,0 +1,38 @@
package applistser
// AppListResp 对应 swagger proto.AppListResp 的业务字段(忽略无关 config/advitise)。
type AppListResp struct {
Category AppCategoryInfo `json:"category"`
CategoryList []AppCategoryInfo `json:"categoryList"`
}
// AppCategoryInfo 对应 swagger proto.AppCategoryInfo
type AppCategoryInfo struct {
ID int64 `json:"id"`
Code int `json:"code"`
Title string `json:"title"`
Intro string `json:"intro"`
Sort int `json:"sort"`
AppList []AppInfoData `json:"appList"`
}
// AppInfoData 对应 swagger proto.AppInfoData
type AppInfoData struct {
ID int64 `json:"id"`
CategoryID int64 `json:"categoryId"`
Name string `json:"name"`
Intro string `json:"intro"`
Description string `json:"description"`
Logo string `json:"logo"`
URL string `json:"url"`
Sort int `json:"sort"`
AdvitiseID string `json:"advitiseId"`
Location int `json:"location"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
+83
View File
@@ -0,0 +1,83 @@
package applistser
import (
"fmt"
"strings"
)
// PageVM 福利应用页模板数据
type PageVM struct {
Categories []CategoryVM
}
type CategoryVM struct {
ID int64
SectionID string
Title string
Intro string
Total int
Apps []AppCardVM
IsFirst bool
}
type AppCardVM struct {
ID int64
Name string
Intro string
Logo string
URL string
}
// BuildPageVM 按接口返回的 categoryList / appList 顺序组装页面数据,不做二次排序。
func BuildPageVM(data *AppListResp) PageVM {
vm := PageVM{Categories: []CategoryVM{}}
if data == nil {
return vm
}
list := data.CategoryList
if len(list) == 0 && data.Category.ID > 0 {
list = []AppCategoryInfo{data.Category}
}
if len(list) == 0 {
return vm
}
for i, cat := range list {
cards := make([]AppCardVM, 0, len(cat.AppList))
for _, app := range cat.AppList {
name := strings.TrimSpace(app.Name)
if name == "" {
continue
}
href := strings.TrimSpace(app.URL)
if href == "" {
href = "#"
}
intro := strings.TrimSpace(app.Intro)
if intro == "" {
intro = strings.TrimSpace(app.Description)
}
cards = append(cards, AppCardVM{
ID: app.ID,
Name: name,
Intro: intro,
Logo: strings.TrimSpace(app.Logo),
URL: href,
})
}
title := strings.TrimSpace(cat.Title)
if title == "" {
title = "应用分类"
}
vm.Categories = append(vm.Categories, CategoryVM{
ID: cat.ID,
SectionID: fmt.Sprintf("app-category-%d", cat.ID),
Title: title,
Intro: strings.TrimSpace(cat.Intro),
Total: len(cards),
Apps: cards,
IsFirst: i == 0,
})
}
return vm
}
+113
View File
@@ -0,0 +1,113 @@
package authser
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const (
guestPath = "/api/app/auth/guest"
cookieMaxAge = 7 * 24 * 3600
)
// GuestLogin 调用上游游客登录,站点不提供注册/登录,上游内容接口依赖游客会话。
func GuestLogin(c *gin.Context, req GuestLoginReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, guestPath, req, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
func decodeLoginData(raw json.RawMessage) (*LoginResp, error) {
if len(raw) == 0 || string(raw) == "null" || string(raw) == `""` {
return nil, fmt.Errorf("empty login data")
}
var user LoginResp
if err := json.Unmarshal(raw, &user); err != nil {
return nil, err
}
return &user, nil
}
// SetAuthCookies 写入游客会话 cookie。
func SetAuthCookies(c *gin.Context, user *LoginResp) {
if c == nil || user == nil {
return
}
secure := c.Request != nil && c.Request.TLS != nil
if strings.TrimSpace(user.Token) != "" {
setCookie(c, "token", user.Token, secure)
}
if sid := useragent.SIDFromRequest(c.Request); sid != "" {
setCookie(c, "sid", sid, secure)
}
}
func setCookie(c *gin.Context, name, value string, secure bool) {
// Cookie Value 仅允许 ASCII;使用 PathEscape(空格→%20),与前端 encodeURIComponent 一致。
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: url.PathEscape(value),
Path: "/",
MaxAge: cookieMaxAge,
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(7 * 24 * time.Hour),
})
}
func cookieValue(c *gin.Context, name string) string {
ck, err := c.Request.Cookie(name)
if err != nil || ck == nil {
return ""
}
raw := strings.TrimSpace(ck.Value)
if raw == "" {
return ""
}
// QueryUnescape 同时兼容历史 QueryEscape(+)与 PathEscape(%20)
if decoded, err := url.QueryUnescape(raw); err == nil {
return strings.TrimSpace(decoded)
}
return raw
}
// ClearAuthCookies 清除会话 cookie,同时清理旧版账号体系遗留的用户信息 cookie。
func ClearAuthCookies(c *gin.Context) {
for _, name := range authCookieNames() {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}
}
func authCookieNames() []string {
return []string{
"token", "_token_", "sid", "_s_token_",
"userId", "accountNo", "nickName", "introduction", "avatar",
"email", "inviteCode", "recharge", "vipType", "currentVipExpire", "showCardId", "showCardName",
"userType", "isRegister", "guest_skip_auto",
}
}
+137
View File
@@ -0,0 +1,137 @@
package authser
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const guestDevIDCookie = "guest_dev_id"
// EnsureGuestOnEnter 页面进入时:无 token 则走游客登录并写 cookie。
// 对应上游 POST /api/app/auth/guest。成功后会把会话补进当前 Request,便于同一次 SSR 直接使用。
func EnsureGuestOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
// 已有会话则跳过
if useragent.TokenFromRequest(c.Request) != "" {
return nil
}
devID := ensureGuestDevID(c)
env, user, err := GuestLogin(c, GuestLoginReq{
DevID: devID,
DeviceID: devID,
Channel: guestChannel(c),
})
if err != nil {
log.Error("ensure guest on enter failed", log.E(err))
return nil
}
if env == nil || env.Code != 200 || user == nil || strings.TrimSpace(user.Token) == "" {
tip := ""
if env != nil {
tip = firstNonEmptyTip(env.Tip, env.Msg)
}
log.Error("ensure guest on enter rejected", log.E(fmt.Errorf("code=%v tip=%s", envCode(env), tip)))
return nil
}
SetAuthCookies(c, user)
patchRequestCookie(c, "token", user.Token)
return user
}
func dropRequestCookie(c *gin.Context, name string) {
if c == nil || c.Request == nil || name == "" {
return
}
var parts []string
for _, ck := range c.Request.Cookies() {
if ck == nil || ck.Name == name {
continue
}
parts = append(parts, ck.Name+"="+ck.Value)
}
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
}
func ensureGuestDevID(c *gin.Context) string {
if id := cookieValue(c, guestDevIDCookie); id != "" {
return id
}
id := newGuestDevID()
secure := c.Request != nil && c.Request.TLS != nil
setCookie(c, guestDevIDCookie, id, secure)
patchRequestCookie(c, guestDevIDCookie, id)
return id
}
func newGuestDevID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
now := time.Now()
return fmt.Sprintf("%016X%d", uint64(now.UnixNano()), now.UnixMilli())
}
return strings.ToUpper(hex.EncodeToString(b)) + strconv.FormatInt(time.Now().UnixMilli(), 10)
}
// guestChannel 为首屏服务端自动游客登录补渠道;cookie 用于复用前端首次归因结果。
func guestChannel(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
if channel := cookieValue(c, "channel"); channel != "" {
return channel
}
for _, key := range []string{"dc", "ch", "pc"} {
if channel := strings.TrimSpace(c.Query(key)); channel != "" {
secure := c.Request.TLS != nil
setCookie(c, "channel", channel, secure)
patchRequestCookie(c, "channel", channel)
return channel
}
}
return ""
}
func patchRequestCookie(c *gin.Context, name, value string) {
if c == nil || c.Request == nil || name == "" {
return
}
escaped := url.PathEscape(value)
var parts []string
for _, ck := range c.Request.Cookies() {
if ck == nil || ck.Name == name {
continue
}
parts = append(parts, ck.Name+"="+ck.Value)
}
parts = append(parts, name+"="+escaped)
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
}
func envCode(env *APIEnvelope) int {
if env == nil {
return 0
}
return env.Code
}
func firstNonEmptyTip(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+28
View File
@@ -0,0 +1,28 @@
package authser
import "github.com/gin-gonic/gin"
// TokenExpiredCode 上游约定:token 失效。
const TokenExpiredCode = 1000
// IsSessionExpiredCode 是否为会话失效类业务码。
func IsSessionExpiredCode(code int) bool {
switch code {
case TokenExpiredCode, 1002, 1012, 6001: // InvalidToken / NoToken / InvalidateToken / 用户不存在
return true
default:
return false
}
}
// RecoverContentSession 会话失效后清理旧 cookie 并重新建立游客会话,调用方随后重试一次原请求。
func RecoverContentSession(c *gin.Context, code int) bool {
if c == nil || !IsSessionExpiredCode(code) {
return false
}
ClearAuthCookies(c)
for _, name := range authCookieNames() {
dropRequestCookie(c, name)
}
return EnsureGuestOnEnter(c) != nil
}
+26
View File
@@ -0,0 +1,26 @@
package authser
import "encoding/json"
// GuestLoginReq 对应 swagger authser.GuestLoginReq
// POST /api/app/auth/guest
type GuestLoginReq struct {
DevID string `json:"devID"`
DeviceID string `json:"deviceId,omitempty"`
Mac string `json:"mac,omitempty"`
Channel string `json:"channel,omitempty"`
}
// LoginResp 游客登录成功 data(只取会话所需字段)。
type LoginResp struct {
Token string `json:"token"`
UserID int64 `json:"userId"`
}
// APIEnvelope 上游统一响应壳。Data 在失败时可能是 string/null。
type APIEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data json.RawMessage `json:"data"`
}
+145
View File
@@ -0,0 +1,145 @@
package commentser
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
listPath = "/api/app/comment/list"
defaultCommentSize = 10
)
// ListQuery 评论列表查询参数。
type ListQuery struct {
ObjectID int64
ObjectType int
PageNum int
PageSize int
SortOrder int
}
// Normalize 校正分页与排序默认值。
func (q *ListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultCommentSize
}
if q.PageSize > 50 {
q.PageSize = 50
}
if q.ObjectType != ObjectTypeChapter {
q.ObjectType = ObjectTypePlaylet
}
if q.SortOrder != SortLatest {
q.SortOrder = SortHot
}
}
// FetchList 分页查询评论。
func FetchList(c *gin.Context, q ListQuery) (*GetCommentListResp, error) {
q.Normalize()
if q.ObjectID <= 0 {
return nil, fmt.Errorf("comment list invalid objectId")
}
params := url.Values{}
params.Set("objectId", strconv.FormatInt(q.ObjectID, 10))
params.Set("objectType", strconv.Itoa(q.ObjectType))
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
authser.EnsureGuestOnEnter(c)
requestPath := listPath + "?" + params.Encode()
env, err := fetchListOnce(c, requestPath)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) && authser.RecoverContentSession(c, env.Code) {
env, err = fetchListOnce(c, requestPath)
if err != nil {
return nil, err
}
}
if env.Code != 200 {
return nil, fmt.Errorf("comment list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
func fetchListOnce(c *gin.Context, path string) (*APIEnvelope[GetCommentListResp], error) {
var env APIEnvelope[GetCommentListResp]
if err := upstream.GetJSON(c, path, &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchListOrEmpty 失败时返回空列表。
func FetchListOrEmpty(c *gin.Context, q ListQuery) *GetCommentListResp {
data, err := FetchList(c, q)
if err != nil {
log.Error("fetch comment list failed", log.E(err))
return &GetCommentListResp{
ObjectID: q.ObjectID,
PageNum: q.PageNum,
PageSize: q.PageSize,
}
}
if data == nil {
return &GetCommentListResp{
ObjectID: q.ObjectID,
PageNum: q.PageNum,
PageSize: q.PageSize,
}
}
return data
}
// ParseSortOrder 解析站点评论排序(1 热门 / 2 最新)。
func ParseSortOrder(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n != SortLatest {
return SortHot
}
return SortLatest
}
// FormatCreatedAt 展示用时间格式化(非字段映射)。
func FormatCreatedAt(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return t.Local().Format("2006/1/2 15:04")
}
}
return raw
}
// AvatarOrDefault 头像空时回退默认图。
func AvatarOrDefault(avatar string) string {
avatar = strings.TrimSpace(avatar)
if avatar == "" {
return "/static/images/avatar.png"
}
return avatar
}
+11
View File
@@ -0,0 +1,11 @@
package commentser
import "html/template"
// TemplateFuncs 评论展示辅助。
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"formatCommentTime": FormatCreatedAt,
"commentAvatar": AvatarOrDefault,
}
}
+71
View File
@@ -0,0 +1,71 @@
package commentser
// CommentSendReq 对应 swagger proto.CommentSendReq
// parentsId 为 0 发一级评论,大于 0 发二级评论;replyCommentId 为实际被回复的评论。
type CommentSendReq struct {
ObjectID int64 `json:"objectId"`
ObjectType int `json:"objectType"`
Text string `json:"text"`
ParentsID int64 `json:"parentsId"`
ReplyCommentID int64 `json:"replyCommentId"`
}
// CommentLikeSetReq 对应 swagger proto.CommentLikeSetReq
type CommentLikeSetReq struct {
CommentID int64 `json:"commentId"`
IsLike bool `json:"isLike"`
}
// CommentLikeSetResp 对应 swagger proto.CommentLikeSetResp
type CommentLikeSetResp struct {
CommentID int64 `json:"commentId"`
CountLike int64 `json:"countLike"`
IsLike int `json:"isLike"`
}
// CommentInfo 对应 swagger proto.CommentInfo
type CommentInfo struct {
ID int64 `json:"id"`
ObjectID int64 `json:"objectId"`
ObjectType int `json:"objectType"`
Text string `json:"text"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
UserAvatar string `json:"userAvatar"`
CountLike int64 `json:"countLike"`
CommentCount int64 `json:"commentCount"`
IsLike int `json:"isLike"`
CreatedAt string `json:"createdAt"`
Level int `json:"level,omitempty"`
ParentsID int64 `json:"parentsId,omitempty"`
ReplyCommentID int64 `json:"replyCommentId,omitempty"`
ReplyUserName string `json:"replyUserName,omitempty"`
Children []CommentInfo `json:"children,omitempty"`
}
// GetCommentListResp 对应 swagger proto.GetCommentListResp
type GetCommentListResp struct {
List []CommentInfo `json:"list"`
ObjectID int64 `json:"objectId"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data T `json:"data"`
}
const (
// ObjectTypePlaylet 短剧评论
ObjectTypePlaylet = 10
// ObjectTypeChapter 短剧剧集评论
ObjectTypeChapter = 11
SortHot = 1 // 热门
SortLatest = 2 // 最新
)
+51
View File
@@ -0,0 +1,51 @@
package configser
import (
"fmt"
"sync"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const footerPath = "/api/app/config/footer/info"
const footerCacheTTL = 60 * time.Second
var (
footerMu sync.Mutex
footerCache *FooterInfo
footerAt time.Time
)
// FetchFooter 拉取底部配置,返回与 swagger proto.FooterInfo 一致的结构。
func FetchFooter(c *gin.Context) (*FooterInfo, error) {
var env APIEnvelope[FooterInfoResp]
if err := upstream.GetPublicJSON(c, footerPath, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("footer info code=%d msg=%s", env.Code, env.Msg)
}
info := env.Data.FooterInfo
return &info, nil
}
// FetchFooterOrEmpty 带短缓存;失败或空数据时返回空结构,不做写死兜底/字段映射。
func FetchFooterOrEmpty(c *gin.Context) FooterInfo {
footerMu.Lock()
defer footerMu.Unlock()
if footerCache != nil && time.Since(footerAt) < footerCacheTTL {
return *footerCache
}
info, err := FetchFooter(c)
if err != nil {
log.Error("fetch footer info failed", log.E(err))
return FooterInfo{}
}
footerCache = info
footerAt = time.Now()
return *info
}
+88
View File
@@ -0,0 +1,88 @@
package configser
import (
"fmt"
"strings"
)
// HomeRoadLinkVM 是“回家的路”页面中的可访问线路。
type HomeRoadLinkVM struct {
Label string
URL string
}
// HomeRoadContactVM 是防失联联系方式。
type HomeRoadContactVM struct {
Title string
Description string
Action string
URL string
}
// HomeRoadPageVM 将 config/info 中的地址字段整理成页面所需结构。
type HomeRoadPageVM struct {
MainURL string
Routes []HomeRoadLinkVM
Permanent []HomeRoadLinkVM
Contacts []HomeRoadContactVM
}
func firstHomeRoadURL(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return "/"
}
// BuildHomeRoadPageVM 使用服务端动态配置组装“回家的路”页面。
func BuildHomeRoadPageVM(cfg AppConfigVM) HomeRoadPageVM {
mainURL := firstHomeRoadURL(cfg.NewURLAddr, cfg.ForeverURLAddr)
routes := make([]HomeRoadLinkVM, 0, 3)
for _, item := range cfg.NavPageList {
if len(routes) == 3 {
break
}
if url := strings.TrimSpace(item.URL); url != "" {
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: url})
}
}
for _, url := range []string{cfg.NewURLAddr, cfg.NewURLAddr1, cfg.NewURLAddr2} {
if len(routes) == 3 {
break
}
if url = strings.TrimSpace(url); url != "" {
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: url})
}
}
for len(routes) < 3 {
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: mainURL})
}
permanent := make([]HomeRoadLinkVM, 0, 3)
for _, item := range []HomeRoadLinkVM{
{Label: "永久地址", URL: cfg.ForeverURLAddr},
{Label: "永久中转页", URL: cfg.NewURLAddr},
{Label: "最新中转页(免翻墙)", URL: cfg.NewURLAddr1},
} {
item.URL = strings.TrimSpace(item.URL)
if item.URL != "" {
permanent = append(permanent, item)
}
}
email := strings.TrimSpace(cfg.GmailAddr)
emailURL := ""
if email != "" {
emailURL = "mailto:" + email
}
contacts := []HomeRoadContactVM{
{Title: "✉ 邮箱自动回复", Description: "发送任意邮件到下方地址,30秒内自动回复当前所有可用入口的最新汇总。", Action: firstHomeRoadURL(email, "huangguoaiai@gmail.com") + " →", URL: firstHomeRoadURL(emailURL, "mailto:huangguoaiai@gmail.com")},
{Title: "➤ TG 官方交流群", Description: "获取域名变更通知的首选渠道,实时播报并加入用户互助社区。", Action: "立即订阅最新动态 →", URL: firstHomeRoadURL(cfg.TGGroup, "/")},
{Title: "◆ 官方商务合作", Description: "联系官方商务合作,洽谈商务事宜,精诚合作,财富共赢。", Action: "立即联系 →", URL: firstHomeRoadURL(cfg.Telegram, "/")},
{Title: "𝕏 推特社区", Description: "关注官方推特社区,第一时间获取地址变更与官方公告。", Action: "快速访问 →", URL: firstHomeRoadURL(cfg.TwitterAddr, "/")},
}
return HomeRoadPageVM{MainURL: mainURL, Routes: routes, Permanent: permanent, Contacts: contacts}
}
+40
View File
@@ -0,0 +1,40 @@
package configser
import "testing"
func TestBuildHomeRoadPageVMUsesDynamicConfig(t *testing.T) {
cfg := AppConfigVM{
NewURLAddr: "https://main.example.com",
NewURLAddr1: "https://relay.example.com",
NewURLAddr2: "https://latest.example.com",
ForeverURLAddr: "https://forever.example.com",
PDFAddr: "https://publish.example.com",
GmailAddr: "home@example.com",
TGGroup: "https://t.me/example",
Telegram: "https://t.me/business-example",
CustomerServiceHost: "https://service.example.com",
TwitterAddr: "https://x.com/example",
NavPageList: []NavPageItem{
{Line: 1, URL: "https://line1.example.com"},
{Line: 2, URL: "https://line2.example.com"},
{Line: 3, URL: "https://line3.example.com"},
},
}
vm := BuildHomeRoadPageVM(cfg)
if vm.MainURL != cfg.NewURLAddr || len(vm.Routes) != 3 || vm.Routes[1].URL != "https://line2.example.com" {
t.Fatalf("unexpected route VM: %#v", vm)
}
if len(vm.Permanent) != 3 || vm.Permanent[1].URL != cfg.NewURLAddr || vm.Permanent[2].URL != cfg.NewURLAddr1 {
t.Fatalf("unexpected permanent links: %#v", vm.Permanent)
}
if len(vm.Contacts) != 4 || vm.Contacts[0].URL != "mailto:home@example.com" || vm.Contacts[2].URL != cfg.Telegram {
t.Fatalf("unexpected contacts: %#v", vm.Contacts)
}
}
func TestBuildHomeRoadPageVMSkipsEmptyPermanentAddresses(t *testing.T) {
vm := BuildHomeRoadPageVM(AppConfigVM{NewURLAddr: "https://relay.example.com"})
if len(vm.Permanent) != 1 || vm.Permanent[0].Label != "永久中转页" {
t.Fatalf("unexpected permanent links: %#v", vm.Permanent)
}
}
+53
View File
@@ -0,0 +1,53 @@
package configser
import (
"fmt"
"sync"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
configInfoPath = "/api/app/config/info"
configInfoCacheTTL = 60 * time.Second
)
var (
configMu sync.Mutex
configCache *AppConfigVM
configAt time.Time
)
// FetchConfigInfo 拉取 APP 公共配置。
func FetchConfigInfo(c *gin.Context) (*ConfigInfoResp, error) {
var env APIEnvelope[ConfigInfoResp]
if err := upstream.GetPublicJSON(c, configInfoPath, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("config info code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchAppConfigVMOrEmpty 带短缓存,失败时返回空配置。
func FetchAppConfigVMOrEmpty(c *gin.Context) AppConfigVM {
configMu.Lock()
defer configMu.Unlock()
if configCache != nil && time.Since(configAt) < configInfoCacheTTL {
return *configCache
}
info, err := FetchConfigInfo(c)
if err != nil {
log.Error("fetch app config info failed", log.E(err))
return AppConfigVM{}
}
vm := BuildAppConfigVM(info)
configCache = &vm
configAt = time.Now()
return vm
}
+104
View File
@@ -0,0 +1,104 @@
package configser
import (
"fmt"
"html/template"
"net/url"
"strings"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const richtextListPath = "/api/app/config/richtext/list"
// RichTextPageVM 富文本页模板数据。
type RichTextPageVM struct {
Title string
Subtitle string
ContentHTML template.HTML
Found bool
}
// FetchRichTextList 查询富文本列表(ids / titles 逗号分隔)。
func FetchRichTextList(c *gin.Context, ids, titles string) ([]AppRichTextItemResp, error) {
params := url.Values{}
if s := strings.TrimSpace(ids); s != "" {
params.Set("ids", s)
}
if s := strings.TrimSpace(titles); s != "" {
params.Set("titles", s)
}
path := richtextListPath
if q := params.Encode(); q != "" {
path += "?" + q
}
var env APIEnvelope[AppRichTextListResp]
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("richtext list code=%d msg=%s", env.Code, env.Msg)
}
return env.Data.List, nil
}
// FetchRichTextByTitle 按标题取第一条启用内容;失败返回空页 VM。
func FetchRichTextByTitle(c *gin.Context, title string) RichTextPageVM {
title = strings.TrimSpace(title)
vm := RichTextPageVM{Title: title}
if title == "" {
return vm
}
list, err := FetchRichTextList(c, "", title)
if err != nil {
log.Error("fetch richtext by title failed", log.E(err), log.Any("title", title))
return vm
}
item := matchRichTextByTitle(list, title)
if item == nil {
return vm
}
displayTitle := strings.TrimSpace(item.Desc)
if displayTitle == "" {
displayTitle = strings.TrimSpace(item.Title)
}
if displayTitle == "" {
displayTitle = title
}
vm.Found = true
vm.Title = displayTitle
vm.Subtitle = strings.TrimSpace(item.Desc)
if vm.Subtitle == displayTitle {
vm.Subtitle = ""
}
vm.ContentHTML = template.HTML(item.Content)
return vm
}
func matchRichTextByTitle(list []AppRichTextItemResp, title string) *AppRichTextItemResp {
if len(list) == 0 {
return nil
}
for i := range list {
if strings.TrimSpace(list[i].Title) == title || strings.TrimSpace(list[i].Desc) == title {
return &list[i]
}
}
return &list[0]
}
// RichTextHref 页脚导航跳转一律进入富文本页。
func RichTextHref(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "/richtext/"
}
return "/richtext/" + title + "/"
}
+19
View File
@@ -0,0 +1,19 @@
package configser
import "testing"
func TestRichTextHrefUsesRichtextRoutes(t *testing.T) {
tests := map[string]string{
"关于我们": "/richtext/关于我们/",
"用户协议": "/richtext/用户协议/",
"隐私政策": "/richtext/隐私政策/",
"帮助中心": "/richtext/帮助中心/",
"问题反馈": "/richtext/问题反馈/",
"自定义内容": "/richtext/自定义内容/",
}
for title, want := range tests {
if got := RichTextHref(title); got != want {
t.Fatalf("RichTextHref(%q) = %q, want %q", title, got, want)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package configser
import (
"bytes"
"encoding/json"
"strings"
)
// UnmarshalJSON 兼容 seoNavInfo 为空字符串,以及 dhKeyworld 为字符串/数组。
func (s *SeoNavInfo) UnmarshalJSON(raw []byte) error {
if s == nil {
return nil
}
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil
}
if raw[0] == '"' {
var encoded string
if err := json.Unmarshal(raw, &encoded); err != nil {
return err
}
encoded = strings.TrimSpace(encoded)
if encoded == "" {
return nil
}
inner := bytes.TrimSpace([]byte(encoded))
if len(inner) == 0 || inner[0] != '{' {
return nil
}
return s.UnmarshalJSON(inner)
}
var aux struct {
DhTitle string `json:"dhTitle"`
DhDescription string `json:"dhDescription"`
DhSiteName string `json:"dhSiteName"`
DhKeyworld json.RawMessage `json:"dhKeyworld"`
Keywords json.RawMessage `json:"keywords"`
}
if err := json.Unmarshal(raw, &aux); err != nil {
return err
}
s.DhTitle = strings.TrimSpace(aux.DhTitle)
s.DhDescription = strings.TrimSpace(aux.DhDescription)
s.DhSiteName = strings.TrimSpace(aux.DhSiteName)
s.DhKeyworld = decodeNavKeywords(aux.DhKeyworld, aux.Keywords)
return nil
}
func decodeNavKeywords(values ...json.RawMessage) []string {
for _, raw := range values {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
continue
}
if raw[0] == '[' {
var list []string
if err := json.Unmarshal(raw, &list); err != nil {
continue
}
out := make([]string, 0, len(list))
for _, item := range list {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, item)
}
}
if len(out) > 0 {
return out
}
continue
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
continue
}
s = strings.TrimSpace(s)
if s == "" {
continue
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
if len(out) > 0 {
return out
}
}
return nil
}
+162
View File
@@ -0,0 +1,162 @@
package configser
// FooterInfoResp 对应 swagger proto.FooterInfoResp
type FooterInfoResp struct {
FooterInfo FooterInfo `json:"footerInfo"`
}
// FooterInfo 对应 swagger proto.FooterInfo
// GET /api/app/config/footer/info — 模板直接使用本结构,不做自定义 VM 映射。
type FooterInfo struct {
Brand FooterBrand `json:"brand"`
NavList []TranslationCategory `json:"navList"`
ContactList []FooterContact `json:"contactList"`
}
// FooterBrand 对应 swagger proto.FooterBrand
type FooterBrand struct {
Title string `json:"title"`
Content string `json:"content"`
}
// TranslationCategory 对应 swagger proto.TranslationCategory(footer.navList 元素)
type TranslationCategory struct {
ID int64 `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
URLValue string `json:"urlValue"`
CategoryType int `json:"categoryType"`
TranslationList []TranslationInfo `json:"translationList"`
SeoInfo SeoValue `json:"seoInfo"`
}
// SeoValue 对应 swagger pubdef.SeoValue(导航分类 seoInfo)
type SeoValue struct {
Title string `json:"title"`
Keyworld []string `json:"keyworld"`
Description string `json:"description"`
}
// TranslationInfo 对应 swagger proto.TranslationInfo
type TranslationInfo struct {
Name string `json:"name"`
URLValue string `json:"urlValue"`
IsFriendAds bool `json:"isFriendAds"`
}
// FooterContact 对应 swagger proto.FooterContact
type FooterContact struct {
Name string `json:"name"`
Value string `json:"value"`
}
// ConfigInfoResp 对应 swagger proto.ConfigInfoResp
// GET /api/app/config/info
// config 为上游实际下发的完整站点配置(swagger 未完整声明,但含 potaoGroup 等字段)。
type ConfigInfoResp struct {
NewAddressInfo NewAddressInfo `json:"newAddressInfo"`
Config ConfigInfo `json:"config"`
Advitise AdvitiseData `json:"advitise"`
SeoInfo SeoInfo `json:"seoInfo"`
SeoNavInfo SeoNavInfo `json:"seoNavInfo"`
NavPageList []NavPageItem `json:"navPageList"`
}
// ConfigInfo 对应上游 data.config(proto.ConfigInfo 子集,按页面需要取用)。
type ConfigInfo struct {
PotaoGroup string `json:"potaoGroup"`
}
// AdvitiseData 对应 GET /api/app/config/info 返回的完整 advitise 数据。
type AdvitiseData struct {
HomeList []Advertisement `json:"homeList"`
DetailTopList []Advertisement `json:"detailTopList"`
NineGridList []Advertisement `json:"nineGridList"`
DetailBannerList []Advertisement `json:"detailbannerList"`
LaunchScreenList []Advertisement `json:"launchScreenList"`
FloatingList []Advertisement `json:"floatingList"`
AList []Advertisement `json:"AList"`
BList []Advertisement `json:"BList"`
DialogList []Advertisement `json:"dialogList"`
VideoInfoList []Advertisement `json:"videoInfoList"`
VideoList []Advertisement `json:"videoList"`
DownList []Advertisement `json:"downList"`
FriendList []Advertisement `json:"friendList"`
HomeBanner []Advertisement `json:"homeBanner"`
BottomBanner []Advertisement `json:"bottomBanner"`
TitleList []Advertisement `json:"titleList"`
PlayList []Advertisement `json:"playList"`
PauseyList []Advertisement `json:"pauseyList"`
}
// Advertisement 对应 swagger proxy.AdvitiseInfo。
type Advertisement struct {
ID string `json:"id"`
Images string `json:"images"`
Link string `json:"link"`
Location int `json:"location"`
Title string `json:"title"`
}
// NewAddressInfo 对应 swagger proto.NewAddressInfo(GET /api/app/config/info)。
type NewAddressInfo struct {
NewURLAddr string `json:"newUrlAddr"`
NewURLAddr1 string `json:"newUrlAddr1"`
NewURLAddr2 string `json:"newUrlAddr2"`
ForeverURLAddr string `json:"foreverUrlAddr"`
GmailAddr string `json:"gmailAddr"`
QQ string `json:"qq"`
TGGroup string `json:"tgGroup"`
Telegram string `json:"telegram"`
PotaoGroup string `json:"potaoGroup"`
CustomerServiceHost string `json:"customerServiceHost"`
AppAddr string `json:"appAddr"`
OfficialAppAddr string `json:"officialAppAddr"`
PDFAddr string `json:"pdfAddr"`
TwitterAddr string `json:"twitterAddr"`
}
// SeoInfo 对应 swagger proto.SeoInfo
type SeoInfo struct {
Title string `json:"title"`
Description string `json:"description"`
Keywords []string `json:"keywords"`
CanonicalURL string `json:"canonicalUrl"`
WebSiteName string `json:"webSiteName"`
}
// SeoNavInfo 对应 swagger proto.SeoNavInfo
// GET /api/app/config/info 的 seoNavInfo。
type SeoNavInfo struct {
DhTitle string `json:"dhTitle"`
DhDescription string `json:"dhDescription"`
DhKeyworld []string `json:"dhKeyworld"`
DhSiteName string `json:"dhSiteName"`
}
// NavPageItem 对应 swagger proto.NavPageItem(GET /api/app/config/info 的 navPageList)。
type NavPageItem struct {
Line int `json:"line"`
URL string `json:"url"`
}
// AppRichTextItemResp 对应 swagger proto.AppRichTextItemResp
// GET /api/app/config/richtext/list
type AppRichTextItemResp struct {
ID int64 `json:"id"`
Title string `json:"title"`
Desc string `json:"desc"`
Content string `json:"content"`
}
// AppRichTextListResp 对应 swagger proto.AppRichTextListResp
type AppRichTextListResp struct {
List []AppRichTextItemResp `json:"list"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
+157
View File
@@ -0,0 +1,157 @@
package configser
import (
"strconv"
"strings"
)
// AppConfigVM 注入全站页面的公共配置(字段名与接口 newAddressInfo / seoInfo 对齐)。
type AppConfigVM struct {
ForeverURLAddr string `json:"foreverUrlAddr"`
NewURLAddr string `json:"newUrlAddr"`
NewURLAddr1 string `json:"newUrlAddr1"`
NewURLAddr2 string `json:"newUrlAddr2"`
GmailAddr string `json:"gmailAddr"`
QQ string `json:"qq"`
TGGroup string `json:"tgGroup"`
Telegram string `json:"telegram"`
PotaoGroup string `json:"potaoGroup"`
CustomerServiceHost string `json:"customerServiceHost"`
AppAddr string `json:"appAddr"`
OfficialAppAddr string `json:"officialAppAddr"`
PDFAddr string `json:"pdfAddr"`
TwitterAddr string `json:"twitterAddr"`
SeoInfo SeoInfo `json:"seoInfo,omitempty"`
SeoNavInfo SeoNavInfo `json:"seoNavInfo,omitempty"`
NavPageList []NavPageItem `json:"navPageList,omitempty"`
Advitise AdvitiseData `json:"advitise"`
DetailTopList []Advertisement `json:"detailTopList,omitempty"`
NineGridList []Advertisement `json:"nineGridList,omitempty"`
DetailBannerList []Advertisement `json:"detailbannerList,omitempty"`
BottomBanner []Advertisement `json:"bottomBanner,omitempty"`
DialogList []Advertisement `json:"dialogList,omitempty"`
}
// BuildAppConfigVM 从上游配置组装前端可用字段。
func BuildAppConfigVM(info *ConfigInfoResp) AppConfigVM {
if info == nil {
return AppConfigVM{}
}
addr := info.NewAddressInfo
return AppConfigVM{
ForeverURLAddr: sanitizeURL(addr.ForeverURLAddr),
NewURLAddr: sanitizeURL(addr.NewURLAddr),
NewURLAddr1: sanitizeURL(addr.NewURLAddr1),
NewURLAddr2: sanitizeURL(addr.NewURLAddr2),
GmailAddr: strings.TrimSpace(addr.GmailAddr),
QQ: strings.TrimSpace(addr.QQ),
TGGroup: sanitizeURL(addr.TGGroup),
Telegram: sanitizeURL(addr.Telegram),
PotaoGroup: sanitizeURL(firstNonEmptyURL(info.Config.PotaoGroup, addr.PotaoGroup)),
CustomerServiceHost: sanitizeURL(addr.CustomerServiceHost),
AppAddr: sanitizeURL(addr.AppAddr),
OfficialAppAddr: sanitizeURL(addr.OfficialAppAddr),
PDFAddr: sanitizeURL(addr.PDFAddr),
TwitterAddr: sanitizeURL(addr.TwitterAddr),
NavPageList: buildNavPageList(info.NavPageList),
Advitise: info.Advitise,
DetailTopList: buildAdvertisementList(info.Advitise.DetailTopList),
NineGridList: buildAdvertisementList(info.Advitise.NineGridList),
DetailBannerList: buildAdvertisementList(info.Advitise.DetailBannerList),
BottomBanner: buildAdvertisementList(info.Advitise.BottomBanner),
DialogList: buildAdvertisementList(info.Advitise.DialogList),
SeoInfo: SeoInfo{
Title: strings.TrimSpace(info.SeoInfo.Title),
Description: strings.TrimSpace(info.SeoInfo.Description),
Keywords: info.SeoInfo.Keywords,
CanonicalURL: strings.TrimSpace(info.SeoInfo.CanonicalURL),
WebSiteName: strings.TrimSpace(info.SeoInfo.WebSiteName),
},
SeoNavInfo: SeoNavInfo{
DhTitle: strings.TrimSpace(info.SeoNavInfo.DhTitle),
DhDescription: strings.TrimSpace(info.SeoNavInfo.DhDescription),
DhKeyworld: info.SeoNavInfo.DhKeyworld,
DhSiteName: strings.TrimSpace(info.SeoNavInfo.DhSiteName),
},
}
}
func buildAdvertisementList(list []Advertisement) []Advertisement {
if len(list) == 0 {
return nil
}
out := make([]Advertisement, 0, len(list))
for _, item := range list {
item.ID = strings.TrimSpace(item.ID)
item.Images = sanitizeURL(item.Images)
item.Link = sanitizeURL(item.Link)
item.Title = strings.TrimSpace(item.Title)
if item.Images == "" {
continue
}
out = append(out, item)
}
return out
}
// NineGridRow 按首页设计每行返回两个 banner,超出当前行时返回空切片。
func (c AppConfigVM) NineGridRow(row int) []Advertisement {
if row < 0 {
return nil
}
start := row * 2
if start >= len(c.NineGridList) {
return nil
}
end := start + 2
if end > len(c.NineGridList) {
end = len(c.NineGridList)
}
return c.NineGridList[start:end]
}
// QRText 下载弹窗二维码、跳转及复制链接内容:仅使用应用下载地址 appAddr。
func (c AppConfigVM) QRText() string {
return strings.TrimSpace(c.AppAddr)
}
func buildNavPageList(list []NavPageItem) []NavPageItem {
if len(list) == 0 {
return nil
}
out := make([]NavPageItem, 0, len(list))
for _, item := range list {
u := sanitizeURL(item.URL)
if u == "" {
continue
}
out = append(out, NavPageItem{Line: item.Line, URL: u})
}
return out
}
func (n NavPageItem) Label() string {
digits := []string{"", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"}
if n.Line >= 1 && n.Line <= 10 {
return "线路" + digits[n.Line]
}
if n.Line > 0 {
return "线路" + strconv.Itoa(n.Line)
}
return "线路"
}
func sanitizeURL(v string) string {
v = strings.TrimSpace(v)
v = strings.TrimRight(v, "\\")
return strings.TrimSpace(v)
}
func firstNonEmptyURL(values ...string) string {
for _, v := range values {
if sanitizeURL(v) != "" {
return v
}
}
return ""
}
+91
View File
@@ -0,0 +1,91 @@
package configser
import "testing"
func TestAppConfigVMQRTextUsesAppAddr(t *testing.T) {
cfg := AppConfigVM{
AppAddr: " https://download.example.com/app ",
ForeverURLAddr: "https://www.example.com/",
}
if got, want := cfg.QRText(), "https://download.example.com/app"; got != want {
t.Fatalf("QRText() = %q, want %q", got, want)
}
}
func TestBuildAppConfigVMIncludesValidAdvertisements(t *testing.T) {
vm := BuildAppConfigVM(&ConfigInfoResp{
NewAddressInfo: NewAddressInfo{Telegram: " https://t.me/business ", QQ: " 2168045324 ", OfficialAppAddr: " https://app.example.com/download\\ "},
Config: ConfigInfo{PotaoGroup: " https://potato.example.com/group\\ "},
Advitise: AdvitiseData{
HomeList: []Advertisement{{ID: "home", Images: "https://cdn.example.com/home.gif"}},
DetailTopList: []Advertisement{{ID: "top", Images: " https://cdn.example.com/top.gif "}},
NineGridList: []Advertisement{
{ID: "banner-1", Images: "https://cdn.example.com/banner-1.gif"},
{ID: "banner-empty", Images: " "},
{ID: "banner-2", Images: "https://cdn.example.com/banner-2.gif"},
{ID: "banner-3", Images: "https://cdn.example.com/banner-3.gif"},
},
DetailBannerList: []Advertisement{{ID: "detail", Images: "https://cdn.example.com/detail.gif"}},
BottomBanner: []Advertisement{
{ID: " bottom ", Images: " https://cdn.example.com/bottom.gif ", Link: " https://example.com/bottom "},
{ID: "bottom-empty", Images: " "},
},
DialogList: []Advertisement{
{ID: " 1 ", Images: " https://cdn.example.com/ad.webp\\ ", Link: " https://example.com ", Title: " 广告一 "},
{ID: "2", Images: " ", Title: "无图广告"},
},
},
})
if vm.Telegram != "https://t.me/business" {
t.Fatalf("Telegram = %q, want sanitized business Telegram URL", vm.Telegram)
}
if vm.PotaoGroup != "https://potato.example.com/group" {
t.Fatalf("PotaoGroup = %q, want sanitized config.potaoGroup", vm.PotaoGroup)
}
if vm.QQ != "2168045324" {
t.Fatalf("QQ = %q, want trimmed QQ group", vm.QQ)
}
if vm.OfficialAppAddr != "https://app.example.com/download" {
t.Fatalf("OfficialAppAddr = %q, want sanitized official app URL", vm.OfficialAppAddr)
}
if len(vm.Advitise.HomeList) != 1 || vm.Advitise.HomeList[0].ID != "home" {
t.Fatalf("Advitise.HomeList = %#v", vm.Advitise.HomeList)
}
if len(vm.Advitise.NineGridList) != 4 {
t.Fatalf("Advitise.NineGridList length = %d, want raw length 4", len(vm.Advitise.NineGridList))
}
if len(vm.DetailTopList) != 1 || vm.DetailTopList[0].ID != "top" {
t.Fatalf("DetailTopList = %#v", vm.DetailTopList)
}
if len(vm.NineGridList) != 3 {
t.Fatalf("NineGridList length = %d, want 3", len(vm.NineGridList))
}
if len(vm.DetailBannerList) != 1 || vm.DetailBannerList[0].ID != "detail" {
t.Fatalf("DetailBannerList = %#v", vm.DetailBannerList)
}
if len(vm.BottomBanner) != 1 || vm.BottomBanner[0].ID != "bottom" || vm.BottomBanner[0].Images != "https://cdn.example.com/bottom.gif" {
t.Fatalf("BottomBanner = %#v", vm.BottomBanner)
}
if len(vm.DialogList) != 1 {
t.Fatalf("DialogList length = %d, want 1", len(vm.DialogList))
}
got := vm.DialogList[0]
if got.ID != "1" || got.Images != "https://cdn.example.com/ad.webp" || got.Link != "https://example.com" || got.Title != "广告一" {
t.Fatalf("DialogList[0] = %#v", got)
}
}
func TestNineGridRowReturnsTwoItemsPerRow(t *testing.T) {
cfg := AppConfigVM{NineGridList: []Advertisement{{ID: "1"}, {ID: "2"}, {ID: "3"}}}
if got := cfg.NineGridRow(0); len(got) != 2 || got[0].ID != "1" || got[1].ID != "2" {
t.Fatalf("NineGridRow(0) = %#v", got)
}
if got := cfg.NineGridRow(1); len(got) != 1 || got[0].ID != "3" {
t.Fatalf("NineGridRow(1) = %#v", got)
}
if got := cfg.NineGridRow(2); len(got) != 0 {
t.Fatalf("NineGridRow(2) = %#v, want empty", got)
}
}
+30
View File
@@ -0,0 +1,30 @@
package feedbackser
import (
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const addPath = "/api/app/feedback/add"
const contactUsAddPath = "/api/app/contactus/add"
// Add 调用上游提交意见反馈。
func Add(c *gin.Context, req FeedbackReq) (*authser.APIEnvelope, error) {
var env authser.APIEnvelope
if _, err := upstream.PostJSON(c, addPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// AddContactUs 调用上游联系我们接口。
func AddContactUs(c *gin.Context, req ContactUsAddReq) (*authser.APIEnvelope, error) {
var env authser.APIEnvelope
if _, err := upstream.PostJSON(c, contactUsAddPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+23
View File
@@ -0,0 +1,23 @@
package feedbackser
// FeedbackReq 对应 swagger proto.FeedbackReq
// type: 1-功能建议、2-播放问题、3-账号问题、4-其他(与接口 description 一致)
type FeedbackReq struct {
Type int `json:"type"`
Content string `json:"content"`
Contact string `json:"contact,omitempty"`
Screenshots []string `json:"screenshots,omitempty"`
}
// ContactUsAddReq 对应 swagger proto.ContactUsAddReq。
type ContactUsAddReq struct {
Type int `json:"type"`
Title string `json:"title"`
Content string `json:"content"`
Email string `json:"email"`
Attachments []string `json:"attachments,omitempty"`
}
type ContactUsAddResp struct {
Message string `json:"message"`
}
+81
View File
@@ -0,0 +1,81 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
categoryDetailPath = "/api/app/playlet/recommend/list"
defaultCategoryPageSize = 25
)
// CategoryDetailQuery 分类详情分页参数。categorySlug 非空时优先使用 slug。
type CategoryDetailQuery struct {
PageNum int
PageSize int
CategoryID int64
CategorySlug string
}
func (q *CategoryDetailQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultCategoryPageSize
}
if q.PageSize > 100 {
q.PageSize = 100
}
if q.CategoryID < 0 {
q.CategoryID = 0
}
q.CategorySlug = strings.TrimSpace(q.CategorySlug)
}
func categoryDetailListURL(q CategoryDetailQuery) string {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
if q.CategorySlug != "" {
params.Set("categorySlug", q.CategorySlug)
} else if q.CategoryID > 0 {
params.Set("categoryId", strconv.FormatInt(q.CategoryID, 10))
}
return categoryDetailPath + "?" + params.Encode()
}
// FetchCategoryDetail 根据分类 slug 或 ID 获取分类详情列表。
func FetchCategoryDetail(c *gin.Context, q CategoryDetailQuery) (*PlayletRecommendListResp, error) {
var env APIEnvelope[PlayletRecommendListResp]
if err := upstream.GetPublicJSON(c, categoryDetailListURL(q), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet category detail code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchCategoryDetailOrEmpty 保持页面可渲染,接口失败时返回当前分页的空结果。
func FetchCategoryDetailOrEmpty(c *gin.Context, q CategoryDetailQuery) *PlayletRecommendListResp {
q.Normalize()
data, err := FetchCategoryDetail(c, q)
if err != nil {
log.Error("fetch playlet category detail failed", log.E(err))
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
if data == nil {
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data.SeoInfo.Normalize()
return data
}
@@ -0,0 +1,49 @@
package playletser
import (
"strings"
"testing"
)
func TestCategoryDetailListURLUsesSlugBeforeID(t *testing.T) {
got := categoryDetailListURL(CategoryDetailQuery{
PageNum: 2,
CategoryID: 18,
CategorySlug: "latest-video",
})
for _, want := range []string{"/api/app/playlet/recommend/list?", "categorySlug=latest-video", "pageNum=2", "pageSize=25"} {
if !strings.Contains(got, want) {
t.Fatalf("categoryDetailListURL() = %q, missing %q", got, want)
}
}
if strings.Contains(got, "categoryId=") {
t.Fatalf("categoryDetailListURL() = %q, categoryId must be omitted when slug exists", got)
}
}
func TestParseCategoryDetailQueryAndCanonicalPath(t *testing.T) {
slugQuery := ParseCategoryDetailQuery("latest-video", "3")
if slugQuery.CategorySlug != "latest-video" || slugQuery.PageNum != 3 || slugQuery.PageSize != 25 {
t.Fatalf("ParseCategoryDetailQuery(slug) = %#v", slugQuery)
}
if got := CategoryDetailCanonicalPath(slugQuery); got != "/category/latest-video/page/3/" {
t.Fatalf("CategoryDetailCanonicalPath(slug) = %q", got)
}
idQuery := ParseCategoryDetailQuery("42", "")
if idQuery.CategoryID != 42 || idQuery.CategorySlug != "" {
t.Fatalf("ParseCategoryDetailQuery(id) = %#v", idQuery)
}
if got := CategoryDetailCanonicalPath(idQuery); got != "/category/42/" {
t.Fatalf("CategoryDetailCanonicalPath(id) = %q", got)
}
}
func TestCategoryDetailURLPrefersSlugAndFallsBackToID(t *testing.T) {
if got := categoryDetailURL("latest-video", 42); got != "/category/latest-video/" {
t.Fatalf("categoryDetailURL(slug) = %q", got)
}
if got := categoryDetailURL("", 42); got != "/category/42/" {
t.Fatalf("categoryDetailURL(id) = %q", got)
}
}
@@ -0,0 +1,63 @@
package playletser
import (
"strconv"
"strings"
)
func categoryDetailPageURL(categoryID int64, categorySlug string, pageNum int) string {
if pageNum <= 0 {
pageNum = 1
}
path := "/category"
if segment := pathSegment(categorySlug, categoryID); segment != "" {
path += "/" + segment
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path + "/"
}
func categoryDetailURL(categorySlug string, categoryID int64) string {
return categoryDetailPageURL(categoryID, categorySlug, 1)
}
// ParseCategoryDetailQuery 从语义化路由读取分类标识和页码。
func ParseCategoryDetailQuery(identifier, pageStr string) CategoryDetailQuery {
q := CategoryDetailQuery{PageSize: defaultCategoryPageSize}
identifier = strings.TrimSpace(identifier)
if id, err := strconv.ParseInt(identifier, 10, 64); err == nil && id > 0 {
q.CategoryID = id
} else {
q.CategorySlug = identifier
}
if page, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = page
}
q.Normalize()
return q
}
func CategoryDetailCanonicalPath(q CategoryDetailQuery) string {
q.Normalize()
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, q.PageNum)
}
func BuildCategoryDetailPagination(q CategoryDetailQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(page int) string {
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, page)
}, q.PageNum, q.PageSize, totalCount)
}
func CategoryDetailPageTitle(categoryName string, pageNum int, seo SeoInfo) string {
name := strings.TrimSpace(categoryName)
if name == "" {
name = strings.TrimSpace(seo.Title)
}
if name == "" {
name = "分类短剧"
}
return name + PageTitleSuffix(pageNum)
}
+154
View File
@@ -0,0 +1,154 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
chapterDetailPath = "/api/app/playlet/chapter/detail"
chapterListPath = "/api/app/playlet/chapter/list"
)
// PlayQuery 播放页参数。
type PlayQuery struct {
PlayletID int64
PlayletSlug string
ChapterID int64
}
// ParsePlayQuery 解析播放页的路径参数。
func ParsePlayQuery(playletKey, chapterIDRaw string) PlayQuery {
q := PlayQuery{PlayletSlug: strings.TrimSpace(playletKey)}
if n, err := strconv.ParseInt(strings.TrimSpace(playletKey), 10, 64); err == nil && n > 0 {
q.PlayletID = n
q.PlayletSlug = ""
}
if n, err := strconv.ParseInt(strings.TrimSpace(chapterIDRaw), 10, 64); err == nil && n > 0 {
q.ChapterID = n
}
return q
}
// FetchChapterDetail 查询剧集播放详情。
func FetchChapterDetail(c *gin.Context, chapterID, playletID int64, playletSlug string) (*PlayletChapterInfoResp, error) {
if chapterID <= 0 {
return nil, fmt.Errorf("chapter detail invalid chapterId")
}
params := url.Values{}
params.Set("chapterId", strconv.FormatInt(chapterID, 10))
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
params.Set("playletSlug", playletSlug)
} else if playletID > 0 {
params.Set("playletId", strconv.FormatInt(playletID, 10))
}
authser.EnsureGuestOnEnter(c)
var env APIEnvelope[PlayletChapterInfoResp]
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("chapter detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
env.Data.SeoInfo.Normalize()
return &env.Data, nil
}
// FetchChapterDetailOrNil 失败返回 nil。
func FetchChapterDetailOrNil(c *gin.Context, chapterID, playletID int64, playletSlug string) *PlayletChapterInfoResp {
data, err := FetchChapterDetail(c, chapterID, playletID, playletSlug)
if err != nil {
log.Error("fetch chapter detail failed", log.E(err))
return nil
}
if data == nil || data.ID <= 0 {
return nil
}
return data
}
// FetchChapterList 根据短剧 slug 或 ID 查询选集列表,slug 优先。
func FetchChapterList(c *gin.Context, playletID int64, playletSlug string) (*PlayletChapterListResp, error) {
params := url.Values{}
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
params.Set("playletSlug", playletSlug)
} else if playletID > 0 {
params.Set("playletId", strconv.FormatInt(playletID, 10))
} else {
return nil, fmt.Errorf("chapter list missing playletSlug or playletId")
}
authser.EnsureGuestOnEnter(c)
var env APIEnvelope[PlayletChapterListResp]
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet chapter list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
return &env.Data, nil
}
// ResolvePlayChapterID 无 chapterId 时取短剧第一章。
func ResolvePlayChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
if chapterID > 0 {
return chapterID
}
if playlet == nil {
return 0
}
for _, ch := range playlet.PlayletChapterInfo {
if ch.ID > 0 {
return ch.ID
}
}
return 0
}
// AllowWatch 是否可播放(优先 canWatch;兼容仅返回 videoUrl 的旧数据)。
func (c *PlayletChapterInfoResp) AllowWatch() bool {
if c == nil {
return false
}
if c.CanWatch {
return true
}
if c.NeedUnlock || c.NeedVip {
return false
}
return strings.TrimSpace(c.VideoURL) != "" && c.PayType == 0
}
// AllowWatch 详情选集是否可直达播放(优先 canWatch)。
func (c PlayletChapterData) AllowWatch() bool {
if c.CanWatch {
return true
}
if c.NeedUnlock || c.NeedVip {
return false
}
if c.IsUnlocked {
return true
}
return c.PayType == 0
}
+133
View File
@@ -0,0 +1,133 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
detailPath = "/api/app/playlet/detail"
defaultEpisodeRange = 30
)
// EpisodeRange 剧集分段(仅 UI 分页展示,非接口字段映射)。
type EpisodeRange struct {
Start int
End int
Label string
}
// FetchDetail 根据短剧 ID 查询详情。
func FetchDetail(c *gin.Context, id int64) (*PlayletInfoResp, error) {
if id <= 0 {
return nil, fmt.Errorf("playlet detail invalid id")
}
return fetchDetail(c, "id", strconv.FormatInt(id, 10))
}
// FetchDetailByKey 优先按 slug 查询;纯数字路由段按 ID 查询。
func FetchDetailByKey(c *gin.Context, key string) (*PlayletInfoResp, error) {
key = strings.TrimSpace(key)
if key == "" {
return nil, fmt.Errorf("playlet detail missing route key")
}
if id := ParseDetailID(key); id > 0 {
return FetchDetail(c, id)
}
return fetchDetail(c, "slug", key)
}
func fetchDetail(c *gin.Context, queryKey, value string) (*PlayletInfoResp, error) {
authser.EnsureGuestOnEnter(c)
env, err := fetchDetailOnce(c, queryKey, value)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
env, err = fetchDetailOnce(c, queryKey, value)
if err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
env.Data.SeoInfo.Normalize()
return &env.Data, nil
}
func fetchDetailOnce(c *gin.Context, queryKey, value string) (*APIEnvelope[PlayletInfoResp], error) {
params := url.Values{}
params.Set(queryKey, value)
var env APIEnvelope[PlayletInfoResp]
if err := upstream.GetJSON(c, detailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchDetailOrNilByKey 失败或无效时返回 nil。
func FetchDetailOrNilByKey(c *gin.Context, key string) *PlayletInfoResp {
data, err := FetchDetailByKey(c, key)
if err != nil {
log.Error("fetch playlet detail by route key failed", log.E(err))
return nil
}
if data == nil || data.ID <= 0 {
return nil
}
return data
}
// ParseDetailID 解析纯数字路由段。
func ParseDetailID(raw string) int64 {
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || id <= 0 {
return 0
}
return id
}
// BuildEpisodeRanges 按集数分段(默认每段 30 集)。
func BuildEpisodeRanges(chapters []PlayletChapterData, totalEpisode, size int) []EpisodeRange {
if size <= 0 {
size = defaultEpisodeRange
}
maxEp := 0
for _, ch := range chapters {
if ch.CurrentEpisode > maxEp {
maxEp = ch.CurrentEpisode
}
}
if totalEpisode > maxEp {
maxEp = totalEpisode
}
if maxEp <= 0 {
maxEp = len(chapters)
}
if maxEp <= 0 {
return nil
}
out := make([]EpisodeRange, 0, (maxEp+size-1)/size)
for start := 1; start <= maxEp; start += size {
end := start + size - 1
if end > maxEp {
end = maxEp
}
out = append(out, EpisodeRange{
Start: start,
End: end,
Label: fmt.Sprintf("%d–%d", start, end),
})
}
return out
}
+69
View File
@@ -0,0 +1,69 @@
package playletser
import (
"bytes"
"encoding/json"
"strings"
)
// UnmarshalJSON 兼容上游 data 为对象、null、空字符串,或二次 JSON 编码的字符串。
func (e *APIEnvelope[T]) UnmarshalJSON(raw []byte) error {
if e == nil {
return nil
}
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil
}
var aux struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &aux); err != nil {
return err
}
e.Code = aux.Code
e.Msg = aux.Msg
e.Tip = aux.Tip
data, err := unmarshalEnvelopeData[T](aux.Data)
if err != nil {
return err
}
e.Data = data
return nil
}
func unmarshalEnvelopeData[T any](raw json.RawMessage) (T, error) {
var zero T
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return zero, nil
}
if raw[0] == '"' {
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return zero, err
}
s = strings.TrimSpace(s)
if s == "" {
return zero, nil
}
inner := bytes.TrimSpace([]byte(s))
if len(inner) == 0 {
return zero, nil
}
if inner[0] != '{' && inner[0] != '[' {
return zero, nil
}
if err := json.Unmarshal(inner, &zero); err != nil {
return zero, err
}
return zero, nil
}
if err := json.Unmarshal(raw, &zero); err != nil {
return zero, err
}
return zero, nil
}
+110
View File
@@ -0,0 +1,110 @@
package playletser
import "testing"
func TestFooterLinkURL(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "关于我们", raw: "/richtext/关于我们", want: "/richtext/关于我们/"},
{name: "帮助中心", raw: "/richtext/帮助中心", want: "/richtext/帮助中心/"},
{name: "隐私政策", raw: "/richtext/privacy", want: "/richtext/privacy/"},
{name: "问题反馈", raw: "/feedback", want: "/feedback/"},
{name: "用户中心", raw: "/user-center/", want: "/user-center/"},
{name: "合作伙伴", raw: "https://example.com", want: "https://example.com"},
{name: "HTTPS外链", raw: "HTTPS://Example.COM", want: "HTTPS://Example.COM"},
{name: "关于我们", raw: "", want: ""},
{name: "关于我们", raw: "/", want: ""},
}
for _, tt := range tests {
t.Run(tt.name+" "+tt.raw, func(t *testing.T) {
if got := footerLinkURL(tt.name, tt.raw); got != tt.want {
t.Fatalf("footerLinkURL(%q, %q) = %q, want %q", tt.name, tt.raw, got, tt.want)
}
})
}
}
func TestFooterLinkIsExternal(t *testing.T) {
if !footerLinkIsExternal("http://a.com") || !footerLinkIsExternal("HTTPS://A.COM") {
t.Fatal("http(s) URLs should be external")
}
if footerLinkIsExternal("/richtext/关于我们/") || footerLinkIsExternal("/feedback/") {
t.Fatal("internal paths should not be external")
}
}
func TestFooterContactURL(t *testing.T) {
tests := []struct {
name string
value string
gmail string
telegram string
customer string
want string
}{
{name: "E-mail", value: "hello@example.com", want: "mailto:hello@example.com"},
{name: "邮箱", gmail: "support@example.com", want: "mailto:support@example.com"},
{name: "商务合作", value: "立即联系", customer: "service.example.com", want: "https://service.example.com"},
{name: "商务合作", value: "business@example.com", want: "mailto:business@example.com"},
{name: "官方群", value: "立即加入", telegram: "https://t.me/example", want: "https://t.me/example"},
{name: "官方群", value: "t.me/example", want: "https://t.me/example"},
{name: "工作时间", value: "10:00-18:00", want: ""},
}
for _, tt := range tests {
t.Run(tt.name+tt.value, func(t *testing.T) {
got := footerContactURL(tt.name, tt.value, tt.gmail, tt.telegram, tt.customer)
if got != tt.want {
t.Fatalf("footerContactURL(%q, %q) = %q, want %q", tt.name, tt.value, got, tt.want)
}
})
}
}
func TestFooterContactValue(t *testing.T) {
for _, value := range []string{"立即联系", "立即加入", " 立即加入 "} {
if got := footerContactValue(value); got != "" {
t.Fatalf("footerContactValue(%q) = %q, want empty", value, got)
}
}
if got := footerContactValue("hello@example.com"); got != "hello@example.com" {
t.Fatalf("footerContactValue(email) = %q", got)
}
if got := footerContactValue("立即联系:https://t1t1.com"); got != "https://t1t1.com" {
t.Fatalf("footerContactValue(business URL) = %q", got)
}
if got := footerContactValue("立即加入: https://t.me/fuliqun_91sp"); got != "https://t.me/fuliqun_91sp" {
t.Fatalf("footerContactValue(group URL) = %q", got)
}
}
func TestFooterContactName(t *testing.T) {
tests := map[string]string{
"商务合作:立即联系": "商务合作",
"官方群:立即加入": "官方群",
"立即联系:商务合作": "商务合作",
" E-mail ": "E-mail",
}
for input, want := range tests {
if got := footerContactName(input); got != want {
t.Fatalf("footerContactName(%q) = %q, want %q", input, got, want)
}
}
}
func TestNavDisplayURL(t *testing.T) {
tests := map[string]string{
"https://91lvmao0000.com/": "91lvmao0000.com",
"http://example.com/path/": "example.com/path",
"example.com": "example.com",
}
for input, want := range tests {
if got := navDisplayURL(input); got != want {
t.Fatalf("navDisplayURL(%q) = %q, want %q", input, got, want)
}
}
}
+33
View File
@@ -0,0 +1,33 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const forwardHeatPath = "/api/app/playlet/forward/heat"
// PlayletForwardHeatReq 对应 swagger proto.PlayletForwardHeatReq
type PlayletForwardHeatReq struct {
ID int64 `json:"id"`
}
// PlayletForwardHeatResp 对应 swagger proto.PlayletForwardHeatResp
type PlayletForwardHeatResp struct {
ID int64 `json:"id"`
HeatValue int64 `json:"heatValue"`
}
// ForwardHeat 短剧转发增加热度。
func ForwardHeat(c *gin.Context, req PlayletForwardHeatReq) (*APIEnvelope[PlayletForwardHeatResp], error) {
if req.ID <= 0 {
return nil, fmt.Errorf("playlet forward heat invalid id")
}
var env APIEnvelope[PlayletForwardHeatResp]
if _, err := upstream.PostJSON(c, forwardHeatPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+129
View File
@@ -0,0 +1,129 @@
package playletser
import (
"fmt"
"net/url"
"path"
"strings"
"sync"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const homePath = "/api/app/playlet/home"
const homeCacheTTL = 30 * time.Second
var (
homeMu sync.Mutex
homeCache *HomePlayletResponse
homeAt time.Time
)
// FetchHome 拉取首页短剧聚合数据。
func FetchHome(c *gin.Context) (*HomePlayletResponse, error) {
var env APIEnvelope[HomePlayletResponse]
if err := upstream.GetPublicJSON(c, homePath, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet home code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchHomeOrEmpty 带短缓存;失败时打日志并返回空结构,保证 SSR 页面可渲染。
func FetchHomeOrEmpty(c *gin.Context) *HomePlayletResponse {
homeMu.Lock()
defer homeMu.Unlock()
if homeCache != nil && time.Since(homeAt) < homeCacheTTL {
return cloneHome(homeCache)
}
data, err := FetchHome(c)
if err != nil {
log.Error("fetch playlet home failed", log.E(err))
if homeCache != nil {
return cloneHome(homeCache)
}
return &HomePlayletResponse{}
}
if data == nil {
if homeCache != nil {
return cloneHome(homeCache)
}
return &HomePlayletResponse{}
}
data.SeoInfo.Normalize()
homeCache = data
homeAt = time.Now()
return cloneHome(homeCache)
}
func cloneHome(src *HomePlayletResponse) *HomePlayletResponse {
if src == nil {
return &HomePlayletResponse{}
}
out := *src
if src.Announcements != nil {
out.Announcements = append([]AnnouncementItem(nil), src.Announcements...)
}
if src.HomeBannerData != nil {
out.HomeBannerData = append([]HomeBannerData(nil), src.HomeBannerData...)
}
if src.HomeTodayUpdateData != nil {
out.HomeTodayUpdateData = append([]CommonData(nil), src.HomeTodayUpdateData...)
}
if src.HomeCategoryData != nil {
out.HomeCategoryData = make([]HomeCategoryData, len(src.HomeCategoryData))
for i, cat := range src.HomeCategoryData {
out.HomeCategoryData[i] = cat
if cat.List != nil {
out.HomeCategoryData[i].List = append([]CommonData(nil), cat.List...)
}
}
}
if src.HomeRecommendThemes != nil {
out.HomeRecommendThemes = append([]HomeRecommendTheme(nil), src.HomeRecommendThemes...)
}
if src.SeoInfo.Keywords != nil {
out.SeoInfo.Keywords = append([]string(nil), src.SeoInfo.Keywords...)
}
return &out
}
// ActiveHomeBanners 过滤未启用项,保留接口返回顺序。
func ActiveHomeBanners(list []HomeBannerData) []HomeBannerData {
if len(list) == 0 {
return list
}
out := make([]HomeBannerData, 0, len(list))
for _, item := range list {
if !item.Status {
continue
}
item.URL = normalizeBannerURL(item.URL)
out = append(out, item)
}
return out
}
// normalizeBannerURL 站内页面链接补尾斜杠,避免后台漏填 "/" 时每次点击都多一次 301。
// 外链、协议相对地址、带扩展名的文件路径保持原样。
func normalizeBannerURL(raw string) string {
raw = strings.TrimSpace(raw)
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
return raw
}
u, err := url.Parse(raw)
if err != nil || u.Path == "" || strings.HasSuffix(u.Path, "/") {
return raw
}
if strings.Contains(path.Base(u.Path), ".") {
return raw
}
u.Path += "/"
return u.String()
}
@@ -0,0 +1,32 @@
package playletser
import "testing"
func TestNormalizeBannerURL(t *testing.T) {
cases := map[string]string{
"/detail/hqtqxdqe": "/detail/hqtqxdqe/",
"/detail/hqtqxdqe/": "/detail/hqtqxdqe/",
" /play/abc ": "/play/abc/",
"/detail/abc?from=banner": "/detail/abc/?from=banner",
"/": "/",
"": "",
"https://example.com/a": "https://example.com/a",
"//cdn.example.com/a": "//cdn.example.com/a",
"/static/images/a.png": "/static/images/a.png",
}
for in, want := range cases {
if got := normalizeBannerURL(in); got != want {
t.Errorf("normalizeBannerURL(%q) = %q, want %q", in, got, want)
}
}
}
func TestActiveHomeBannersNormalizesURL(t *testing.T) {
got := ActiveHomeBanners([]HomeBannerData{
{ID: 1, URL: "/detail/abc", Status: true},
{ID: 2, URL: "/detail/off", Status: false},
})
if len(got) != 1 || got[0].URL != "/detail/abc/" {
t.Fatalf("unexpected banners: %+v", got)
}
}
+34
View File
@@ -0,0 +1,34 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const hotLikePath = "/api/app/playlet/hot-like"
// PlayletHotSearchItem 对应 swagger proto.PlayletHotSearchItem。
type PlayletHotSearchItem struct {
Keyword string `json:"keyword"`
Count int64 `json:"count"`
}
// PlayletHotLikeResp 对应 swagger proto.PlayletHotLikeResp。
type PlayletHotLikeResp struct {
HotSearch []PlayletHotSearchItem `json:"hotSearch"`
GuessLike []CommonData `json:"guessLike"`
}
// FetchHotLike 获取前 10 个热搜词及按收藏量排序的 15 部猜你喜欢短剧。
func FetchHotLike(c *gin.Context) (*PlayletHotLikeResp, error) {
var env APIEnvelope[PlayletHotLikeResp]
if err := upstream.GetPublicJSON(c, hotLikePath, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet hot-like code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
return &env.Data, nil
}
+31
View File
@@ -0,0 +1,31 @@
package playletser
import (
"encoding/json"
"testing"
)
func TestPlayletHotLikeRespMatchesSwaggerFields(t *testing.T) {
const raw = `{
"code": 200,
"msg": "success",
"data": {
"hotSearch": [{"keyword": "都市", "count": 18}],
"guessLike": [{"id": 12, "title": "示例短剧", "slug": "example"}]
}
}`
var env APIEnvelope[PlayletHotLikeResp]
if err := json.Unmarshal([]byte(raw), &env); err != nil {
t.Fatalf("unmarshal hot-like response: %v", err)
}
if env.Code != 200 || len(env.Data.HotSearch) != 1 || env.Data.HotSearch[0].Keyword != "都市" || env.Data.HotSearch[0].Count != 18 {
t.Fatalf("unexpected hot search data: %#v", env)
}
if len(env.Data.GuessLike) != 1 || env.Data.GuessLike[0].ID != 12 || env.Data.GuessLike[0].Slug != "example" {
t.Fatalf("unexpected guess-like data: %#v", env.Data.GuessLike)
}
if hotLikePath != "/api/app/playlet/hot-like" {
t.Fatalf("hotLikePath = %q", hotLikePath)
}
}
+136
View File
@@ -0,0 +1,136 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
listPath = "/api/app/playlet/list"
themePath = "/api/app/theme/list"
defaultListPageSize = 25
sortHeat = 1 // 热度优先
sortTodayUpdate = 2 // 今日更新
// 站点统一类型:1-非成人 2-成人(与 themeType 一致)。
contentTypeNonAdult = 1
contentTypeAdult = 2
// playlet/list 上游 type 与站点定义相反:1-成人 2-非成人。
upstreamListTypeAdult = 1
upstreamListTypeNonAdult = 2
)
// ListQuery 短剧列表查询参数。
type ListQuery struct {
PageNum int
PageSize int
SortOrder int
ThemeID int64
ThemeSlug string
Type int
}
// Normalize 校正分页与排序默认值。
func (q *ListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultListPageSize
}
if q.PageSize > 50 {
q.PageSize = 50
}
if q.SortOrder != sortTodayUpdate {
q.SortOrder = sortHeat
}
if q.ThemeID < 0 {
q.ThemeID = 0
}
q.Type = normalizeContentType(q.Type)
}
// FetchList 分页查询已上架短剧。
func FetchList(c *gin.Context, q ListQuery) (*PlayletVideoResp, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
if strings.TrimSpace(q.ThemeSlug) != "" {
params.Set("themeSlug", strings.TrimSpace(q.ThemeSlug))
} else if q.ThemeID > 0 {
params.Set("themeId", strconv.FormatInt(q.ThemeID, 10))
} else {
params.Set("type", strconv.Itoa(listTypeForUpstream(q.Type)))
}
var env APIEnvelope[PlayletVideoResp]
if err := upstream.GetPublicJSON(c, listPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchListOrEmpty 失败时返回空列表。
func FetchListOrEmpty(c *gin.Context, q ListQuery) *PlayletVideoResp {
data, err := FetchList(c, q)
if err != nil {
log.Error("fetch playlet list failed", log.E(err))
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
if data == nil {
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data.SeoInfo.Normalize()
return data
}
// FetchThemes 拉取题材列表。
func FetchThemes(c *gin.Context, contentType int) ([]PlayletThemeItem, error) {
contentType = normalizeContentType(contentType)
params := url.Values{}
params.Set("themeType", strconv.Itoa(contentType))
var env APIEnvelope[PlayletThemeListResponse]
if err := upstream.GetPublicJSON(c, themePath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("theme list code=%d msg=%s", env.Code, env.Msg)
}
return env.Data.List, nil
}
// FetchThemesOrEmpty 失败时返回空题材。
func FetchThemesOrEmpty(c *gin.Context, contentType int) []PlayletThemeItem {
list, err := FetchThemes(c, contentType)
if err != nil {
log.Error("fetch theme list failed", log.E(err))
return nil
}
return list
}
func normalizeContentType(contentType int) int {
if contentType == contentTypeAdult {
return contentTypeAdult
}
return contentTypeNonAdult
}
// listTypeForUpstream 将站点统一类型转为 playlet/list 的 type(该接口 1=成人 2=非成人)。
func listTypeForUpstream(contentType int) int {
if normalizeContentType(contentType) == contentTypeAdult {
return upstreamListTypeAdult
}
return upstreamListTypeNonAdult
}
+250
View File
@@ -0,0 +1,250 @@
package playletser
import (
"strconv"
"strings"
)
// PaginationVM 分页条数据(页面 UI,非接口字段映射)。
type PaginationVM struct {
Visible bool
HasPrev bool
HasNext bool
PrevURL string
NextURL string
Page int
TotalPages int
Items []PaginationItemVM
}
// PaginationItemVM 页码或省略号。
type PaginationItemVM struct {
Label string
URL string
Active bool
Dots bool
}
// FilterChipVM 筛选 / Tab UI。
type FilterChipVM struct {
Label string
URL string
Active bool
}
// ThemeChipVM 题材筛选芯片(ID/Name 来自接口,URL/Active 为页面状态)。
type ThemeChipVM struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string
Active bool
}
// FindThemeSlugByID 查询 ID 对应的 slug;无 slug 时继续使用 ID 路由。
func FindThemeSlugByID(themes []PlayletThemeItem, id int64) string {
if id <= 0 {
return ""
}
for _, theme := range themes {
if theme.ID == id {
return strings.TrimSpace(theme.Slug)
}
}
return ""
}
func listPageURL(themeID int64, themeSlug string, sortOrder, pageNum, contentType int) string {
if sortOrder != sortTodayUpdate {
sortOrder = sortHeat
}
if pageNum <= 0 {
pageNum = 1
}
cleanSlug := strings.TrimSpace(themeSlug)
defaultSort := sortHeat
if cleanSlug == "ai-short-play" || cleanSlug == "ai-series" {
defaultSort = sortTodayUpdate
}
if themeID <= 0 && cleanSlug == "" && sortOrder == sortHeat && pageNum == 1 {
return "/list/"
}
path := "/list"
segment := pathSegment(themeSlug, themeID)
if segment == "" {
segment = "all"
}
path += "/" + segment + "/" + strconv.Itoa(sortOrder)
if themeID <= 0 && cleanSlug != "" && sortOrder == defaultSort && pageNum == 1 {
return "/list/" + segment + "/"
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path + "/"
}
// ListCanonicalPath 返回列表页唯一的无 query 规范路径。
func ListCanonicalPath(q ListQuery) string {
q.Normalize()
return listPageURL(q.ThemeID, q.ThemeSlug, q.SortOrder, q.PageNum, q.Type)
}
// ListResetURL 重置筛选并生成无 query 的默认排序路径。
func ListResetURL(q ListQuery) string {
q.Normalize()
return ListCanonicalPath(ListQuery{Type: q.Type})
}
// BuildListThemes 题材芯片;Name/ID 保持接口字段语义。
func BuildListThemes(themes []PlayletThemeItem, q ListQuery) []ThemeChipVM {
q.Normalize()
out := make([]ThemeChipVM, 0, len(themes)+1)
out = append(out, ThemeChipVM{
ID: 0,
Name: "全部",
URL: listPageURL(0, "", q.SortOrder, 1, q.Type),
Active: q.ThemeID == 0 && strings.TrimSpace(q.ThemeSlug) == "",
})
for _, t := range themes {
if !t.Status {
continue
}
name := strings.TrimSpace(t.Name)
if name == "" || t.ID <= 0 {
continue
}
out = append(out, ThemeChipVM{
ID: t.ID,
Name: name,
URL: listPageURL(t.ID, t.Slug, q.SortOrder, 1, q.Type),
Active: q.ThemeID == t.ID || (strings.TrimSpace(q.ThemeSlug) != "" && strings.TrimSpace(q.ThemeSlug) == strings.TrimSpace(t.Slug)),
})
}
return out
}
// BuildListSortChips 排序芯片。
func BuildListSortChips(q ListQuery) (heat, update FilterChipVM) {
q.Normalize()
heat = FilterChipVM{
Label: "热度优先",
URL: listPageURL(q.ThemeID, q.ThemeSlug, sortHeat, 1, q.Type),
Active: q.SortOrder == sortHeat,
}
update = FilterChipVM{
Label: "今日更新",
URL: listPageURL(q.ThemeID, q.ThemeSlug, sortTodayUpdate, 1, q.Type),
Active: q.SortOrder == sortTodayUpdate,
}
return heat, update
}
// BuildListPagination 列表分页。
func BuildListPagination(q ListQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(p int) string {
return listPageURL(q.ThemeID, q.ThemeSlug, q.SortOrder, p, q.Type)
}, q.PageNum, q.PageSize, totalCount)
}
// BuildPagination 生成分页 VM;pageURL 生成指定页链接。
func BuildPagination(pageURL func(int) string, pageNum, pageSize int, totalCount int64) PaginationVM {
if pageURL == nil {
pageURL = func(p int) string {
if p <= 1 {
return "#"
}
return "?page=" + strconv.Itoa(p)
}
}
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = defaultListPageSize
}
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
if totalPages <= 1 {
return PaginationVM{Visible: false}
}
if pageNum > totalPages {
pageNum = totalPages
}
return PaginationVM{
Visible: true,
HasPrev: pageNum > 1,
HasNext: pageNum < totalPages,
PrevURL: pageURL(pageNum - 1),
NextURL: pageURL(pageNum + 1),
Page: pageNum,
TotalPages: totalPages,
Items: buildPageItems(pageNum, totalPages, pageURL),
}
}
func buildPageItems(current, total int, pageURL func(int) string) []PaginationItemVM {
if total <= 0 {
return nil
}
if total <= 7 {
out := make([]PaginationItemVM, 0, total)
for i := 1; i <= total; i++ {
out = append(out, PaginationItemVM{
Label: strconv.Itoa(i),
URL: pageURL(i),
Active: i == current,
})
}
return out
}
addPage := func(out *[]PaginationItemVM, p int) {
*out = append(*out, PaginationItemVM{
Label: strconv.Itoa(p),
URL: pageURL(p),
Active: p == current,
})
}
addDots := func(out *[]PaginationItemVM) {
*out = append(*out, PaginationItemVM{Dots: true, Label: "…"})
}
var out []PaginationItemVM
addPage(&out, 1)
if current > 3 {
addDots(&out)
}
start := current - 1
end := current + 1
if start < 2 {
start = 2
}
if end > total-1 {
end = total - 1
}
for p := start; p <= end; p++ {
addPage(&out, p)
}
if current < total-2 {
addDots(&out)
}
addPage(&out, total)
return out
}
// ParseListQuery 从 gin query 解析列表参数。
func ParseListQuery(pageStr, sortStr, typeStr string) ListQuery {
q := ListQuery{PageSize: defaultListPageSize, SortOrder: sortHeat, Type: contentTypeNonAdult}
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = n
}
if n, err := strconv.Atoi(strings.TrimSpace(sortStr)); err == nil {
q.SortOrder = n
}
if n, err := strconv.Atoi(strings.TrimSpace(typeStr)); err == nil {
q.Type = n
}
q.Normalize()
return q
}
+81
View File
@@ -0,0 +1,81 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const operateListPath = "/api/app/playlet/operate/list"
type OperateListQuery struct {
OperateType int
FilterType int
PageNum int
PageSize int
}
func (q *OperateListQuery) Normalize() {
if q.OperateType < 1 || q.OperateType > 2 {
q.OperateType = 1
}
if q.FilterType < 1 || q.FilterType > 3 {
q.FilterType = 1
}
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = 40
}
if q.PageSize > 100 {
q.PageSize = 100
}
}
// FetchOperateList 按运营类型和筛选类型查询已上架短剧。
func FetchOperateList(c *gin.Context, q OperateListQuery) (*PlayletOperateListResp, error) {
q.Normalize()
params := url.Values{}
params.Set("operateType", strconv.Itoa(q.OperateType))
params.Set("filterType", strconv.Itoa(q.FilterType))
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[PlayletOperateListResp]
if err := upstream.GetPublicJSON(c, operateListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet operate list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
return &env.Data, nil
}
// FetchOperateListOrEmpty 为 SSR 页面提供容错,接口异常时仍渲染完整页面骨架。
func FetchOperateListOrEmpty(c *gin.Context, q OperateListQuery) *PlayletOperateListResp {
q.Normalize()
data, err := FetchOperateList(c, q)
if err != nil {
log.Error("fetch playlet operate list failed", log.E(err))
return &PlayletOperateListResp{
OperateType: q.OperateType,
FilterType: q.FilterType,
PageNum: q.PageNum,
PageSize: q.PageSize,
}
}
if data == nil {
return &PlayletOperateListResp{
OperateType: q.OperateType,
FilterType: q.FilterType,
PageNum: q.PageNum,
PageSize: q.PageSize,
}
}
return data
}
+79
View File
@@ -0,0 +1,79 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
rankPath = "/api/app/playlet/rank"
defaultRankPageSize = 30
// 排行榜 sortOrder:1-热播榜,2-新剧榜,3-追剧榜
rankSortHot = 1
rankSortNew = 2
rankSortBinge = 3
)
// RankQuery 排行榜查询参数。
type RankQuery struct {
PageNum int
PageSize int
SortOrder int
}
// Normalize 校正分页与榜单类型。
func (q *RankQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultRankPageSize
}
if q.PageSize > 50 {
q.PageSize = 50
}
switch q.SortOrder {
case rankSortNew, rankSortBinge:
default:
q.SortOrder = rankSortHot
}
}
// FetchRank 分页查询短剧排行榜。
func FetchRank(c *gin.Context, q RankQuery) (*PlayletVideoResp, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
var env APIEnvelope[PlayletVideoResp]
if err := upstream.GetPublicJSON(c, rankPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet rank code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchRankOrEmpty 失败时返回空榜单。
func FetchRankOrEmpty(c *gin.Context, q RankQuery) *PlayletVideoResp {
data, err := FetchRank(c, q)
if err != nil {
log.Error("fetch playlet rank failed", log.E(err))
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
if data == nil {
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data.SeoInfo.Normalize()
return data
}
+88
View File
@@ -0,0 +1,88 @@
package playletser
import (
"fmt"
"strconv"
"strings"
)
func formatCollectHeat(n int64) string {
if n <= 0 {
return "0 收藏"
}
if n < 10000 {
return fmt.Sprintf("%d 收藏", n)
}
v := float64(n) / 10000
if v >= 100 {
return fmt.Sprintf("%.0f万 收藏", v)
}
return fmt.Sprintf("%.1f万 收藏", v)
}
func rankPageURL(sortOrder, pageNum int) string {
switch sortOrder {
case rankSortNew, rankSortBinge:
default:
sortOrder = rankSortHot
}
if pageNum <= 0 {
pageNum = 1
}
path := "/rank"
if sortOrder != rankSortHot {
path += "/" + strconv.Itoa(sortOrder)
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path + "/"
}
// BuildRankTabs 排行榜 Tab。
func BuildRankTabs(q RankQuery) []FilterChipVM {
q.Normalize()
return []FilterChipVM{
{Label: "热播榜", URL: rankPageURL(rankSortHot, 1), Active: q.SortOrder == rankSortHot},
{Label: "新剧榜", URL: rankPageURL(rankSortNew, 1), Active: q.SortOrder == rankSortNew},
{Label: "追剧榜", URL: rankPageURL(rankSortBinge, 1), Active: q.SortOrder == rankSortBinge},
}
}
// BuildRankPagination 排行榜分页。
func BuildRankPagination(q RankQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(p int) string {
return rankPageURL(q.SortOrder, p)
}, q.PageNum, q.PageSize, totalCount)
}
// RankCanonicalPath 返回排行榜筛选和分页对应的唯一规范路径。
func RankCanonicalPath(q RankQuery) string {
q.Normalize()
return rankPageURL(q.SortOrder, q.PageNum)
}
// RankOffset 当前页起始排名偏移(0-based)。
func RankOffset(q RankQuery) int {
q.Normalize()
return (q.PageNum - 1) * q.PageSize
}
// ParseRankQuery 从 query 解析排行榜参数。
func ParseRankQuery(pageStr, sortStr string) RankQuery {
q := RankQuery{PageSize: defaultRankPageSize, SortOrder: rankSortHot}
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = n
}
if n, err := strconv.Atoi(strings.TrimSpace(sortStr)); err == nil {
q.SortOrder = n
}
q.Normalize()
return q
}
// IsRankBinge 是否追剧榜(热度展示用 countCollect)。
func IsRankBinge(sortOrder int) bool {
return sortOrder == rankSortBinge
}
+23
View File
@@ -0,0 +1,23 @@
package playletser
import "testing"
func TestRankCanonicalPath(t *testing.T) {
tests := []struct {
name string
q RankQuery
want string
}{
{name: "default", q: RankQuery{}, want: "/rank/"},
{name: "new", q: RankQuery{SortOrder: rankSortNew}, want: "/rank/2/"},
{name: "paged", q: RankQuery{SortOrder: rankSortBinge, PageNum: 3}, want: "/rank/3/page/3/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := RankCanonicalPath(tt.q); got != tt.want {
t.Fatalf("RankCanonicalPath(%#v) = %q, want %q", tt.q, got, tt.want)
}
})
}
}
+83
View File
@@ -0,0 +1,83 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
recommendListPath = "/api/app/playlet/list"
defaultRecommendSize = 25
)
// RecommendQuery 专题详情分页参数。
type RecommendQuery struct {
PageNum int
PageSize int
SortOrder int
ThemeID int64
ThemeSlug string
}
// Normalize 校正分页默认值。
func (q *RecommendQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultRecommendSize
}
if q.PageSize > 100 {
q.PageSize = 100
}
if q.SortOrder != sortTodayUpdate {
q.SortOrder = sortHeat
}
}
func recommendListURL(q RecommendQuery) string {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
if slug := strings.TrimSpace(q.ThemeSlug); slug != "" {
params.Set("themeSlug", slug)
} else if q.ThemeID > 0 {
params.Set("themeId", strconv.FormatInt(q.ThemeID, 10))
}
return recommendListPath + "?" + params.Encode()
}
// FetchRecommendList 按 Swagger 的题材 ID 或 slug 参数分页查询专题短剧。
func FetchRecommendList(c *gin.Context, q RecommendQuery) (*PlayletVideoResp, error) {
var env APIEnvelope[PlayletVideoResp]
if err := upstream.GetPublicJSON(c, recommendListURL(q), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet recommend list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchRecommendListOrEmpty 失败时返回空列表。
func FetchRecommendListOrEmpty(c *gin.Context, q RecommendQuery) *PlayletVideoResp {
q.Normalize()
data, err := FetchRecommendList(c, q)
if err != nil {
log.Error("fetch playlet recommend list failed", log.E(err))
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
if data == nil {
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data.SeoInfo.Normalize()
return data
}
+64
View File
@@ -0,0 +1,64 @@
package playletser
import (
"strconv"
"strings"
)
func recommendPageURL(themeID int64, themeSlug string, pageNum int) string {
if pageNum <= 0 {
pageNum = 1
}
path := "/topic"
if segment := pathSegment(themeSlug, themeID); segment != "" {
path += "/" + segment
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path + "/"
}
func RecommendPagePath(q RecommendQuery) string {
q.Normalize()
return recommendPageURL(q.ThemeID, q.ThemeSlug, q.PageNum)
}
// RecommendThemeTitle 专题页标题:优先题材名称,其次使用列表接口 SEO 标题。
func RecommendThemeTitle(name, seoTitle string, themeID int64) string {
name = strings.TrimSpace(name)
if name != "" {
return name
}
seoTitle = strings.TrimSpace(seoTitle)
if seoTitle != "" {
return seoTitle
}
if themeID > 0 {
return "专题短剧"
}
return "专题推荐"
}
// RecommendCanonicalPath 推荐页 canonical(不含 page)。
func RecommendCanonicalPath(themeID int64, themeSlug string) string {
return recommendPageURL(themeID, themeSlug, 1)
}
// BuildRecommendPagination 推荐列表分页。
func BuildRecommendPagination(q RecommendQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(p int) string {
return recommendPageURL(q.ThemeID, q.ThemeSlug, p)
}, q.PageNum, q.PageSize, totalCount)
}
// ParseRecommendQuery 从路由解析分页。
func ParseRecommendQuery(pageStr string) RecommendQuery {
q := RecommendQuery{PageSize: defaultRecommendSize, SortOrder: sortHeat}
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = n
}
q.Normalize()
return q
}
+124
View File
@@ -0,0 +1,124 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
searchPath = "/api/app/playlet/search"
defaultSearchPageSize = 25
)
// SearchQuery 搜索分页参数(keyword 对应上游 query)。
type SearchQuery struct {
Keyword string
PageNum int
PageSize int
}
// Normalize 校正分页默认值。
func (q *SearchQuery) Normalize() {
q.Keyword = strings.TrimSpace(q.Keyword)
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultSearchPageSize
}
if q.PageSize > 50 {
q.PageSize = 50
}
}
// FetchSearch ES 分页搜索短剧。
func FetchSearch(c *gin.Context, q SearchQuery) (*PlayletVideoResp, error) {
q.Normalize()
if q.Keyword == "" {
return nil, fmt.Errorf("playlet search keyword is empty")
}
authser.EnsureGuestOnEnter(c)
env, err := fetchSearchOnce(c, q)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
env, err = fetchSearchOnce(c, q)
if err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet search code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
return &env.Data, nil
}
func fetchSearchOnce(c *gin.Context, q SearchQuery) (*APIEnvelope[PlayletVideoResp], error) {
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("keyword", q.Keyword)
var env APIEnvelope[PlayletVideoResp]
if err := upstream.GetJSON(c, searchPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchSearchOrEmpty 失败时返回空列表。
func FetchSearchOrEmpty(c *gin.Context, q SearchQuery) *PlayletVideoResp {
data, err := FetchSearch(c, q)
if err != nil {
log.Error("fetch playlet search failed", log.E(err))
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
if data == nil {
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
func searchPageURL(keyword string, pageNum int) string {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return "/search/"
}
path := "/search/" + url.PathEscape(keyword)
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path + "/"
}
func SearchCanonicalPath(q SearchQuery) string {
q.Normalize()
return searchPageURL(q.Keyword, q.PageNum)
}
// BuildSearchPagination 搜索分页。
func BuildSearchPagination(q SearchQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(p int) string {
return searchPageURL(q.Keyword, p)
}, q.PageNum, q.PageSize, totalCount)
}
// ParseSearchQuery 从站点 query 解析(q → keyword)。
func ParseSearchQuery(qStr, pageStr string) SearchQuery {
q := SearchQuery{PageSize: defaultSearchPageSize, Keyword: strings.TrimSpace(qStr)}
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = n
}
q.Normalize()
return q
}
+312
View File
@@ -0,0 +1,312 @@
package playletser
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// PageOutOfRange 判断分页是否越界(第 1 页始终合法,即使列表为空)。
func PageOutOfRange(pageNum, pageSize int, totalCount int64) bool {
if pageNum <= 1 {
return false
}
if pageSize <= 0 {
pageSize = defaultListPageSize
}
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
if totalPages < 1 {
return true
}
return pageNum > totalPages
}
// PageTitleSuffix 分页标题后缀:第 1 页为空,其余为「 - 第N页」。
func PageTitleSuffix(pageNum int) string {
if pageNum <= 1 {
return ""
}
return fmt.Sprintf(" - 第%d页", pageNum)
}
// PaginatedTitle 为第 2 页及以后追加页码,并避免重复追加。
func PaginatedTitle(title string, pageNum int) string {
title = strings.TrimSpace(title)
if title == "" || pageNum <= 1 {
return title
}
marker := fmt.Sprintf("第%d页", pageNum)
if strings.Contains(title, marker) {
return title
}
return title + PageTitleSuffix(pageNum)
}
// PaginateSeoInfo 统一生成分页页的唯一 title 与 description。
// fallbackDescription 仅在上游未提供 description 时使用。
func PaginateSeoInfo(base SeoInfo, fallbackDescription string, pageNum int) SeoInfo {
base.Normalize()
if pageNum <= 1 {
return base
}
base.Title = PaginatedTitle(base.Title, pageNum)
if base.Description == "" {
base.Description = strings.TrimSpace(fallbackDescription)
}
if base.Description != "" {
marker := fmt.Sprintf("第%d页", pageNum)
if !strings.Contains(base.Description, marker) {
base.Description = strings.TrimRight(base.Description, "。;; ") + " - " + marker
}
}
base.Normalize()
return base
}
// UnmarshalJSON 兼容 swagger proto.SeoInfo,并容忍上游别名 / 字符串 keywords。
// 主字段:title、description、keywords[]、canonicalUrl。
func (s *SeoInfo) UnmarshalJSON(raw []byte) error {
if s == nil {
return nil
}
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil
}
if raw[0] == '"' {
var encoded string
if err := json.Unmarshal(raw, &encoded); err != nil {
return err
}
encoded = strings.TrimSpace(encoded)
if encoded == "" {
return nil
}
inner := bytes.TrimSpace([]byte(encoded))
if len(inner) == 0 || inner[0] != '{' {
return nil
}
return s.UnmarshalJSON(inner)
}
var aux struct {
Title string `json:"title"`
WebTitle string `json:"webTitle"`
Description string `json:"description"`
WebDescription string `json:"webDescription"`
CanonicalURL string `json:"canonicalUrl"`
Canonical string `json:"canonical"`
WebSiteName string `json:"webSiteName"`
Keywords json.RawMessage `json:"keywords"`
Keyworld json.RawMessage `json:"keyworld"`
WebKeyworld json.RawMessage `json:"webKeyworld"`
}
if err := json.Unmarshal(raw, &aux); err != nil {
return err
}
s.Title = firstNonEmpty(aux.Title, aux.WebTitle)
s.Description = firstNonEmpty(aux.Description, aux.WebDescription)
s.CanonicalURL = firstNonEmpty(aux.CanonicalURL, aux.Canonical)
s.WebSiteName = strings.TrimSpace(aux.WebSiteName)
s.Keywords = decodeKeywordList(aux.Keywords, aux.Keyworld, aux.WebKeyworld)
s.Normalize()
return nil
}
// Normalize 去掉 seoInfo 空白项,保证模板可直接渲染。
func (s *SeoInfo) Normalize() {
if s == nil {
return
}
s.Title = strings.TrimSpace(s.Title)
s.Description = strings.TrimSpace(s.Description)
s.CanonicalURL = strings.TrimSpace(s.CanonicalURL)
s.WebSiteName = strings.TrimSpace(s.WebSiteName)
if len(s.Keywords) == 0 {
return
}
out := make([]string, 0, len(s.Keywords))
seen := make(map[string]struct{}, len(s.Keywords))
for _, k := range s.Keywords {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, k)
}
s.Keywords = out
}
func decodeKeywordList(vals ...json.RawMessage) []string {
for _, raw := range vals {
if items := parseKeywordsRaw(raw); len(items) > 0 {
return items
}
}
return nil
}
func parseKeywordsRaw(raw json.RawMessage) []string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil
}
switch raw[0] {
case '"':
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return nil
}
return splitKeywordText(s)
case '[':
var anyItems []any
if err := json.Unmarshal(raw, &anyItems); err != nil {
return nil
}
out := make([]string, 0, len(anyItems))
for _, it := range anyItems {
s, ok := it.(string)
if !ok {
continue
}
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func splitKeywordText(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
s = strings.NewReplacer(",", ",", "、", ",", ";", ",", ";", ",").Replace(s)
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
func joinStrings(items []string, sep string) string {
parts := make([]string, 0, len(items))
for _, s := range items {
s = strings.TrimSpace(s)
if s != "" {
parts = append(parts, s)
}
}
return strings.Join(parts, sep)
}
// MergeSeoInfo 按字段合并 SEO:优先 primary,缺失时回退 fallback。
func MergeSeoInfo(primary, fallback SeoInfo) SeoInfo {
out := SeoInfo{
Title: firstNonEmpty(primary.Title, fallback.Title),
Description: firstNonEmpty(primary.Description, fallback.Description),
CanonicalURL: firstNonEmpty(primary.CanonicalURL, fallback.CanonicalURL),
WebSiteName: firstNonEmpty(primary.WebSiteName, fallback.WebSiteName),
}
if len(primary.Keywords) > 0 {
out.Keywords = append([]string(nil), primary.Keywords...)
} else {
out.Keywords = append([]string(nil), fallback.Keywords...)
}
out.Normalize()
return out
}
// SEOSelection 描述页面当前选中的筛选维度,用于生成差异化 TDK。
type SEOSelection struct {
Dimension string
Value string
}
// DifferentiateSeoInfo 按基础 TDK - 选项1 - 选项2 的顺序拼接筛选条件。
func DifferentiateSeoInfo(base SeoInfo, selections []SEOSelection) SeoInfo {
base.Normalize()
normalized := make([]SEOSelection, 0, len(selections))
seen := make(map[string]struct{}, len(selections))
for _, selection := range selections {
dimension := strings.TrimSpace(selection.Dimension)
value := strings.TrimSpace(selection.Value)
if value == "" {
continue
}
key := dimension + "\x00" + value
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, SEOSelection{Dimension: dimension, Value: value})
}
if len(normalized) == 0 {
return base
}
values := make([]string, 0, len(normalized))
keywords := append([]string(nil), base.Keywords...)
for _, selection := range normalized {
values = append(values, selection.Value)
keywords = append(keywords, selection.Value)
}
base.Title = joinSEOParts(base.Title, values)
base.Description = joinSEOParts(base.Description, values)
base.Keywords = keywords
base.Normalize()
return base
}
func joinSEOParts(base string, values []string) string {
parts := make([]string, 0, len(values)+1)
if base = strings.TrimSpace(base); base != "" {
parts = append(parts, base)
}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
parts = append(parts, value)
}
}
return strings.Join(parts, " - ")
}
// ResolvePlayPageSEO 播放页 SEO:章节详情 seoInfo 优先,短剧详情作字段级回退。
func ResolvePlayPageSEO(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) SeoInfo {
var chapterSEO, playletSEO SeoInfo
if chapter != nil {
chapterSEO = chapter.SeoInfo
}
if playlet != nil {
playletSEO = playlet.SeoInfo
}
return MergeSeoInfo(chapterSEO, playletSEO)
}
// ResolvePlayPageTitle 播放页标题:优先章节 seoInfo.title,否则短剧名 + 集数。
func ResolvePlayPageTitle(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) string {
seo := ResolvePlayPageSEO(chapter, playlet)
if seo.Title != "" {
return seo.Title
}
title := "播放"
if playlet != nil && playlet.Title != "" {
title = playlet.Title
}
if chapter != nil && chapter.CurrentEpisode > 0 {
return fmt.Sprintf("%s - 第%d集", title, chapter.CurrentEpisode)
}
return title
}
+164
View File
@@ -0,0 +1,164 @@
package playletser
import (
"encoding/json"
"reflect"
"testing"
)
func TestSeoInfo_UnmarshalJSON_SwaggerShape(t *testing.T) {
raw := []byte(`{
"title":"灵气复苏,我成为了万古医圣",
"keywords":["灵气复苏,我成为了万古医圣","职场"],
"description":"灵气复苏,我成为了万古医圣",
"canonicalUrl":"/detail/example-show"
}`)
var seo SeoInfo
if err := json.Unmarshal(raw, &seo); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if seo.Title != "灵气复苏,我成为了万古医圣" {
t.Fatalf("title=%q", seo.Title)
}
if seo.Description != "灵气复苏,我成为了万古医圣" {
t.Fatalf("description=%q", seo.Description)
}
if seo.CanonicalURL != "/detail/example-show" {
t.Fatalf("canonicalUrl=%q", seo.CanonicalURL)
}
want := []string{"灵气复苏,我成为了万古医圣", "职场"}
if !reflect.DeepEqual(seo.Keywords, want) {
t.Fatalf("keywords=%v", seo.Keywords)
}
}
func TestSeoInfo_UnmarshalJSON_AliasesAndStringKeywords(t *testing.T) {
raw := []byte(`{
"webTitle":"镇龙",
"webDescription":"短剧简介",
"keyworld":"镇龙,玄幻",
"canonical":"/detail/example-show"
}`)
var seo SeoInfo
if err := json.Unmarshal(raw, &seo); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if seo.Title != "镇龙" || seo.Description != "短剧简介" {
t.Fatalf("got title=%q desc=%q", seo.Title, seo.Description)
}
if seo.CanonicalURL != "/detail/example-show" {
t.Fatalf("canonicalUrl=%q", seo.CanonicalURL)
}
want := []string{"镇龙", "玄幻"}
if !reflect.DeepEqual(seo.Keywords, want) {
t.Fatalf("keywords=%v", seo.Keywords)
}
}
func TestAPIEnvelope_UnmarshalJSON_ObjectData(t *testing.T) {
raw := []byte(`{"code":200,"msg":"ok","data":{"id":10741,"title":"都市逍遥神医","seoInfo":""}}`)
var env APIEnvelope[PlayletInfoResp]
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Code != 200 || env.Data.ID != 10741 || env.Data.Title != "都市逍遥神医" {
t.Fatalf("env=%+v data=%+v", env, env.Data)
}
}
func TestAPIEnvelope_UnmarshalJSON_StringEncodedData(t *testing.T) {
raw := []byte(`{"code":200,"msg":"ok","data":"{\"id\":10741,\"title\":\"都市逍遥神医\",\"seoInfo\":\"\"}"}`)
var env APIEnvelope[PlayletInfoResp]
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Data.ID != 10741 || env.Data.Title != "都市逍遥神医" {
t.Fatalf("data=%+v", env.Data)
}
}
func TestAPIEnvelope_UnmarshalJSON_EmptyStringData(t *testing.T) {
raw := []byte(`{"code":200,"msg":"ok","data":""}`)
var env APIEnvelope[PlayletInfoResp]
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Data.ID != 0 {
t.Fatalf("data=%+v", env.Data)
}
}
func TestPlayletInfoResp_SeoInfoEmptyString(t *testing.T) {
raw := []byte(`{"id":10741,"title":"都市逍遥神医","seoInfo":""}`)
var info PlayletInfoResp
if err := json.Unmarshal(raw, &info); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if info.ID != 10741 || info.Title != "都市逍遥神医" {
t.Fatalf("info=%+v", info)
}
if info.SeoInfo.Title != "" {
t.Fatalf("seoInfo=%+v", info.SeoInfo)
}
}
func TestPlayletInfoResp_KeepsSeoInfo(t *testing.T) {
raw := []byte(`{"id":10619,"slug":"example-show","title":"剧名","seoInfo":{"title":"SEO标题","description":"SEO描述","keywords":["a","b"],"canonicalUrl":"/detail/example-show"}}`)
var info PlayletInfoResp
if err := json.Unmarshal(raw, &info); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if info.SeoInfo.Title != "SEO标题" || info.SeoInfo.CanonicalURL != "/detail/example-show" {
t.Fatalf("seoInfo=%+v", info.SeoInfo)
}
}
func TestPlayletChapterInfoResp_KeepsSeoInfo(t *testing.T) {
raw := []byte(`{"id":12743,"playletId":10619,"currentEpisode":1,"title":"1","seoInfo":{"title":"剧名 1","description":"剧名","keywords":["剧名","短剧"],"canonicalUrl":"/play/example-show/12743"}}`)
var info PlayletChapterInfoResp
if err := json.Unmarshal(raw, &info); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if info.SeoInfo.Title != "剧名 1" || info.SeoInfo.CanonicalURL != "/play/example-show/12743" {
t.Fatalf("seoInfo=%+v", info.SeoInfo)
}
}
func TestResolvePlayPageSEO_PrefersChapter(t *testing.T) {
chapter := &PlayletChapterInfoResp{
CurrentEpisode: 1,
SeoInfo: SeoInfo{
Title: "章节标题",
Description: "章节描述",
Keywords: []string{"章节"},
CanonicalURL: "/play/example-show/2",
},
}
playlet := &PlayletInfoResp{
Title: "短剧名",
SeoInfo: SeoInfo{
Title: "短剧SEO",
Description: "短剧描述",
Keywords: []string{"短剧"},
CanonicalURL: "/detail/example-show",
},
}
seo := ResolvePlayPageSEO(chapter, playlet)
if seo.Title != "章节标题" || seo.CanonicalURL != "/play/example-show/2" {
t.Fatalf("seo=%+v", seo)
}
if !reflect.DeepEqual(seo.Keywords, []string{"章节"}) {
t.Fatalf("keywords=%v", seo.Keywords)
}
}
func TestResolvePlayPageTitle_UsesChapterSeoTitle(t *testing.T) {
chapter := &PlayletChapterInfoResp{
CurrentEpisode: 1,
SeoInfo: SeoInfo{Title: "灵气复苏,我成为了万古医圣 1"},
}
playlet := &PlayletInfoResp{Title: "灵气复苏,我成为了万古医圣"}
if got := ResolvePlayPageTitle(chapter, playlet); got != "灵气复苏,我成为了万古医圣 1" {
t.Fatalf("title=%q", got)
}
}
+123
View File
@@ -0,0 +1,123 @@
package playletser
import (
"strconv"
"strings"
)
const (
seriesOperateType = 2
seriesPageSize = 25
seriesFilterLatest = 1
seriesFilterHot = 2
seriesFilterOrigin = 3
)
var seriesFilters = []struct {
Type int
Slug string
Label string
}{
{Type: seriesFilterLatest, Slug: "latest", Label: "最新更新"},
{Type: seriesFilterHot, Slug: "popular", Label: "当前热播"},
{Type: seriesFilterOrigin, Slug: "original", Label: "独家原创"},
}
// ParseSeriesQuery 将语义化筛选路由转换为 Swagger 的 operateType/filterType 参数。
func ParseSeriesQuery(filterSlug, pageStr string) (OperateListQuery, bool) {
filterType, ok := seriesFilterType(filterSlug)
if !ok {
return OperateListQuery{}, false
}
q := OperateListQuery{
OperateType: seriesOperateType,
FilterType: filterType,
PageSize: seriesPageSize,
}
if page, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = page
}
q.Normalize()
return q, true
}
func seriesFilterType(slug string) (int, bool) {
slug = strings.TrimSpace(slug)
if slug == "" || slug == "latest" {
return seriesFilterLatest, true
}
for _, filter := range seriesFilters {
if filter.Slug == slug {
return filter.Type, true
}
}
return 0, false
}
func seriesFilterSlug(filterType int) string {
for _, filter := range seriesFilters {
if filter.Type == filterType {
return filter.Slug
}
}
return "latest"
}
func seriesPageURL(filterType, pageNum int) string {
if pageNum <= 0 {
pageNum = 1
}
path := "/series"
if filterType != seriesFilterLatest {
path += "/" + seriesFilterSlug(filterType)
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path + "/"
}
// SeriesCanonicalPath 返回 AI 成人剧集页唯一规范路径。
func SeriesCanonicalPath(q OperateListQuery) string {
q.Normalize()
return seriesPageURL(q.FilterType, q.PageNum)
}
// BuildSeriesFilterTabs 构建 Figma 中的三个运营筛选项。
func BuildSeriesFilterTabs(q OperateListQuery) []FilterChipVM {
q.Normalize()
out := make([]FilterChipVM, 0, len(seriesFilters))
for _, filter := range seriesFilters {
out = append(out, FilterChipVM{
Label: filter.Label,
URL: seriesPageURL(filter.Type, 1),
Active: q.FilterType == filter.Type,
})
}
return out
}
func SeriesSectionTitle(filterType int) string {
for _, filter := range seriesFilters {
if filter.Type == filterType {
return filter.Label + "的AI成人剧集"
}
}
return "最新更新的AI成人剧集"
}
func BuildSeriesPagination(q OperateListQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(page int) string {
return seriesPageURL(q.FilterType, page)
}, q.PageNum, q.PageSize, totalCount)
}
// SeriesPathFromLegacyList 将原 /list/ai-series 排序地址映射到新页面。
func SeriesPathFromLegacyList(sortOrder, pageNum int) string {
filterType := seriesFilterLatest
if sortOrder == sortHeat {
filterType = seriesFilterHot
}
return seriesPageURL(filterType, pageNum)
}
@@ -0,0 +1,53 @@
package playletser
import "testing"
func TestParseSeriesQueryMapsSwaggerOperateFilters(t *testing.T) {
tests := []struct {
slug string
wantFilter int
}{
{slug: "", wantFilter: seriesFilterLatest},
{slug: "latest", wantFilter: seriesFilterLatest},
{slug: "popular", wantFilter: seriesFilterHot},
{slug: "original", wantFilter: seriesFilterOrigin},
}
for _, tt := range tests {
q, ok := ParseSeriesQuery(tt.slug, "2")
if !ok {
t.Fatalf("ParseSeriesQuery(%q) rejected valid filter", tt.slug)
}
if q.OperateType != 2 || q.FilterType != tt.wantFilter || q.PageNum != 2 || q.PageSize != 25 {
t.Fatalf("ParseSeriesQuery(%q) = %#v", tt.slug, q)
}
}
if _, ok := ParseSeriesQuery("unknown", "1"); ok {
t.Fatal("unknown filter should be rejected")
}
}
func TestSeriesCanonicalPaths(t *testing.T) {
tests := []struct {
filter int
page int
want string
}{
{filter: seriesFilterLatest, page: 1, want: "/series"},
{filter: seriesFilterLatest, page: 3, want: "/series/page/3"},
{filter: seriesFilterHot, page: 1, want: "/series/popular"},
{filter: seriesFilterOrigin, page: 4, want: "/series/original/page/4"},
}
for _, tt := range tests {
got := seriesPageURL(tt.filter, tt.page)
if got != tt.want {
t.Fatalf("seriesPageURL(%d, %d) = %q, want %q", tt.filter, tt.page, got, tt.want)
}
}
}
func TestBuildSeriesFilterTabs(t *testing.T) {
tabs := BuildSeriesFilterTabs(OperateListQuery{OperateType: 2, FilterType: 2, PageNum: 1, PageSize: 25})
if len(tabs) != 3 || tabs[0].URL != "/series" || tabs[1].URL != "/series/popular" || !tabs[1].Active || tabs[2].URL != "/series/original" {
t.Fatalf("unexpected series tabs: %#v", tabs)
}
}
+110
View File
@@ -0,0 +1,110 @@
package playletser
import (
"strconv"
"strings"
)
const (
shortPlayOperateType = 1
shortPlayPageSize = 25
)
var shortPlayFilters = []struct {
Type int
Slug string
Label string
}{
{Type: 1, Slug: "latest", Label: "最新更新"},
{Type: 2, Slug: "popular", Label: "当前热播"},
{Type: 3, Slug: "original", Label: "独家原创"},
}
// ParseShortPlayQuery 将短剧页路由转换为 operate/list 的 Swagger 参数。
func ParseShortPlayQuery(filter, page string) (OperateListQuery, bool) {
filterType, ok := shortPlayFilterType(filter)
if !ok {
return OperateListQuery{}, false
}
q := OperateListQuery{
OperateType: shortPlayOperateType,
FilterType: filterType,
PageSize: shortPlayPageSize,
}
if pageNum, err := strconv.Atoi(strings.TrimSpace(page)); err == nil {
q.PageNum = pageNum
}
q.Normalize()
return q, true
}
func shortPlayFilterType(filter string) (int, bool) {
filter = strings.TrimSpace(filter)
switch filter {
case "", "latest", "2":
return 1, true
case "popular", "1":
return 2, true
case "original", "3":
return 3, true
default:
return 0, false
}
}
func shortPlayFilterSlug(filterType int) string {
for _, filter := range shortPlayFilters {
if filter.Type == filterType {
return filter.Slug
}
}
return "latest"
}
func shortPlayPageURL(filterType, pageNum int) string {
if pageNum <= 0 {
pageNum = 1
}
path := "/list/ai-short-play"
if filterType != 1 || pageNum > 1 {
path += "/" + shortPlayFilterSlug(filterType)
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path + "/"
}
func ShortPlayCanonicalPath(q OperateListQuery) string {
q.Normalize()
return shortPlayPageURL(q.FilterType, q.PageNum)
}
func BuildShortPlayFilterTabs(q OperateListQuery) []FilterChipVM {
q.Normalize()
out := make([]FilterChipVM, 0, len(shortPlayFilters))
for _, filter := range shortPlayFilters {
out = append(out, FilterChipVM{
Label: filter.Label,
URL: shortPlayPageURL(filter.Type, 1),
Active: q.FilterType == filter.Type,
})
}
return out
}
func ShortPlaySectionTitle(filterType int) string {
for _, filter := range shortPlayFilters {
if filter.Type == filterType {
return filter.Label + "的AI成人短剧"
}
}
return "最新更新的AI成人短剧"
}
func BuildShortPlayPagination(q OperateListQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(page int) string {
return shortPlayPageURL(q.FilterType, page)
}, q.PageNum, q.PageSize, totalCount)
}
@@ -0,0 +1,35 @@
package playletser
import (
"encoding/json"
"testing"
)
func TestHomeResponseDecodesDocumentedFields(t *testing.T) {
raw := []byte(`{
"announcements":[{"id":1,"title":"公告","content":"内容"}],
"homeRecommendThemes":[{"id":2,"name":"都市","slug":"urban","playletCount":8}],
"homeBannerData":[{"id":3,"name":"轮播A","image":"https://cdn.example.com/a.png","verticalImage":"https://cdn.example.com/a-v.png","url":"https://example.com","rank":1,"status":true}]
}`)
var got HomePlayletResponse
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
if len(got.Announcements) != 1 || got.Announcements[0].Content != "内容" {
t.Fatalf("announcements not decoded: %#v", got.Announcements)
}
if len(got.HomeRecommendThemes) != 1 || got.HomeRecommendThemes[0].Slug != "urban" {
t.Fatalf("recommended themes not decoded: %#v", got.HomeRecommendThemes)
}
if len(got.HomeBannerData) != 1 || got.HomeBannerData[0].Name != "轮播A" || got.HomeBannerData[0].Image == "" || got.HomeBannerData[0].VerticalImage == "" || !got.HomeBannerData[0].Status {
t.Fatalf("banner additions not decoded: %#v", got.HomeBannerData)
}
}
func TestOperateListQueryNormalize(t *testing.T) {
q := OperateListQuery{OperateType: 9, FilterType: -1, PageNum: 0, PageSize: 999}
q.Normalize()
if q.OperateType != 1 || q.FilterType != 1 || q.PageNum != 1 || q.PageSize != 100 {
t.Fatalf("unexpected normalized query: %#v", q)
}
}
+72
View File
@@ -0,0 +1,72 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
tagListPath = "/api/app/playlet/tag/list"
defaultTagSize = 30
)
type TagListQuery struct {
PageNum int
PageSize int
TagID int64
TagSlug string
}
func (q *TagListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultTagSize
}
if q.PageSize > 100 {
q.PageSize = 100
}
if q.TagID < 0 {
q.TagID = 0
}
}
func FetchTagList(c *gin.Context, q TagListQuery) (*PlayletVideoResp, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
if slug := strings.TrimSpace(q.TagSlug); slug != "" {
params.Set("tagSlug", slug)
} else if q.TagID > 0 {
params.Set("tagId", strconv.FormatInt(q.TagID, 10))
}
var env APIEnvelope[PlayletVideoResp]
if err := upstream.GetPublicJSON(c, tagListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet tag list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
func FetchTagListOrEmpty(c *gin.Context, q TagListQuery) *PlayletVideoResp {
data, err := FetchTagList(c, q)
if err != nil {
log.Error("fetch playlet tag list failed", log.E(err))
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
if data == nil {
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
+199
View File
@@ -0,0 +1,199 @@
package playletser
import (
"strconv"
"strings"
)
func tagListPageURL(tagID int64, tagSlug string, pageNum int) string {
if pageNum <= 0 {
pageNum = 1
}
path := "/tag"
if segment := pathSegment(tagSlug, tagID); segment != "" {
path += "/" + segment
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path + "/"
}
func tagListURL(item CommonData) string {
return cardTag(item).URL
}
func tagListURLByKey(slug string, id int64) string {
if segment := pathSegment(slug, id); segment != "" {
return "/tag/" + segment + "/"
}
return ""
}
func themeListURLByKey(slug string, id int64) string {
if segment := pathSegment(slug, id); segment != "" {
return "/list/" + segment + "/"
}
return ""
}
type NamedTag struct {
Name string
URL string
}
func cardTag(item CommonData) NamedTag {
tags := cardTags(item)
if len(tags) > 0 {
return tags[0]
}
return NamedTag{}
}
// cardTags returns at most three distinct labels for a poster card. Regular
// tags keep their API order, followed by theme labels when space remains.
func cardTags(item CommonData) []NamedTag {
out := make([]NamedTag, 0, 3)
seen := make(map[string]struct{}, 3)
appendUnique := func(tags []NamedTag) {
for _, tag := range tags {
name := strings.TrimSpace(tag.Name)
if name == "" {
continue
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
continue
}
tag.Name = name
out = append(out, tag)
seen[key] = struct{}{}
if len(out) == 3 {
return
}
}
}
appendUnique(namedTags(item.TagIDs, item.TagNames, item.TagSlugs))
if len(out) < 3 {
appendUnique(namedThemeTags(item.ThemeIDs, item.ThemeNames, item.ThemeSlugs))
}
return out
}
func namedTags(ids []int64, names, slugs []string) []NamedTag {
out := make([]NamedTag, 0, len(names))
for i, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
item := NamedTag{Name: name}
id := int64(0)
slug := ""
if i < len(ids) {
id = ids[i]
}
if i < len(slugs) {
slug = slugs[i]
}
item.URL = tagListURLByKey(slug, id)
out = append(out, item)
}
return out
}
func namedThemeTags(ids []int64, names, slugs []string) []NamedTag {
out := make([]NamedTag, 0, len(names))
for i, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
item := NamedTag{Name: name}
if i < len(ids) {
slug := ""
if i < len(slugs) {
slug = slugs[i]
}
item.URL = themeListURLByKey(slug, ids[i])
}
out = append(out, item)
}
return out
}
func TagListCanonicalPath(q TagListQuery) string {
q.Normalize()
return tagListPageURL(q.TagID, q.TagSlug, q.PageNum)
}
func BuildTagListPagination(q TagListQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(p int) string {
return tagListPageURL(q.TagID, q.TagSlug, p)
}, q.PageNum, q.PageSize, totalCount)
}
func ParseTagListQuery(pageStr string) TagListQuery {
q := TagListQuery{PageSize: defaultTagSize}
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = n
}
q.Normalize()
return q
}
func FindTagNameBySlug(list []CommonData, slug string) string {
slug = strings.TrimSpace(slug)
if slug == "" {
return ""
}
for _, item := range list {
for i, itemSlug := range item.TagSlugs {
if strings.TrimSpace(itemSlug) == slug && i < len(item.TagNames) {
return strings.TrimSpace(item.TagNames[i])
}
}
}
return ""
}
func FindTagName(list []CommonData, tagID int64) string {
if tagID <= 0 {
return ""
}
for _, item := range list {
for i, id := range item.TagIDs {
if id == tagID && i < len(item.TagNames) {
return strings.TrimSpace(item.TagNames[i])
}
}
}
return ""
}
func FindTagSlug(list []CommonData, tagID int64) string {
if tagID <= 0 {
return ""
}
for _, item := range list {
for i, id := range item.TagIDs {
if id == tagID && i < len(item.TagSlugs) {
return strings.TrimSpace(item.TagSlugs[i])
}
}
}
return ""
}
func TagListPageTitle(tagName string, pageNum int, seo SeoInfo) string {
if title := strings.TrimSpace(seo.Title); title != "" {
return title
}
base := "标签短剧"
if tagName != "" {
base = tagName + "短剧"
}
return base + PageTitleSuffix(pageNum)
}
+66
View File
@@ -0,0 +1,66 @@
package playletser
import "testing"
func TestTagListPageURLUsesSemanticSlugRoute(t *testing.T) {
tests := []struct {
name string
id int64
slug string
page int
want string
}{
{name: "root", page: 1, want: "/tag"},
{name: "slug", id: 99, slug: "urban-love", page: 1, want: "/tag/urban-love"},
{name: "slug page", id: 99, slug: "urban-love", page: 3, want: "/tag/urban-love/page/3"},
{name: "id fallback", id: 99, page: 1, want: "/tag/99"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tagListPageURL(tt.id, tt.slug, tt.page); got != tt.want {
t.Fatalf("tagListPageURL(%d, %q, %d) = %q, want %q", tt.id, tt.slug, tt.page, got, tt.want)
}
})
}
}
func TestTagListURLByKeyPrefersSlugAndFallsBackToID(t *testing.T) {
if got := tagListURLByKey("fantasy", 12); got != "/tag/fantasy" {
t.Fatalf("slug URL = %q", got)
}
if got := tagListURLByKey("", 12); got != "/tag/12" {
t.Fatalf("id fallback URL = %q", got)
}
}
func TestCardTagsReturnsAtMostThreeDistinctLabels(t *testing.T) {
item := CommonData{
TagIDs: []int64{1, 2, 3},
TagNames: []string{"都市", "甜宠", "都市"},
TagSlugs: []string{"city", "sweet", "city-copy"},
ThemeIDs: []int64{4, 5},
ThemeNames: []string{"逆袭", "复仇"},
ThemeSlugs: []string{"counterattack", "revenge"},
}
got := cardTags(item)
if len(got) != 3 {
t.Fatalf("cardTags length = %d, want 3", len(got))
}
if got[0].Name != "都市" || got[0].URL != "/tag/city" {
t.Fatalf("first tag = %#v", got[0])
}
if got[1].Name != "甜宠" || got[1].URL != "/tag/sweet" {
t.Fatalf("second tag = %#v", got[1])
}
if got[2].Name != "逆袭" || got[2].URL != "/list/counterattack" {
t.Fatalf("third tag = %#v", got[2])
}
}
func TestNamedTagsUsesSlugWithoutID(t *testing.T) {
got := namedTags(nil, []string{"甜宠"}, []string{"sweet-love"})
if len(got) != 1 || got[0].URL != "/tag/sweet-love" {
t.Fatalf("namedTags slug-only URL = %#v", got)
}
}
+72
View File
@@ -0,0 +1,72 @@
package playletser
import (
"strconv"
"strings"
)
const defaultThemeIndexPageSize = 20
// ThemeIndexQuery 专题列表页分页参数。
type ThemeIndexQuery struct {
PageNum int
PageSize int
}
func (q *ThemeIndexQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultThemeIndexPageSize
}
}
// ParseThemeIndexQuery 从语义化分页路由读取页码。
func ParseThemeIndexQuery(page string) ThemeIndexQuery {
q := ThemeIndexQuery{PageSize: defaultThemeIndexPageSize}
if n, err := strconv.Atoi(strings.TrimSpace(page)); err == nil {
q.PageNum = n
}
q.Normalize()
return q
}
// BuildThemeIndexPage 过滤已启用专题,并按设计稿每页 20 项分页。
func BuildThemeIndexPage(themes []PlayletThemeItem, q ThemeIndexQuery) ([]PlayletThemeItem, int64) {
q.Normalize()
visible := make([]PlayletThemeItem, 0, len(themes))
for _, theme := range themes {
if !theme.Status || theme.ID <= 0 || strings.TrimSpace(theme.Name) == "" {
continue
}
visible = append(visible, theme)
}
total := int64(len(visible))
start := (q.PageNum - 1) * q.PageSize
if start >= len(visible) {
return nil, total
}
end := start + q.PageSize
if end > len(visible) {
end = len(visible)
}
return visible[start:end], total
}
func themeIndexPageURL(page int) string {
if page <= 1 {
return "/topic/"
}
return "/topic/page/" + strconv.Itoa(page) + "/"
}
func ThemeIndexCanonicalPath(q ThemeIndexQuery) string {
q.Normalize()
return themeIndexPageURL(q.PageNum)
}
func BuildThemeIndexPagination(q ThemeIndexQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(themeIndexPageURL, q.PageNum, q.PageSize, totalCount)
}
@@ -0,0 +1,24 @@
package playletser
import "testing"
func TestBuildThemeIndexPage(t *testing.T) {
themes := make([]PlayletThemeItem, 0, 23)
for i := 1; i <= 23; i++ {
themes = append(themes, PlayletThemeItem{ID: int64(i), Name: "专题", Status: true})
}
themes[1].Status = false
themes[2].Name = ""
first, total := BuildThemeIndexPage(themes, ParseThemeIndexQuery("1"))
if total != 21 || len(first) != 20 {
t.Fatalf("first page = (%d, %d), want (20, 21)", len(first), total)
}
second, _ := BuildThemeIndexPage(themes, ParseThemeIndexQuery("2"))
if len(second) != 1 {
t.Fatalf("second page length = %d, want 1", len(second))
}
if got := ThemeIndexCanonicalPath(ParseThemeIndexQuery("2")); got != "/topic/page/2" {
t.Fatalf("canonical path = %q, want /topic/page/2", got)
}
}
+66
View File
@@ -0,0 +1,66 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
todayUpdatePath = "/api/app/playlet/today-update"
defaultTodayUpdateSize = 40
)
// TodayUpdateQuery 今日更新分页参数。
type TodayUpdateQuery struct {
PageNum int
PageSize int
}
// Normalize 校正分页默认值。
func (q *TodayUpdateQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultTodayUpdateSize
}
if q.PageSize > 50 {
q.PageSize = 50
}
}
// FetchTodayUpdate 分页查询今日更新短剧。
func FetchTodayUpdate(c *gin.Context, q TodayUpdateQuery) (*PlayletVideoResp, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[PlayletVideoResp]
if err := upstream.GetPublicJSON(c, todayUpdatePath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet today-update code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchTodayUpdateOrEmpty 失败时返回空列表。
func FetchTodayUpdateOrEmpty(c *gin.Context, q TodayUpdateQuery) *PlayletVideoResp {
data, err := FetchTodayUpdate(c, q)
if err != nil {
log.Error("fetch playlet today-update failed", log.E(err))
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
if data == nil {
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data.SeoInfo.Normalize()
return data
}
@@ -0,0 +1,36 @@
package playletser
import (
"strconv"
"strings"
)
func todayUpdatePageURL(pageNum int) string {
if pageNum <= 1 {
return "/today-update/"
}
return "/today-update/page/" + strconv.Itoa(pageNum) + "/"
}
func TodayUpdateCanonicalPath(q TodayUpdateQuery) string {
q.Normalize()
return todayUpdatePageURL(q.PageNum)
}
// BuildTodayUpdatePagination 今日更新分页。
func BuildTodayUpdatePagination(q TodayUpdateQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(p int) string {
return todayUpdatePageURL(p)
}, q.PageNum, q.PageSize, totalCount)
}
// ParseTodayUpdateQuery 从 query 解析分页。
func ParseTodayUpdateQuery(pageStr string) TodayUpdateQuery {
q := TodayUpdateQuery{PageSize: defaultTodayUpdateSize}
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = n
}
q.Normalize()
return q
}
+385
View File
@@ -0,0 +1,385 @@
package playletser
import (
"fmt"
"html/template"
"net/url"
"strings"
"time"
)
// TemplateFuncs 模板辅助:仅做展示格式化 / URL,不把接口字段映射成另一套名字。
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"detailURL": detailURL,
"playURL": playURL,
"chapterPlayURL": chapterPlayURL,
"categoryURL": categoryURL,
"categoryDetailURL": categoryDetailURL,
"themeListURL": themeListURLByKey,
"coverURL": coverURL,
"formatHeat": formatHeat,
"formatCollect": formatCollectHeat,
"formatLikeCount": formatLikeCount,
"episodeLabel": episodeText,
"displayTag": displayTag,
"firstTagName": firstTagName,
"themeTagText": themeTagText,
"tagListURL": tagListURL,
"cardTag": cardTag,
"cardTags": cardTags,
"namedTags": namedTags,
"namedThemeTags": namedThemeTags,
"firstName": firstName,
"rankClass": rankClass,
"add": addInt,
"contains": strings.Contains,
"hasPrefix": strings.HasPrefix,
"footerLinkURL": footerLinkURL,
"footerLinkIsExternal": footerLinkIsExternal,
"footerContactURL": footerContactURL,
"footerContactName": footerContactName,
"footerContactValue": footerContactValue,
"navDisplayURL": navDisplayURL,
"urlquery": url.QueryEscape,
"pathescape": url.PathEscape,
"metaLine": metaLine,
"updateStatusText": updateStatusText,
"browseText": browseText,
"usableAnchor": usableAnchor,
"playletYearText": playletYearText,
"playletDateText": playletDateText,
"isPlayletAllFree": isPlayletAllFree,
"absInt64": absInt64Tpl,
"absURL": absURLTpl,
"canonicalOr": canonicalOr,
"joinStrings": joinStrings,
"dict": dict,
}
}
// dict builds a map for nested template calls: {{template "x" dict "K" v}}.
func dict(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict requires an even number of arguments")
}
out := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict keys must be strings")
}
out[key] = values[i+1]
}
return out, nil
}
func navDisplayURL(raw string) string {
value := strings.TrimSpace(raw)
value = strings.TrimPrefix(value, "https://")
value = strings.TrimPrefix(value, "http://")
return strings.TrimRight(value, "/")
}
// footerLinkURL 按配置地址跳转:/richtext/xxx 进入富文本页,http 开头视为外链原样返回。
func footerLinkURL(_, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "/" || raw == "#" {
return ""
}
if footerLinkIsExternal(raw) {
return raw
}
if href := footerRichtextHref(raw); href != "" {
return href
}
return normalizeFooterInternalURL(raw)
}
func footerLinkIsExternal(raw string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "http")
}
func footerRichtextHref(raw string) string {
path := raw
if i := strings.IndexAny(raw, "?#"); i >= 0 {
path = raw[:i]
}
path = strings.TrimRight(strings.TrimSpace(path), "/")
if path == "/richtext" {
return "/richtext/"
}
if strings.HasPrefix(path, "/richtext/") {
return path + "/"
}
return ""
}
func normalizeFooterInternalURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
if parsed.Scheme != "" || parsed.Host != "" || !strings.HasPrefix(parsed.Path, "/") {
return raw
}
if parsed.Path != "/" && !strings.HasSuffix(parsed.Path, "/") {
if parsed.RawQuery == "" && parsed.Fragment == "" {
return raw + "/"
}
parsed.Path += "/"
href := parsed.Path
if parsed.RawQuery != "" {
href += "?" + parsed.RawQuery
}
if parsed.Fragment != "" {
href += "#" + parsed.Fragment
}
return href
}
return raw
}
// footerContactURL 把页脚联系方式转换为可点击地址,并使用全站配置补齐按钮型文案。
func footerContactURL(name, value, gmailAddr, tgGroup, customerServiceHost string) string {
normalizedName := strings.ToLower(strings.ReplaceAll(footerContactName(name), " ", ""))
switch {
case strings.Contains(normalizedName, "官方群") || strings.Contains(normalizedName, "telegram") || strings.Contains(normalizedName, "tg群"):
return firstContactURL(tgGroup, value)
case strings.Contains(normalizedName, "商务") || strings.Contains(normalizedName, "合作"):
return firstContactURL(customerServiceHost, value, gmailAddr)
case strings.Contains(normalizedName, "邮箱") || strings.Contains(normalizedName, "email") || strings.Contains(normalizedName, "e-mail"):
return firstContactURL(value, gmailAddr)
default:
return firstContactURL(value)
}
}
func footerContactName(name string) string {
return cleanFooterContactAction(name)
}
func footerContactValue(value string) string {
return cleanFooterContactAction(value)
}
func cleanFooterContactAction(value string) string {
value = strings.TrimSpace(value)
for _, action := range []string{"立即联系", "立即加入"} {
value = strings.ReplaceAll(value, action, "")
}
for {
cleaned := strings.TrimSpace(strings.Trim(value, "::"))
if cleaned == value {
return cleaned
}
value = cleaned
}
}
func firstContactURL(values ...string) string {
for _, value := range values {
if href := normalizeContactURL(value); href != "" {
return href
}
}
return ""
}
func normalizeContactURL(value string) string {
value = footerContactValue(value)
if value == "" || strings.ContainsAny(value, " \t\r\n") {
return ""
}
lower := strings.ToLower(value)
if strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "tel:") {
return value
}
if strings.HasPrefix(value, "//") {
return "https:" + value
}
if strings.HasPrefix(value, "/") {
return value
}
if strings.Contains(value, "@") && strings.Contains(value, ".") {
return "mailto:" + value
}
if strings.Contains(value, ".") {
return "https://" + value
}
return ""
}
// absURLTpl 模板内绝对化 URL:absURL foreverUrlAddr path
func absURLTpl(siteURL, path string) string {
return joinAbs(siteURL, path)
}
// canonicalOr 优先使用接口返回的 canonical,否则用 foreverUrlAddr+fallbackPath。
func canonicalOr(siteURL, preferred, fallbackPath string) string {
preferred = strings.TrimSpace(preferred)
if preferred != "" {
if strings.HasPrefix(preferred, "http://") || strings.HasPrefix(preferred, "https://") {
return preferred
}
return joinAbs(siteURL, preferred)
}
return joinAbs(siteURL, fallbackPath)
}
func joinAbs(siteURL, path string) string {
siteURL = strings.TrimRight(strings.TrimSpace(siteURL), "/")
path = strings.TrimSpace(path)
if path == "" {
if siteURL == "" {
return "/"
}
return siteURL + "/"
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if siteURL == "" {
return path
}
return siteURL + path
}
// coverURL 选出封面地址。返回 template.URL,避免 html/template 把 data-src
// 当 URL 上下文时把已百分号编码的中文路径再次转成 %25xx(导致封面 404)。
func coverURL(primary, fallback string) template.URL {
return template.URL(firstNonEmpty(primary, fallback))
}
func displayTag(item CommonData) string {
if name := firstName(item.TagNames); name != "" {
return name
}
if name := firstName(item.ThemeNames); name != "" {
return name
}
return "短剧"
}
func firstTagName(item CommonData) string {
if len(item.TagNames) == 0 {
return ""
}
return strings.TrimSpace(item.TagNames[0])
}
func firstName(names []string) string {
for _, n := range names {
n = strings.TrimSpace(n)
if n != "" {
return n
}
}
return ""
}
func themeTagText(themeNames, tagNames []string) string {
theme := firstName(themeNames)
tag := firstName(tagNames)
switch {
case theme != "" && tag != "" && theme != tag:
return theme + " · " + tag
case tag != "":
return tag
default:
return theme
}
}
func addInt(a, b int) int { return a + b }
func absInt64Tpl(n int64) int64 {
if n < 0 {
return -n
}
return n
}
func metaLine(item CommonData) string {
cat := firstName(item.ThemeNames)
if cat == "" {
cat = firstName(item.TagNames)
}
if item.TotalEpisode > 0 {
if cat != "" {
return fmt.Sprintf("%s·%d集", cat, item.TotalEpisode)
}
return fmt.Sprintf("%d集", item.TotalEpisode)
}
return cat
}
// playletYearText 详情 PC 元信息年份;无有效 onlineAt 时展示「年份未知」。
func playletYearText(onlineAt string) string {
onlineAt = strings.TrimSpace(onlineAt)
if len(onlineAt) < 4 {
return "年份未知"
}
year := onlineAt[:4]
for _, c := range year {
if c < '0' || c > '9' {
return "年份未知"
}
}
if year == "0001" || year == "1970" {
return "年份未知"
}
return year
}
// playletDateText formats the first valid detail-page date as YYYY.MM.DD.
func playletDateText(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if len(value) < 10 {
continue
}
date := strings.NewReplacer("-", ".", "/", ".").Replace(value[:10])
parts := strings.Split(date, ".")
if len(parts) != 3 || len(parts[0]) != 4 || len(parts[1]) != 2 || len(parts[2]) != 2 {
continue
}
validDigits := true
for _, part := range parts {
for _, c := range part {
if c < '0' || c > '9' {
validDigits = false
break
}
}
if !validDigits {
break
}
}
if !validDigits || parts[0] == "0001" || parts[0] == "1970" {
continue
}
parsed, err := time.Parse("2006.01.02", date)
if err != nil {
continue
}
return parsed.Format("2006.01.02")
}
return "时间未知"
}
// isPlayletAllFree 全部剧集 PayType==0 时视为全站免费。
func isPlayletAllFree(chapters []PlayletChapterData) bool {
if len(chapters) == 0 {
return false
}
for _, ch := range chapters {
if ch.PayType != 0 {
return false
}
}
return true
}
+264
View File
@@ -0,0 +1,264 @@
package playletser
// HomePlayletResponse 对应 swagger proto.HomePlayletResponse
// GET /api/app/playlet/home
type HomePlayletResponse struct {
SeoInfo SeoInfo `json:"seoInfo"`
Announcements []AnnouncementItem `json:"announcements"`
HomeBannerData []HomeBannerData `json:"homeBannerData"`
HomeTodayUpdateData []CommonData `json:"homeTodayUpdateData"`
HomeCategoryData []HomeCategoryData `json:"homeCategoryData"`
HomeRecommendThemes []HomeRecommendTheme `json:"homeRecommendThemes"`
}
type AnnouncementItem struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
}
type HomeRecommendTheme struct {
ID int64 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Icon string `json:"icon"`
Rank int `json:"rank"`
PlayletCount int64 `json:"playletCount"`
}
// HomeBannerData 对应 swagger proto.HomeBannerData
type HomeBannerData struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
VerticalImage string `json:"verticalImage"`
URL string `json:"url"`
Rank int `json:"rank"`
Status bool `json:"status"`
}
// HomeCategoryData 对应 swagger proto.HomeCategoryData
type HomeCategoryData struct {
ID int64 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Desc string `json:"desc"`
Icon string `json:"icon"`
Rank int `json:"rank"`
List []CommonData `json:"list"`
}
// CommonData 对应 swagger proto.CommonData
type CommonData struct {
ID int64 `json:"id"`
Status int `json:"status"`
IsFeatured bool `json:"isFeatured"`
IsNew bool `json:"isNew"`
IsRecommend bool `json:"isRecommend"`
OperateType int `json:"operateType"`
ShowOnBanner bool `json:"showOnBanner"`
Banner string `json:"banner"`
OnlineAt string `json:"onlineAt"`
MediaCreatedAt string `json:"mediaCreatedAt"`
MediaUpdatedAt string `json:"mediaUpdatedAt"`
ThemeIDs []int64 `json:"themeIds"`
ThemeNames []string `json:"themeNames"`
ThemeSlugs []string `json:"themeSlugs"`
Title string `json:"title"`
PlayName string `json:"playName"`
Description string `json:"description"`
Slug string `json:"slug"`
Desc string `json:"desc"`
Anchor string `json:"anchor"`
Cover string `json:"cover"`
TagIDs []int64 `json:"tagIds"`
TagNames []string `json:"tagNames"`
TagSlugs []string `json:"tagSlugs"`
TotalEpisode int `json:"totalEpisode"`
UpdateStatus int `json:"updateStatus"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
HeatValue int64 `json:"heatValue"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
SeoInfo SeoInfo `json:"seoInfo"`
}
// PlayletVideoResp 对应 swagger proto.PlayletVideoResp
// GET /api/app/playlet/list
type PlayletVideoResp struct {
SeoInfo SeoInfo `json:"seoInfo"`
List []CommonData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// PlayletRecommendListResp 对应 swagger proto.PlayletRecommendListResp。
// GET /api/app/playlet/recommend/list
type PlayletRecommendListResp struct {
CategoryID int64 `json:"categoryId"`
CategoryName string `json:"categoryName"`
CategorySlug string `json:"categorySlug"`
CategoryDesc string `json:"categoryDesc"`
List []CommonData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
SeoInfo SeoInfo `json:"seoInfo"`
TotalCount int64 `json:"totalCount"`
}
// PlayletThemeItem 对应 swagger proto.PlayletThemeItem
type PlayletThemeItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Icon string `json:"icon"`
Rank int `json:"rank"`
Status bool `json:"status"`
IsRecommend bool `json:"isRecommend"`
PlayletCount int64 `json:"playletCount"`
SeoInfo SeoInfo `json:"seoInfo"`
}
// PlayletThemeListResponse 对应 swagger proto.PlayletThemeListResponse
// GET /api/app/theme/list
type PlayletThemeListResponse struct {
List []PlayletThemeItem `json:"list"`
}
// PlayletChapterData 对应 swagger proto.PlayletChapterData
// 出现在 GET /api/app/playlet/detail 的 playletChapterInfo。
type PlayletChapterData struct {
ID int64 `json:"id"`
PlayletID int64 `json:"playletId"`
CurrentEpisode int `json:"currentEpisode"`
Title string `json:"title"`
MD5 string `json:"md5"`
Hash string `json:"hash"`
Height int `json:"height"`
Width int `json:"width"`
Ratio float64 `json:"ratio"`
VideoURL string `json:"videoUrl"`
MediaSize int64 `json:"mediaSize"`
MediaTime int `json:"mediaTime"`
PayType int `json:"payType"`
SingleCoins int `json:"singleCoins"`
CostCoins int64 `json:"costCoins"`
Balance int64 `json:"balance"`
CanWatch bool `json:"canWatch"`
IsUnlocked bool `json:"isUnlocked"`
NeedUnlock bool `json:"needUnlock"`
NeedVip bool `json:"needVip"`
VipValid bool `json:"vipValid"`
}
// PlayletChapterListResp 对应 swagger proto.PlayletChapterListResp。
type PlayletChapterListResp struct {
PlayletID int64 `json:"playletId"`
List []PlayletChapterData `json:"list"`
}
// PlayletOperateListResp 对应 swagger proto.PlayletOperateListResp。
type PlayletOperateListResp struct {
OperateType int `json:"operateType"`
FilterType int `json:"filterType"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
List []CommonData `json:"list"`
}
// PlayletInfoResp 对应 swagger proto.PlayletInfoResp
// GET /api/app/playlet/detail
type PlayletInfoResp struct {
ID int64 `json:"id"`
Status int `json:"status"`
IsFeatured bool `json:"isFeatured"`
IsNew bool `json:"isNew"`
IsRecommend bool `json:"isRecommend"`
OperateType int `json:"operateType"`
ShowOnBanner bool `json:"showOnBanner"`
Banner string `json:"banner"`
OnlineAt string `json:"onlineAt"`
MediaCreatedAt string `json:"mediaCreatedAt"`
MediaUpdatedAt string `json:"mediaUpdatedAt"`
ThemeIDs []int64 `json:"themeIds"`
ThemeNames []string `json:"themeNames"`
ThemeSlugs []string `json:"themeSlugs"`
Title string `json:"title"`
PlayName string `json:"playName"`
Description string `json:"description"`
Slug string `json:"slug"`
Desc string `json:"desc"`
Anchor string `json:"anchor"`
Cover string `json:"cover"`
TagIDs []int64 `json:"tagIds"`
TagNames []string `json:"tagNames"`
TagSlugs []string `json:"tagSlugs"`
TotalEpisode int `json:"totalEpisode"`
UpdateStatus int `json:"updateStatus"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
HeatValue int64 `json:"heatValue"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
IsLike int `json:"isLike"`
IsCollect int `json:"isCollect"`
SeoInfo SeoInfo `json:"seoInfo"`
RelatedRecommendData []CommonData `json:"relatedRecommendData"`
PlayletChapterInfo []PlayletChapterData `json:"playletChapterInfo"`
}
// SeoInfo 对应 GET /api/app/playlet/detail 的 proto.SeoInfo。
// 字段:title / description / keywords / canonicalUrl / webSiteName。
type SeoInfo struct {
Title string `json:"title"`
Description string `json:"description"`
Keywords []string `json:"keywords"`
CanonicalURL string `json:"canonicalUrl"`
WebSiteName string `json:"webSiteName"`
}
// PlayletChapterInfoResp 对应 swagger proto.PlayletChapterInfoResp
// GET /api/app/playlet/chapter/detail
type PlayletChapterInfoResp struct {
ID int64 `json:"id"`
PlayletID int64 `json:"playletId"`
CurrentEpisode int `json:"currentEpisode"`
Title string `json:"title"`
MD5 string `json:"md5"`
Hash string `json:"hash"`
Height int `json:"height"`
Width int `json:"width"`
Ratio float64 `json:"ratio"`
VideoURL string `json:"videoUrl"`
MediaSize int64 `json:"mediaSize"`
MediaTime int `json:"mediaTime"`
CountComment int64 `json:"countComment"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
IsLike int `json:"isLike"`
IsCollect int `json:"isCollect"`
PayType int `json:"payType"`
SingleCoins int `json:"singleCoins"`
CostCoins int64 `json:"costCoins"`
Balance int64 `json:"balance"`
CanWatch bool `json:"canWatch"`
IsUnlocked bool `json:"isUnlocked"`
NeedUnlock bool `json:"needUnlock"`
NeedVip bool `json:"needVip"`
VipValid bool `json:"vipValid"`
SeoInfo SeoInfo `json:"seoInfo"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data T `json:"data"`
}
+147
View File
@@ -0,0 +1,147 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
)
func pathSegment(slug string, id int64) string {
slug = strings.TrimSpace(slug)
if slug == "" && id > 0 {
slug = strconv.FormatInt(id, 10)
}
if slug == "" {
return ""
}
return url.PathEscape(slug)
}
func detailURL(slug string, id int64) string {
if segment := pathSegment(slug, id); segment != "" {
return "/detail/" + segment + "/"
}
return "#"
}
func playURL(slug string, playletID int64) string {
if segment := pathSegment(slug, playletID); segment != "" {
return "/play/" + segment + "/"
}
return "#"
}
func chapterPlayURL(slug string, playletID, chapterID int64) string {
path := playURL(slug, playletID)
if path == "#" || chapterID <= 0 {
return path
}
return fmt.Sprintf("%s%d/", path, chapterID)
}
func categoryURL(slug string, id int64) string {
if segment := pathSegment(slug, id); segment != "" {
return "/topic/" + segment + "/"
}
return "/topic/"
}
func DetailPath(slug string, id int64) string {
return detailURL(slug, id)
}
func PlayPath(slug string, id int64) string {
return playURL(slug, id)
}
func ChapterPlayPath(slug string, playletID, chapterID int64) string {
return chapterPlayURL(slug, playletID, chapterID)
}
func updateStatusText(status int) string {
if status == 2 {
return "已完结"
}
return "连载中"
}
func browseText(n int64) string {
if n <= 0 {
return "0次播放"
}
if n < 10000 {
return fmt.Sprintf("%d次播放", n)
}
v := float64(n) / 10000
if v >= 100 {
return fmt.Sprintf("%.0f万次播放", v)
}
return fmt.Sprintf("%.1f万次播放", v)
}
func usableAnchor(anchor string) string {
anchor = strings.TrimSpace(anchor)
if anchor == "" || anchor == "暂无" {
return ""
}
return anchor
}
func rankClass(rank int) string {
switch rank {
case 1:
return "gold"
case 2:
return "silver"
case 3:
return "bronze"
default:
return "dark"
}
}
func episodeText(n int) string {
if n <= 0 {
return ""
}
return fmt.Sprintf("%d集", n)
}
func formatHeat(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)
}
// formatLikeCount 封面点赞数展示:不足一万原样,否则 x.x万。
func formatLikeCount(n int64) string {
if n <= 0 {
return "0"
}
if n < 10000 {
return fmt.Sprintf("%d", n)
}
v := float64(n) / 10000
if v >= 100 {
return fmt.Sprintf("%.0f万", v)
}
return fmt.Sprintf("%.1f万", v)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+84
View File
@@ -0,0 +1,84 @@
package playletser
import "testing"
func TestPlayletURLsPreferSlugAndFallbackToIDPath(t *testing.T) {
if got := detailURL("my-show", 12); got != "/detail/my-show" {
t.Fatalf("detailURL with slug = %q", got)
}
if got := detailURL("", 12); got != "/detail/12" {
t.Fatalf("detailURL ID fallback = %q", got)
}
if got := chapterPlayURL("my-show", 12, 3); got != "/play/my-show/3" {
t.Fatalf("chapterPlayURL with slug = %q", got)
}
if got := chapterPlayURL("", 12, 3); got != "/play/12/3" {
t.Fatalf("chapterPlayURL ID fallback = %q", got)
}
if got := themeListURLByKey("wuxia", 10404); got != "/list/wuxia" {
t.Fatalf("themeListURLByKey with slug = %q", got)
}
if got := themeListURLByKey("", 10404); got != "/list/10404" {
t.Fatalf("themeListURLByKey ID fallback = %q", got)
}
if got := categoryURL("editor-picks", 12); got != "/topic/editor-picks" {
t.Fatalf("categoryURL with slug = %q", got)
}
if got := categoryURL("", 12); got != "/topic/12" {
t.Fatalf("categoryURL ID fallback = %q", got)
}
if got := recommendPageURL(12, "editor-picks", 2); got != "/topic/editor-picks/page/2" {
t.Fatalf("recommendPageURL pagination = %q", got)
}
if got := rankPageURL(rankSortNew, 1); got != "/rank/2" {
t.Fatalf("rankPageURL with sort order = %q", got)
}
if got := rankPageURL(rankSortNew, 2); got != "/rank/2/page/2" {
t.Fatalf("rankPageURL pagination = %q", got)
}
q := ListQuery{ThemeSlug: "jiating", SortOrder: sortTodayUpdate, Type: contentTypeNonAdult}
if got := listPageURL(q.ThemeID, q.ThemeSlug, q.SortOrder, 1, q.Type); got != "/list/jiating/2" {
t.Fatalf("listPageURL with theme and sort = %q", got)
}
if got := listPageURL(q.ThemeID, q.ThemeSlug, q.SortOrder, 2, q.Type); got != "/list/jiating/2/page/2" {
t.Fatalf("listPageURL pagination = %q", got)
}
}
func TestListPageURLUsesSortPath(t *testing.T) {
if got := listPageURL(0, "zhichang", sortTodayUpdate, 1, contentTypeNonAdult); got != "/list/zhichang/2" {
t.Fatalf("listPageURL with slug and sort = %q", got)
}
if got := listPageURL(12, "", sortHeat, 1, contentTypeAdult); got != "/list/12/1" {
t.Fatalf("listPageURL ID fallback = %q", got)
}
if got := listPageURL(0, "ai-short-play", sortTodayUpdate, 1, contentTypeAdult); got != "/list/ai-short-play" {
t.Fatalf("listPageURL editorial default = %q", got)
}
if got := listPageURL(0, "ai-short-play", sortHeat, 1, contentTypeAdult); got != "/list/ai-short-play/1" {
t.Fatalf("listPageURL editorial hot = %q", got)
}
}
func TestPlayletDateText(t *testing.T) {
tests := map[string]string{
"2020-09-09 12:30:00": "2020.09.09",
"2026/03/18": "2026.03.18",
"2026.03.18T08:00:00": "2026.03.18",
"": "时间未知",
"1970-01-01": "时间未知",
"2026-13-40": "时间未知",
"invalid-date": "时间未知",
}
for input, want := range tests {
if got := playletDateText(input); got != want {
t.Fatalf("playletDateText(%q) = %q, want %q", input, got, want)
}
}
if got := playletDateText("", "2026-04-05 10:20:30", "2025-01-01"); got != "2026.04.05" {
t.Fatalf("playletDateText fallback = %q, want %q", got, "2026.04.05")
}
if got := playletDateText("invalid-date", "", "2025/08/09"); got != "2025.08.09" {
t.Fatalf("playletDateText valid fallback = %q, want %q", got, "2025.08.09")
}
}
+37
View File
@@ -0,0 +1,37 @@
package seoser
import (
"net/url"
"strings"
)
// IsIndexablePath 是否为需要收录的公开页(与 robots 可抓取、页面 index,follow 对齐)。
func IsIndexablePath(raw string) bool {
path := strings.TrimSpace(raw)
if path == "" {
return true
}
if u, err := url.Parse(path); err == nil && u.Path != "" {
path = u.Path
}
path = strings.TrimSuffix(path, "/")
if path == "" {
return true
}
switch path {
case "/list", "/series", "/rank", "/today-update", "/topic", "/tag", "/category",
"/download-app", "/application", "/home-road", "/detail", "/play", "/nav",
"/about-us", "/aboutUs", "/help-center", "/helpCenter":
return true
default:
return strings.HasPrefix(path, "/list/") ||
strings.HasPrefix(path, "/category/") ||
strings.HasPrefix(path, "/series/") ||
strings.HasPrefix(path, "/topic/") ||
strings.HasPrefix(path, "/tag/") ||
strings.HasPrefix(path, "/rank/") ||
strings.HasPrefix(path, "/today-update/") ||
strings.HasPrefix(path, "/detail/") ||
strings.HasPrefix(path, "/play/")
}
}
+862
View File
@@ -0,0 +1,862 @@
package seoser
import (
"encoding/json"
"fmt"
"html/template"
"net/url"
"strconv"
"strings"
"time"
"videoweb/app/service/applistser"
"videoweb/app/service/configser"
"videoweb/app/service/playletser"
)
const brandName = "黄果短剧"
type ldThing map[string]any
// FAQItem 同时驱动页面可见内容与 FAQPage JSON-LD,避免标记内容与页面正文不一致。
type FAQItem struct {
Question string
Answer string
}
// PageJSONLD 按页面数据生成可被 Schema.org / 富媒体测试校验的 JSON-LD。
// 仅收录页生成;非收录页返回空。pageCanonical 为当前请求完整 URL。
func PageJSONLD(siteURL, pageCanonical, path string, data map[string]any, cfg configser.AppConfigVM) template.JS {
siteURL = strings.TrimRight(strings.TrimSpace(siteURL), "/")
pageCanonical = strings.TrimSpace(pageCanonical)
path = strings.TrimSpace(path)
if !IsIndexablePath(path) {
return ""
}
pageURL := pageCanonical
if pageURL == "" {
pageURL = ensureTrailingSlash(AbsURL(siteURL, path))
}
graph := make([]any, 0, 8)
graph = append(graph, organizationNode(siteURL, cfg), websiteNode(siteURL, cfg))
if detail, ok := asPlaylet(data["detail"]); ok {
graph = append(graph, detailNodes(siteURL, pageURL, detail)...)
} else if playlet, hasPlaylet := asPlaylet(data["playlet"]); hasPlaylet || data["chapter"] != nil {
chapter, _ := asChapter(data["chapter"])
graph = append(graph, playNodes(siteURL, pageURL, playlet, chapter)...)
} else {
pageNode := webpageNode(siteURL, pageURL, data, cfg)
graph = append(graph, pageNode)
if crumb := genericBreadcrumb(siteURL, pageURL, data); crumb != nil {
pageNode["breadcrumb"] = ldThing{"@id": pageURL + "#breadcrumb"}
graph = append(graph, crumb)
}
if items := itemListNode(siteURL, pageURL, data); items != nil {
pageNode["mainEntity"] = ldThing{"@id": pageURL + "#itemlist"}
graph = append(graph, items)
}
if faq := faqPageNode(siteURL, pageURL, data); faq != nil {
pageNode["hasPart"] = ldThing{"@id": pageURL + "#faq"}
graph = append(graph, faq)
}
}
raw, err := json.Marshal(ldThing{
"@context": "https://schema.org",
"@graph": graph,
})
if err != nil {
return template.JS("{}")
}
return template.JS(raw)
}
func organizationNode(siteURL string, cfg configser.AppConfigVM) ldThing {
logo := AbsURL(siteURL, "/favicon.png")
node := ldThing{
"@type": "Organization",
"@id": siteURL + "/#organization",
"name": siteName(cfg),
"url": AbsURL(siteURL, "/"),
"logo": ldThing{
"@type": "ImageObject",
"@id": siteURL + "/#logo",
"url": logo,
"contentUrl": logo,
"width": 512,
"height": 512,
},
}
return node
}
func websiteNode(siteURL string, cfg configser.AppConfigVM) ldThing {
desc := strings.TrimSpace(cfg.SeoInfo.Description)
if desc == "" {
desc = "黄果短剧汇集热门短剧、今日更新与精选推荐,免费在线观看高清短剧。"
}
node := ldThing{
"@type": "WebSite",
"@id": siteURL + "/#website",
"name": siteName(cfg),
"url": AbsURL(siteURL, "/"),
"description": desc,
"inLanguage": "zh-CN",
"publisher": ldThing{"@id": siteURL + "/#organization"},
}
return node
}
func detailNodes(siteURL, pageURL string, d *playletser.PlayletInfoResp) []any {
detailURL := pageURL
playURL := AbsURL(siteURL, playletPath("/play", d.Slug, d.ID))
name := firstNonEmpty(d.Title, d.SeoInfo.Title, "短剧详情")
desc := firstNonEmpty(d.SeoInfo.Description, d.Desc, fmt.Sprintf("在线观看《%s》,高清短剧全集免费看。", d.Title))
cover := absMedia(siteURL, firstNonEmpty(d.Cover, d.Banner))
seriesID := detailURL + "#tvseries"
series := ldThing{
"@type": "TVSeries",
"@id": seriesID,
"name": name,
"description": desc,
"url": detailURL,
"inLanguage": "zh-CN",
"publisher": ldThing{"@id": siteURL + "/#organization"},
"potentialAction": ldThing{
"@type": "WatchAction",
"target": ldThing{
"@type": "EntryPoint",
"urlTemplate": playURL,
},
},
}
if cover != "" {
series["image"] = []string{cover}
series["thumbnailUrl"] = []string{cover}
}
if d.TotalEpisode > 0 {
series["numberOfEpisodes"] = d.TotalEpisode
}
if published := isoDate(firstNonEmpty(d.OnlineAt, d.CreatedAt)); published != "" {
series["datePublished"] = published
}
if modified := isoDate(firstNonEmpty(d.UpdatedAt, d.MediaUpdatedAt)); modified != "" {
series["dateModified"] = modified
}
if genres := uniqueStrings(append(append([]string{}, d.SeoInfo.Keywords...), append(d.TagNames, d.ThemeNames...)...)); len(genres) > 0 {
series["genre"] = genres
}
if actor := strings.TrimSpace(d.Anchor); actor != "" {
series["actor"] = ldThing{"@type": "Person", "name": actor}
}
crumb := breadcrumb(siteURL, [][2]string{
{"首页", AbsURL(siteURL, "/")},
{"短剧", AbsURL(siteURL, "/list/ai-short-play/")},
{name, detailURL},
})
page := contentWebPageNode(siteURL, detailURL, name, desc, "WebPage")
page["mainEntity"] = ldThing{"@id": seriesID}
page["breadcrumb"] = ldThing{"@id": detailURL + "#breadcrumb"}
return []any{page, crumb, series}
}
func playNodes(siteURL, pageURL string, playlet *playletser.PlayletInfoResp, chapter *playletser.PlayletChapterInfoResp) []any {
playletID := int64(0)
playletTitle := "短剧"
cover := ""
desc := "黄果短剧高清在线播放。"
published := ""
modified := ""
contentURL := ""
playURL := pageURL
detailURL := AbsURL(siteURL, "/")
seriesID := ""
totalEpisodes := 0
genres := []string(nil)
if playlet != nil {
playletID = playlet.ID
playletTitle = firstNonEmpty(playlet.Title, playlet.SeoInfo.Title, playletTitle)
cover = absMedia(siteURL, firstNonEmpty(playlet.Cover, playlet.Banner))
desc = firstNonEmpty(chapterSEODesc(chapter), playlet.SeoInfo.Description, playlet.Desc, fmt.Sprintf("正在播放《%s》", playlet.Title))
published = isoDateTime(firstNonEmpty(
playlet.OnlineAt,
playlet.MediaCreatedAt,
playlet.CreatedAt,
))
modified = isoDateTime(firstNonEmpty(playlet.MediaUpdatedAt, playlet.UpdatedAt))
detailURL = AbsURL(siteURL, playletPath("/detail", playlet.Slug, playlet.ID))
seriesID = detailURL + "#tvseries"
totalEpisodes = playlet.TotalEpisode
genres = uniqueStrings(append(append([]string{}, playlet.TagNames...), playlet.ThemeNames...))
}
episode := 0
duration := 0
if chapter != nil {
episode = chapter.CurrentEpisode
duration = chapter.MediaTime
contentURL = videoContentURL(siteURL, chapter.VideoURL)
if playletID == 0 && chapter.PlayletID > 0 {
playletID = chapter.PlayletID
detailURL = AbsURL(siteURL, playletPath("/detail", "", playletID))
}
}
videoName := playletTitle
if episode > 0 {
videoName = fmt.Sprintf("%s - 第%d集", playletTitle, episode)
}
crumbs := [][2]string{{"首页", AbsURL(siteURL, "/")}}
if playletID > 0 {
crumbs = append(crumbs, [2]string{playletTitle, detailURL})
}
crumbs = append(crumbs, [2]string{videoName, playURL})
page := contentWebPageNode(siteURL, playURL, videoName, desc, "WebPage")
page["breadcrumb"] = ldThing{"@id": playURL + "#breadcrumb"}
nodes := []any{page, breadcrumb(siteURL, crumbs)}
if playlet != nil {
series := ldThing{
"@type": "TVSeries",
"@id": seriesID,
"name": playletTitle,
"description": firstNonEmpty(playlet.SeoInfo.Description, playlet.Desc, desc),
"url": detailURL,
"inLanguage": "zh-CN",
"publisher": ldThing{"@id": siteURL + "/#organization"},
}
if cover != "" {
series["image"] = []string{cover}
series["thumbnailUrl"] = []string{cover}
}
if totalEpisodes > 0 {
series["numberOfEpisodes"] = totalEpisodes
}
if len(genres) > 0 {
series["genre"] = genres
}
if published != "" {
series["datePublished"] = published
}
if modified != "" {
series["dateModified"] = modified
}
nodes = append(nodes, series)
}
// Google 的 VideoObject 富媒体结果要求 name、thumbnailUrl、uploadDate。
// 缺少真实封面或发布日期时只输出 WebPage,避免用虚构值凑齐必填字段。
if cover == "" || published == "" {
return nodes
}
videoID := playURL + "#video"
video := ldThing{
"@type": "VideoObject",
"@id": videoID,
"name": videoName,
"description": desc,
"url": playURL,
"mainEntityOfPage": ldThing{"@id": playURL + "#webpage"},
"inLanguage": "zh-CN",
"isFamilyFriendly": false,
"contentRating": "adult",
"isPartOf": ldThing{"@id": siteURL + "/#website"},
"publisher": ldThing{"@id": siteURL + "/#organization"},
"potentialAction": ldThing{
"@type": "WatchAction",
"target": ldThing{
"@type": "EntryPoint",
"urlTemplate": playURL,
},
},
}
video["thumbnailUrl"] = []string{cover}
video["image"] = []string{cover}
video["uploadDate"] = published
if modified != "" {
video["dateModified"] = modified
}
if contentURL != "" {
video["contentUrl"] = contentURL
}
if duration > 0 {
video["duration"] = fmt.Sprintf("PT%dS", duration)
}
if chapter != nil && chapter.CountBrowse > 0 {
video["interactionStatistic"] = ldThing{
"@type": "InteractionCounter",
"interactionType": ldThing{"@type": "WatchAction"},
"userInteractionCount": chapter.CountBrowse,
}
}
if seriesID != "" {
video["partOfSeries"] = ldThing{"@id": seriesID}
} else if playletID > 0 {
video["partOfSeries"] = ldThing{"@type": "TVSeries", "name": playletTitle, "url": detailURL}
}
if episode > 0 {
video["episodeNumber"] = episode
}
if len(genres) > 0 {
video["genre"] = genres
video["keywords"] = strings.Join(genres, ",")
}
if chapter != nil {
if chapter.Width > 0 {
video["width"] = chapter.Width
}
if chapter.Height > 0 {
video["height"] = chapter.Height
}
if chapter.MediaSize > 0 {
video["contentSize"] = fmt.Sprintf("%d bytes", chapter.MediaSize)
}
}
page["mainEntity"] = ldThing{"@id": videoID}
nodes = append(nodes, video)
if episode > 0 {
ep := ldThing{
"@type": "TVEpisode",
"@id": playURL + "#episode",
"name": videoName,
"description": desc,
"episodeNumber": episode,
"url": playURL,
"associatedMedia": ldThing{"@id": videoID},
}
if seriesID != "" {
ep["partOfSeries"] = ldThing{"@id": seriesID}
} else {
ep["partOfSeries"] = ldThing{"@type": "TVSeries", "name": playletTitle, "url": detailURL}
}
if cover != "" {
ep["image"] = []string{cover}
}
if published != "" {
ep["datePublished"] = published
}
nodes = append(nodes, ep)
}
return nodes
}
func webpageNode(siteURL, pageURL string, data map[string]any, cfg configser.AppConfigVM) ldThing {
name := strFrom(data["title"])
if name == "" {
name = strFrom(data["pageName"])
}
if name == "" {
name = brandName
}
pathOnly := pageURL
if i := strings.IndexByte(pathOnly, '?'); i >= 0 {
pathOnly = pathOnly[:i]
}
if j := strings.Index(pathOnly, "://"); j >= 0 {
if k := strings.IndexByte(pathOnly[j+3:], '/'); k >= 0 {
pathOnly = pathOnly[j+3+k:]
} else {
pathOnly = "/"
}
}
pathOnly = strings.TrimSuffix(pathOnly, "/")
if pathOnly == "" {
pathOnly = "/"
}
brand := siteName(cfg)
if !strings.Contains(name, brand) && pathOnly != "/" {
name = name + " - " + brand
}
desc := pageDescription(data, cfg, pathOnly)
if desc == "" {
desc = brandName + "短剧在线观看。"
}
typ := "WebPage"
switch {
case isCollectionPath(pathOnly):
typ = "CollectionPage"
case pathOnly == "/about-us" || pathOnly == "/aboutUs":
typ = "AboutPage"
}
return contentWebPageNode(siteURL, pageURL, name, desc, typ)
}
func siteName(cfg configser.AppConfigVM) string {
return firstNonEmpty(cfg.SeoInfo.WebSiteName, brandName)
}
func contentWebPageNode(siteURL, pageURL, name, desc, typ string) ldThing {
return ldThing{
"@type": typ,
"@id": pageURL + "#webpage",
"name": name,
"description": desc,
"url": pageURL,
"inLanguage": "zh-CN",
"isPartOf": ldThing{"@id": siteURL + "/#website"},
"publisher": ldThing{"@id": siteURL + "/#organization"},
}
}
func isCollectionPath(path string) bool {
for _, prefix := range []string{"/list", "/series", "/rank", "/today-update", "/topic", "/tag", "/category"} {
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return true
}
}
return path == "/application" || path == "/nav"
}
// itemListNode 为首页及列表型页面输出当前 SSR 首屏实际展示的站内详情链接。
// Google 的 summary-page ItemList 只需要 position 与唯一的站内详情 URL。
func itemListNode(siteURL, pageURL string, data map[string]any) ldThing {
urls := make([]string, 0, 24)
appendPlaylet := func(slug string, id int64) {
path := playletser.DetailPath(slug, id)
if path != "#" {
urls = append(urls, AbsURL(siteURL, path))
}
}
if list, ok := data["list"].([]playletser.CommonData); ok {
for _, item := range list {
appendPlaylet(item.Slug, item.ID)
}
}
if categories, ok := data["homeCategoryData"].([]playletser.HomeCategoryData); ok {
for _, category := range categories {
for _, item := range category.List {
appendPlaylet(item.Slug, item.ID)
}
}
}
if banners, ok := data["homeBannerData"].([]playletser.HomeBannerData); ok {
for _, item := range banners {
if itemURL := structuredURL(siteURL, item.URL); itemURL != "" {
urls = append(urls, itemURL)
}
}
}
if themes, ok := data["list"].([]playletser.PlayletThemeItem); ok {
for _, item := range themes {
path := playletser.RecommendCanonicalPath(item.ID, item.Slug)
urls = append(urls, AbsURL(siteURL, path))
}
}
// 福利应用页是一个可见的应用集合;使用页面实际渲染的链接生成通用
// Schema.org ItemList。应用链接为外站落地页,因此不宣称具备 Google
// 站内轮播资格,只帮助搜索引擎理解页面集合语义。
if categories, ok := data["categories"].([]applistser.CategoryVM); ok {
for _, category := range categories {
for _, app := range category.Apps {
appURL := structuredURL(siteURL, app.URL)
if appURL != "" {
urls = append(urls, appURL)
}
}
}
}
seen := make(map[string]struct{}, len(urls))
unique := make([]string, 0, len(urls))
for _, itemURL := range urls {
itemURL = strings.TrimSpace(itemURL)
if itemURL == "" {
continue
}
if _, exists := seen[itemURL]; exists {
continue
}
seen[itemURL] = struct{}{}
unique = append(unique, itemURL)
}
if len(unique) < 2 {
return nil
}
start := 1
offset := intFrom(data["itemListOffset"])
if offset == 0 {
offset = intFrom(data["rankOffset"])
}
if offset > 0 {
start += offset
}
items := make([]any, 0, len(unique))
for i, itemURL := range unique {
items = append(items, ldThing{
"@type": "ListItem",
"position": start + i,
"url": itemURL,
})
}
return ldThing{
"@type": "ItemList",
"@id": pageURL + "#itemlist",
"numberOfItems": len(items),
"itemListElement": items,
}
}
func faqPageNode(siteURL, pageURL string, data map[string]any) ldThing {
faqItems, ok := data["faqItems"].([]FAQItem)
if !ok {
return nil
}
questions := make([]any, 0, len(faqItems))
for _, item := range faqItems {
question := strings.TrimSpace(item.Question)
answer := strings.TrimSpace(item.Answer)
if question == "" || answer == "" {
continue
}
questions = append(questions, ldThing{
"@type": "Question",
"name": question,
"acceptedAnswer": ldThing{
"@type": "Answer",
"text": answer,
},
})
}
if len(questions) == 0 {
return nil
}
return ldThing{
"@type": "FAQPage",
"@id": pageURL + "#faq",
"url": pageURL,
"inLanguage": "zh-CN",
"isPartOf": ldThing{"@id": pageURL + "#webpage"},
"mainEntity": questions,
"publisher": ldThing{"@id": siteURL + "/#organization"},
}
}
func breadcrumb(siteURL string, items [][2]string) ldThing {
list := make([]any, 0, len(items))
for i, item := range items {
name, loc := strings.TrimSpace(item[0]), strings.TrimSpace(item[1])
if name == "" || loc == "" {
continue
}
list = append(list, ldThing{
"@type": "ListItem",
"position": i + 1,
"name": name,
"item": loc,
})
}
pageURL := ""
if len(items) > 0 {
pageURL = strings.TrimSpace(items[len(items)-1][1])
}
return ldThing{
"@type": "BreadcrumbList",
"@id": pageURL + "#breadcrumb",
"itemListElement": list,
}
}
func genericBreadcrumb(siteURL, pageURL string, data map[string]any) ldThing {
if pageURL == "" || pageURL == AbsURL(siteURL, "/") {
return nil
}
path := pagePath(pageURL)
if !hasVisibleBreadcrumb(path) {
return nil
}
name := firstNonEmpty(strFrom(data["pageTitle"]), strFrom(data["pageName"]), strFrom(data["title"]), "内容页")
if strFrom(data["pageKey"]) == "topic" && strings.HasPrefix(path, "/topic/") {
return breadcrumb(siteURL, [][2]string{
{"首页", AbsURL(siteURL, "/")},
{"专题", AbsURL(siteURL, "/topic/")},
{name, pageURL},
})
}
return breadcrumb(siteURL, [][2]string{
{"首页", AbsURL(siteURL, "/")},
{name, pageURL},
})
}
func hasVisibleBreadcrumb(path string) bool {
for _, prefix := range []string{"/list", "/series", "/rank", "/today-update", "/topic", "/tag", "/category", "/richtext", "/application"} {
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return true
}
}
switch path {
case "/home-road", "/about-us", "/aboutUs", "/user-agreement", "/userAgreement",
"/privacy-policy", "/privacyPolicy", "/help-center", "/helpCenter":
return true
default:
return false
}
}
func structuredURL(siteURL, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "#" {
return ""
}
if strings.HasPrefix(raw, "/") {
return AbsURL(siteURL, raw)
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return ""
}
return u.String()
}
func pagePath(pageURL string) string {
u, err := url.Parse(strings.TrimSpace(pageURL))
if err == nil && u.Path != "" {
return strings.TrimSuffix(u.Path, "/")
}
return ""
}
func pageDescription(data map[string]any, cfg configser.AppConfigVM, path string) string {
if page, ok := asRichText(data["richtext"]); ok && page.Found {
return firstNonEmpty(page.Subtitle, page.Title+" - 黄果短剧官方内容。")
}
switch seo := data["seoInfo"].(type) {
case playletser.SeoInfo:
if desc := strings.TrimSpace(seo.Description); desc != "" {
return desc
}
case *playletser.SeoInfo:
if seo != nil {
if desc := strings.TrimSpace(seo.Description); desc != "" {
return desc
}
}
}
if desc := firstNonEmpty(
strFrom(data["topicDescription"]),
strFrom(data["tagDescription"]),
strFrom(data["categoryDescription"]),
); desc != "" {
return desc
}
if path == "/" {
return firstNonEmpty(strings.TrimSpace(cfg.SeoInfo.Description), "黄果短剧汇集热门短剧、今日更新与精选推荐,免费在线观看高清短剧。")
}
if path == "/nav" {
return firstNonEmpty(strings.TrimSpace(cfg.SeoNavInfo.DhDescription), "黄果短剧官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。")
}
for prefix, desc := range map[string]string{
"/list": "浏览黄果短剧全部短剧列表,按分类、标签筛选海量高清短剧,发现更多精彩剧集。",
"/series": "黄果短剧精选AI成人剧集,持续更新热门漫剧、独家原创剧集,支持免费在线观看。",
"/rank": "黄果短剧排行榜实时更新热播短剧,查看热门榜、飙升榜。",
"/today-update": "黄果短剧今日更新,第一时间追更最新上线短剧与连载剧集。",
"/topic": "黄果短剧精选短剧专题与专题作品,方便按主题发现内容。",
"/tag": "黄果短剧标签内容页,按题材标签浏览相关短剧。",
"/category": "黄果短剧分类内容页,按内容分类浏览最新上架短剧。",
"/richtext": "黄果短剧官方说明与帮助内容。",
} {
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return desc
}
}
switch path {
case "/application":
return "黄果短剧福利应用中心,精选实用应用与福利入口。"
case "/download-app":
return "下载黄果短剧APP,随时随地追看热门短剧。"
case "/home-road":
return "黄果短剧官方最新地址发布页,实时同步国内可用线路、永久入口与防失联联系方式。"
case "/about-us", "/aboutUs":
return "了解黄果短剧的品牌、产品与服务。"
case "/user-agreement", "/userAgreement":
return "黄果短剧用户协议,说明使用平台服务的权利与义务。"
case "/privacy-policy", "/privacyPolicy":
return "黄果短剧隐私政策,说明个人信息的收集、使用与保护方式。"
case "/help-center", "/helpCenter":
return "黄果短剧帮助中心,提供账号、观影、充值与常见问题解答。"
default:
return ""
}
}
func intFrom(v any) int {
switch n := v.(type) {
case int:
return n
case int32:
return int(n)
case int64:
return int(n)
case float64:
return int(n)
default:
return 0
}
}
func playletPath(route, slug string, id int64) string {
key := strings.TrimSpace(slug)
if key == "" && id > 0 {
key = strconv.FormatInt(id, 10)
}
if key == "" {
return ""
}
return strings.TrimRight(route, "/") + "/" + url.PathEscape(key) + "/"
}
func chapterSEODesc(ch *playletser.PlayletChapterInfoResp) string {
if ch == nil {
return ""
}
return strings.TrimSpace(ch.SeoInfo.Description)
}
func asPlaylet(v any) (*playletser.PlayletInfoResp, bool) {
switch t := v.(type) {
case *playletser.PlayletInfoResp:
if t != nil {
return t, true
}
case playletser.PlayletInfoResp:
return &t, true
}
return nil, false
}
func asChapter(v any) (*playletser.PlayletChapterInfoResp, bool) {
switch t := v.(type) {
case *playletser.PlayletChapterInfoResp:
if t != nil {
return t, true
}
case playletser.PlayletChapterInfoResp:
return &t, true
}
return nil, false
}
func asRichText(v any) (configser.RichTextPageVM, bool) {
switch t := v.(type) {
case configser.RichTextPageVM:
return t, true
case *configser.RichTextPageVM:
if t != nil {
return *t, true
}
}
return configser.RichTextPageVM{}, false
}
func absMedia(siteURL, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
return AbsURL(siteURL, raw)
}
// videoContentURL 仅接受可定位真实媒体字节的 HTTP(S) 或站内绝对路径。
// 诸如哈希、播放器状态值等上游字段不能误标为 Google VideoObject.contentUrl。
func videoContentURL(siteURL, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "/") {
return AbsURL(siteURL, raw)
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return ""
}
return raw
}
func isoDate(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "0001") || strings.HasPrefix(raw, "1970-01-01") {
return ""
}
layouts := []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02",
"2006/01/02 15:04:05",
"2006/01/02",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
return t.Format("2006-01-02")
}
}
if len(raw) >= 10 {
return raw[:10]
}
return ""
}
// isoDateTime 为 Google VideoObject.uploadDate 生成带时区的 ISO 8601 DateTime。
// 上游无时区的中文站点时间按 UTC+8 解释,避免让 Google 自行猜测时区。
func isoDateTime(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "0001") || strings.HasPrefix(raw, "1970-01-01") {
return ""
}
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return parsed.Format(time.RFC3339)
}
chinaTime := time.FixedZone("UTC+8", 8*60*60)
for _, layout := range []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006/01/02 15:04:05",
"2006-01-02",
"2006/01/02",
} {
if parsed, err := time.ParseInLocation(layout, raw, chinaTime); err == nil {
return parsed.Format(time.RFC3339)
}
}
return ""
}
func uniqueStrings(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
func strFrom(v any) string {
s, _ := v.(string)
return strings.TrimSpace(s)
}
+215
View File
@@ -0,0 +1,215 @@
package seoser
import (
"encoding/json"
"html/template"
"testing"
"videoweb/app/service/configser"
"videoweb/app/service/playletser"
)
func TestPageJSONLD_DetailIsValidJSON(t *testing.T) {
detail := &playletser.PlayletInfoResp{
ID: 12,
Slug: "test-show",
Title: "测试短剧",
Desc: "剧情简介",
Cover: "https://cdn.example.com/cover.jpg",
OnlineAt: "2026-01-02 15:04:05",
TotalEpisode: 30,
TagNames: []string{"都市"},
}
raw := PageJSONLD("https://www.example.com", "https://www.example.com/detail/test-show", "/detail/test-show", map[string]any{"detail": detail}, configser.AppConfigVM{})
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
t.Fatalf("json-ld invalid: %v\n%s", err, raw)
}
graph, _ := parsed["@graph"].([]any)
if len(graph) < 3 {
t.Fatalf("expected organization + website + detail nodes, got %d", len(graph))
}
}
func TestPageJSONLD_PlayHasVideoObject(t *testing.T) {
playlet := &playletser.PlayletInfoResp{
ID: 1,
Slug: "example-show",
Title: "剧",
Cover: "https://cdn.example.com/a.jpg",
MediaCreatedAt: "2026-03-01 08:30:00",
MediaUpdatedAt: "2026-03-02 09:45:00",
TotalEpisode: 12,
TagNames: []string{"都市", "甜宠"},
}
chapter := &playletser.PlayletChapterInfoResp{
ID: 9,
PlayletID: 1,
CurrentEpisode: 2,
MediaTime: 90,
MediaSize: 2048,
Width: 1080,
Height: 1920,
VideoURL: "https://cdn.example.com/episode-2.m3u8",
CountBrowse: 321,
}
raw := PageJSONLD("https://www.example.com", "https://www.example.com/play/example-show/9", "/play/example-show/9", map[string]any{
"playlet": playlet,
"chapter": chapter,
}, configser.AppConfigVM{})
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
t.Fatalf("json-ld invalid: %v", err)
}
found := false
foundSeries := false
foundEpisode := false
for _, node := range parsed["@graph"].([]any) {
m, _ := node.(map[string]any)
if m["@type"] == "TVSeries" {
foundSeries = true
if m["numberOfEpisodes"] != float64(12) || m["datePublished"] == nil {
t.Fatalf("TVSeries missing episode/date fields: %v", m)
}
}
if m["@type"] == "TVEpisode" {
foundEpisode = true
if m["episodeNumber"] != float64(2) || m["associatedMedia"] == nil {
t.Fatalf("TVEpisode missing episode/media relation: %v", m)
}
}
if m["@type"] == "VideoObject" {
found = true
if m["uploadDate"] == nil || m["thumbnailUrl"] == nil {
t.Fatalf("VideoObject missing required fields: %v", m)
}
if m["uploadDate"] != "2026-03-01T08:30:00+08:00" {
t.Fatalf("VideoObject uploadDate = %v", m["uploadDate"])
}
if m["dateModified"] != "2026-03-02T09:45:00+08:00" {
t.Fatalf("VideoObject dateModified = %v", m["dateModified"])
}
if m["contentUrl"] != "https://cdn.example.com/episode-2.m3u8" {
t.Fatalf("VideoObject contentUrl = %v", m["contentUrl"])
}
if m["duration"] != "PT90S" || m["episodeNumber"] != float64(2) {
t.Fatalf("VideoObject missing duration/episode: %v", m)
}
if m["contentRating"] != "adult" || m["isFamilyFriendly"] != false {
t.Fatalf("VideoObject missing adult flags: %v", m)
}
if m["partOfSeries"] == nil || m["genre"] == nil {
t.Fatalf("VideoObject missing series/genre relation: %v", m)
}
if _, exists := m["embedUrl"]; exists {
t.Fatalf("landing page must not be emitted as embedUrl: %v", m["embedUrl"])
}
if m["interactionStatistic"] == nil {
t.Fatalf("VideoObject missing interactionStatistic: %v", m)
}
}
}
if !found {
t.Fatal("missing VideoObject")
}
if !foundSeries || !foundEpisode {
t.Fatalf("missing TVSeries/TVEpisode: series=%v episode=%v", foundSeries, foundEpisode)
}
}
func TestPageJSONLD_PaginatedCollectionHasItemList(t *testing.T) {
raw := PageJSONLD("https://www.example.com", "https://www.example.com/list/ai-short-play/page/2", "/list/ai-short-play/page/2", map[string]any{
"title": "短剧第2页",
"rankOffset": 20,
"list": []playletser.CommonData{
{ID: 1, Slug: "first"},
{ID: 2, Slug: "second"},
},
}, configser.AppConfigVM{})
graph := parseGraph(t, raw)
page := findNodeByType(t, graph, "CollectionPage")
if page["mainEntity"] == nil {
t.Fatalf("CollectionPage missing ItemList reference: %v", page)
}
items := findNodeByType(t, graph, "ItemList")
entries, _ := items["itemListElement"].([]any)
if len(entries) != 2 {
t.Fatalf("ItemList entries = %d", len(entries))
}
first, _ := entries[0].(map[string]any)
if first["position"] != float64(21) || first["url"] != "https://www.example.com/detail/first" {
t.Fatalf("first ItemList entry = %v", first)
}
}
func TestPageJSONLD_FAQMatchesVisibleItems(t *testing.T) {
raw := PageJSONLD("https://www.example.com", "https://www.example.com/home-road", "/home-road", map[string]any{
"title": "回家的路",
"faqItems": []FAQItem{
{Question: "入口打不开怎么办?", Answer: "请切换备用入口。"},
{Question: "如何确认安全?", Answer: "仅使用官方发布的地址。"},
},
}, configser.AppConfigVM{})
graph := parseGraph(t, raw)
faq := findNodeByType(t, graph, "FAQPage")
questions, _ := faq["mainEntity"].([]any)
if len(questions) != 2 {
t.Fatalf("FAQ questions = %d", len(questions))
}
question, _ := questions[0].(map[string]any)
if question["name"] != "入口打不开怎么办?" {
t.Fatalf("FAQ question = %v", question)
}
findNodeByType(t, graph, "BreadcrumbList")
}
func TestPageJSONLD_DetailBreadcrumbMatchesVisibleHierarchy(t *testing.T) {
detail := &playletser.PlayletInfoResp{ID: 12, Slug: "test-show", Title: "测试短剧"}
raw := PageJSONLD("https://www.example.com", "https://www.example.com/detail/test-show", "/detail/test-show", map[string]any{"detail": detail}, configser.AppConfigVM{})
crumb := findNodeByType(t, parseGraph(t, raw), "BreadcrumbList")
items, _ := crumb["itemListElement"].([]any)
if len(items) != 3 {
t.Fatalf("detail breadcrumb items = %d", len(items))
}
middle, _ := items[1].(map[string]any)
if middle["name"] != "短剧" || middle["item"] != "https://www.example.com/list/ai-short-play" {
t.Fatalf("detail breadcrumb middle item = %v", middle)
}
}
func TestPageJSONLD_SkipNoindex(t *testing.T) {
raw := PageJSONLD("https://www.example.com", "", "/setting", map[string]any{"title": "账号设置"}, configser.AppConfigVM{})
if string(raw) != "" {
t.Fatalf("noindex page should not emit json-ld, got %s", raw)
}
}
func TestIsIndexablePath(t *testing.T) {
if !IsIndexablePath("/") || !IsIndexablePath("/detail/example-show") || !IsIndexablePath("/topic/editor-picks") || !IsIndexablePath("/help-center") {
t.Fatal("public pages should be indexable")
}
if IsIndexablePath("/setting") || IsIndexablePath("/user-center") || IsIndexablePath("/search") || IsIndexablePath("/richtext/custom") || IsIndexablePath("/privacy-policy") || IsIndexablePath("/user-agreement") {
t.Fatal("private pages should not be indexable")
}
}
func parseGraph(t *testing.T, raw template.JS) []any {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
t.Fatalf("json-ld invalid: %v\n%s", err, raw)
}
graph, _ := parsed["@graph"].([]any)
return graph
}
func findNodeByType(t *testing.T, graph []any, typ string) map[string]any {
t.Helper()
for _, node := range graph {
m, _ := node.(map[string]any)
if m["@type"] == typ {
return m
}
}
t.Fatalf("missing %s node in %v", typ, graph)
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package seoser
import (
"net/url"
"os"
"regexp"
"strings"
)
// BuildRobotsTXT 动态 robots.txt:优先读取 templates/page/robots.txt,并把 Sitemap 写成绝对地址。
func BuildRobotsTXT(siteURL string) string {
sitemap := AbsURL(siteURL, "/sitemap.xml")
body := defaultRobotsTXT
if raw, err := os.ReadFile("templates/page/robots.txt"); err == nil {
if t := strings.TrimSpace(string(raw)); t != "" {
body = t
}
}
body = strings.ReplaceAll(body, "\r\n", "\n")
body = sitemapLineRe.ReplaceAllString(body, "Sitemap: "+sitemap)
if !strings.Contains(body, "Sitemap:") {
body = strings.TrimRight(body, "\n") + "\n\nSitemap: " + sitemap + "\n"
} else if !strings.HasSuffix(body, "\n") {
body += "\n"
}
if host := hostFromSiteURL(siteURL); host != "" && !strings.Contains(body, "Host:") {
body = strings.TrimRight(body, "\n") + "\nHost: " + host + "\n"
}
return body
}
func hostFromSiteURL(siteURL string) string {
u, err := url.Parse(strings.TrimSpace(siteURL))
if err != nil || u.Host == "" {
return ""
}
return u.Host
}
var sitemapLineRe = regexp.MustCompile(`(?m)^Sitemap:\s*\S+`)
const defaultRobotsTXT = `User-agent: *
Allow: /
# 非 HTML 接口与工具路由不抓取
Disallow: /api/
Disallow: /ui/
Disallow: /healthz
Disallow: /h5/
Disallow: /enkey
Disallow: /newenkey
# 登录、搜索和账户 HTML 页面允许抓取,以便爬虫读取页面内 noindex。
Sitemap: /sitemap.xml
`
+29
View File
@@ -0,0 +1,29 @@
package seoser
import (
"strings"
"testing"
)
func TestBuildRobotsTXTUsesSingleCrawlerGroup(t *testing.T) {
body := BuildRobotsTXT("https://www.example.com")
if got := strings.Count(body, "User-agent:"); got != 1 {
t.Fatalf("User-agent group count = %d, want 1\n%s", got, body)
}
for _, want := range []string{
"Disallow: /api/",
"Disallow: /ui/",
"Disallow: /healthz",
"Sitemap: https://www.example.com/sitemap.xml",
"Host: www.example.com",
} {
if !strings.Contains(body, want) {
t.Fatalf("robots.txt missing %q\n%s", want, body)
}
}
for _, unwanted := range []string{"Disallow: /login", "Disallow: /search", "Disallow: /user-center"} {
if strings.Contains(body, unwanted) {
t.Fatalf("robots.txt should allow HTML page for noindex discovery: %q", unwanted)
}
}
}
+215
View File
@@ -0,0 +1,215 @@
package seoser
import (
"net/http"
"net/url"
"strconv"
"strings"
"videoweb/app/service/configser"
"github.com/gin-gonic/gin"
)
// ResolveSiteURL 解析对外站点根地址(无尾斜杠)。
// 优先有效正式域名;跳过占位域名后回退到当前请求 Host。
func ResolveSiteURL(c *gin.Context, cfg configser.AppConfigVM) string {
candidates := []string{
cfg.SeoInfo.CanonicalURL,
cfg.ForeverURLAddr,
cfg.NewURLAddr,
}
for _, raw := range candidates {
if u := usableSiteURL(raw); u != "" {
return u
}
}
if c != nil && c.Request != nil {
return requestOrigin(c.Request)
}
return ""
}
func usableSiteURL(raw string) string {
u := sanitizeBase(raw)
if u == "" {
return ""
}
host := strings.ToLower(originHost(u))
if isPlaceholderHost(host) {
return ""
}
return u
}
func originHost(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
return u.Hostname()
}
func isPlaceholderHost(host string) bool {
host = strings.TrimSpace(strings.ToLower(host))
switch host {
case "", "localhost", "127.0.0.1", "0.0.0.0", "www.xx.xxx", "xx.xxx", "example.com", "www.example.com":
return true
}
return strings.HasSuffix(host, ".example.com") || strings.HasSuffix(host, ".local")
}
// AbsURL 将 path 或完整 URL 转为绝对地址。
func AbsURL(siteURL, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
if siteURL == "" {
return "/"
}
return siteURL + "/"
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
return raw
}
if siteURL == "" {
if strings.HasPrefix(raw, "/") {
return raw
}
return "/" + raw
}
if strings.HasPrefix(raw, "/") {
return siteURL + raw
}
return siteURL + "/" + raw
}
// PageCanonicalURL 首页用 siteURL 根路径;其他页用 siteURL + 当前 path + query。
// siteURL 为空时回退 foreverURLAddr。
func PageCanonicalURL(siteURL, foreverURLAddr string, c *gin.Context) string {
base := strings.TrimRight(strings.TrimSpace(siteURL), "/")
if base == "" {
base = strings.TrimRight(strings.TrimSpace(usableSiteURL(foreverURLAddr)), "/")
}
if c == nil || c.Request == nil || c.Request.URL == nil {
return AbsURL(base, "/")
}
path := c.Request.URL.Path
if path == "" || path == "/" {
return AbsURL(base, "/")
}
return ensureTrailingSlash(AbsURL(base, relativeRequestURI(c.Request.URL)))
}
// CanonicalWithoutQuery 移除 canonical 中的查询参数与片段,避免参数变体形成重复 URL。
func CanonicalWithoutQuery(raw string) string {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil {
return raw
}
u.RawQuery = ""
u.ForceQuery = false
u.Fragment = ""
return u.String()
}
// ContentCanonicalURL 由实际内容生成 slug 路由。
func ContentCanonicalURL(siteURL, fallback string, data map[string]any) string {
if detail, ok := asPlaylet(data["detail"]); ok {
if path := playletPath("/detail", detail.Slug, detail.ID); path != "" {
return AbsURL(siteURL, path)
}
} else if chapter, ok := asChapter(data["chapter"]); ok {
playlet, _ := asPlaylet(data["playlet"])
playletID := chapter.PlayletID
playletSlug := ""
if playlet != nil {
playletID = playlet.ID
playletSlug = playlet.Slug
}
path := playletPath("/play", playletSlug, playletID)
if path == "" {
return fallback
}
if chapter.ID > 0 {
path += strconv.FormatInt(chapter.ID, 10) + "/"
}
return AbsURL(siteURL, path)
}
return ensureTrailingSlash(fallback)
}
// ensureTrailingSlash 将 HTML 页面 URL 统一为目录式尾斜杠形式,并保留查询参数。
func ensureTrailingSlash(raw string) string {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil {
return raw
}
if u.Path == "" {
u.Path = "/"
}
if u.Path != "/" && !strings.HasSuffix(u.Path, "/") {
u.Path += "/"
}
return u.String()
}
func relativeRequestURI(u *url.URL) string {
if u == nil {
return "/"
}
path := u.EscapedPath()
if path == "" {
path = "/"
}
if u.RawQuery != "" {
return path + "?" + u.RawQuery
}
return path
}
func sanitizeBase(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
if !strings.Contains(raw, "://") && strings.Contains(raw, ".") {
return sanitizeBase("https://" + strings.TrimPrefix(raw, "/"))
}
return ""
}
if u.Scheme != "http" && u.Scheme != "https" {
return ""
}
return strings.TrimRight(u.Scheme+"://"+u.Host, "/")
}
func requestOrigin(r *http.Request) string {
if r == nil {
return ""
}
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if proto == "" {
if r.TLS != nil {
proto = "https"
} else {
proto = "http"
}
}
if i := strings.IndexByte(proto, ','); i >= 0 {
proto = strings.TrimSpace(proto[:i])
}
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
if host == "" {
host = strings.TrimSpace(r.Host)
}
if i := strings.IndexByte(host, ','); i >= 0 {
host = strings.TrimSpace(host[:i])
}
if host == "" {
return ""
}
return strings.TrimRight(proto+"://"+host, "/")
}
+68
View File
@@ -0,0 +1,68 @@
package seoser
import (
"net/http/httptest"
"testing"
"videoweb/app/service/configser"
"github.com/gin-gonic/gin"
)
func TestResolveSiteURLUsesForeverURLAddr(t *testing.T) {
cfg := configser.AppConfigVM{
ForeverURLAddr: "https://forever.prod.com/path",
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "https://temp.example.com/", nil)
got := ResolveSiteURL(c, cfg)
want := "https://forever.prod.com"
if got != want {
t.Fatalf("ResolveSiteURL() = %q, want %q", got, want)
}
}
func TestResolveSiteURLFallbackRequestHost(t *testing.T) {
cfg := configser.AppConfigVM{}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "https://temp.example.com/", nil)
got := ResolveSiteURL(c, cfg)
want := "https://temp.example.com"
if got != want {
t.Fatalf("ResolveSiteURL() = %q, want %q", got, want)
}
}
func TestPageCanonicalURLHomepageUsesSiteURL(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "https://mirror.example.com/", nil)
got := PageCanonicalURL("https://www.prod.com", "", c)
want := "https://www.prod.com/"
if got != want {
t.Fatalf("PageCanonicalURL() = %q, want %q", got, want)
}
}
func TestPageCanonicalURLSubpageUsesSiteURLAndPath(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "https://mirror.example.com/list?theme=1&page=2", nil)
got := PageCanonicalURL("https://www.prod.com", "", c)
want := "https://www.prod.com/list?theme=1&page=2"
if got != want {
t.Fatalf("PageCanonicalURL() = %q, want %q", got, want)
}
}
func TestPageCanonicalURLFallbackForeverURLAddr(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/detail/example-show", nil)
got := PageCanonicalURL("", "https://forever.prod.com/app", c)
want := "https://forever.prod.com/detail/example-show"
if got != want {
t.Fatalf("PageCanonicalURL() = %q, want %q", got, want)
}
}
+51
View File
@@ -0,0 +1,51 @@
package seoser
import (
"path"
"strings"
"videoweb/app/appg"
)
// VerifyCodes 站长平台所有权验证(meta + 根路径文件)。
type VerifyCodes struct {
Baidu string
BaiduHTML string
IndexNowKey string
}
// VerifyFromConfig 读取 yaml 中的验证码与验证文件名。
func VerifyFromConfig() VerifyCodes {
seo := appg.Conf.SEO
return VerifyCodes{
Baidu: strings.TrimSpace(seo.BaiduSiteVerification),
BaiduHTML: sanitizeVerifyFile(seo.BaiduHTMLFile),
IndexNowKey: sanitizeIndexNowKey(seo.IndexNowKey),
}
}
func sanitizeVerifyFile(raw string) string {
raw = strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/"))
raw = path.Base(raw)
if raw == "" || raw == "." || raw == "/" || strings.Contains(raw, "..") {
return ""
}
lower := strings.ToLower(raw)
if strings.HasSuffix(lower, ".html") || strings.HasSuffix(lower, ".htm") || strings.HasSuffix(lower, ".xml") {
return raw
}
return ""
}
func sanitizeIndexNowKey(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
for _, r := range raw {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
continue
}
return ""
}
return raw
}
+56
View File
@@ -0,0 +1,56 @@
package translationser
import (
"fmt"
"sync"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
categoryListPath = "/api/app/translation/category/list"
categoryListCacheTTL = 60 * time.Second
)
var (
navMu sync.Mutex
navCache []HeaderNavItemVM
navAt time.Time
)
// FetchCategoryList 拉取导航分类列表。
func FetchCategoryList(c *gin.Context) (*AppTranslationInfo, error) {
var env APIEnvelope[AppTranslationInfo]
if err := upstream.GetPublicJSON(c, categoryListPath, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("translation category list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchHeaderNavOrDefault 带短缓存;失败或空列表时返回默认导航。
func FetchHeaderNavOrDefault(c *gin.Context) []HeaderNavItemVM {
navMu.Lock()
defer navMu.Unlock()
if navCache != nil && time.Since(navAt) < categoryListCacheTTL {
return append([]HeaderNavItemVM(nil), navCache...)
}
info, err := FetchCategoryList(c)
if err != nil {
log.Error("fetch translation category list failed", log.E(err))
return DefaultHeaderNav()
}
vm := BuildHeaderNav(info)
if len(vm) == 0 {
return DefaultHeaderNav()
}
navCache = vm
navAt = time.Now()
return append([]HeaderNavItemVM(nil), vm...)
}
+46
View File
@@ -0,0 +1,46 @@
package translationser
// AppTranslationInfo 对应 swagger proto.AppTranslationInfo
// GET /api/app/translation/category/list
type AppTranslationInfo struct {
CategoryList []TranslationCategory `json:"categoryList"`
}
// TranslationCategory 对应 swagger proto.TranslationCategory
type TranslationCategory struct {
ID int64 `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
URLValue string `json:"urlValue"`
CategoryType int `json:"categoryType"` // 1 URL 2 二级 3 底部
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"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data T `json:"data"`
}
const (
TranslationTypeURL = 1 // URL 模式
TranslationTypeSub = 2 // 二级导航
TranslationTypeBottom = 3 // 底部导航
)
+288
View File
@@ -0,0 +1,288 @@
package translationser
import (
"net/url"
"strings"
)
// HeaderNavItemVM 顶部 / 抽屉导航项。
type HeaderNavItemVM struct {
ID int64
Name string
Href string
Icon string
Key string // 与页面 navActive 对齐
IsActive bool
SeoInfo SeoValue
IsDownload bool
IsExternal bool
Children []HeaderNavChildVM
}
// ResolveNavigationActiveKey 将当前路由归一到 PC 顶部导航和移动侧栏共用的栏目键。
func ResolveNavigationActiveKey(activeKey, requestPath string) string {
path := normalizeNavPath(requestPath)
switch {
case path == "/":
return "home"
case path == "/list/ai-short-play" || strings.HasPrefix(path, "/list/ai-short-play/"):
return "aiShortPlay"
case path == "/series" || strings.HasPrefix(path, "/series/") || path == "/list/ai-series" || strings.HasPrefix(path, "/list/ai-series/"):
return "aiSeries"
case path == "/tag" || strings.HasPrefix(path, "/tag/") || path == "/tag-list" || strings.HasPrefix(path, "/tag-list/"):
return "tagList"
case path == "/topic" || strings.HasPrefix(path, "/topic/"):
return "topic"
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
return "rank"
case path == "/today-update" || strings.HasPrefix(path, "/today-update/"):
return "todayUpdate"
case strings.HasPrefix(path, "/category/"):
return "category"
case path == "/application" || strings.HasPrefix(path, "/application/"):
return "application"
case path == "/home-road" || strings.HasPrefix(path, "/home-road/"):
return "homeRoad"
case path == "/list" || strings.HasPrefix(path, "/detail/") || strings.HasPrefix(path, "/play/") || path == "/more" || strings.HasPrefix(path, "/more/") || path == "/search" || strings.HasPrefix(path, "/search/"):
return "list"
}
if key := strings.TrimSpace(activeKey); key != "" {
return key
}
return path
}
// MarkHeaderNavActive 返回带当前页选中态的导航副本。
// PC 顶部导航仅在当前路径与导航路径完全一致时高亮,避免搜索、详情和分页页误亮栏目。
func MarkHeaderNavActive(items []HeaderNavItemVM, requestPath string) []HeaderNavItemVM {
currentPath := normalizeNavPath(requestPath)
out := append([]HeaderNavItemVM(nil), items...)
for i := range out {
out[i].IsActive = headerNavItemMatchesPath(out[i], currentPath)
}
return out
}
func headerNavItemMatchesPath(item HeaderNavItemVM, currentPath string) bool {
if item.IsExternal || item.IsDownload {
return false
}
href := strings.TrimSpace(item.Href)
if href == "" || href == "#" || strings.HasPrefix(strings.ToLower(href), "javascript:") {
return false
}
return normalizeNavPath(href) == currentPath
}
// HeaderNavChildVM 二级导航。
type HeaderNavChildVM struct {
Name string
Href string
IsExternal bool
IsFriendAds bool
}
// DefaultHeaderNav 接口失败时的兜底导航。
func DefaultHeaderNav() []HeaderNavItemVM {
return []HeaderNavItemVM{
{Name: "首页", Href: "/", Key: "home"},
{Name: "短剧", Href: "/list/", Key: "list"},
{Name: "排行榜", Href: "/rank/", Key: "rank"},
{Name: "回家的路", Href: "/home-road/", Key: "homeRoad"},
{Name: "今日更新", Href: "/today-update/", Key: "todayUpdate"},
{Name: "下载APP", Href: "javascript:;", Key: "downloadApp", IsDownload: true},
{Name: "福利应用", Href: "/application/", Key: "application"},
}
}
// BuildHeaderNav 将上游分类转为模板可用导航(顶部 URL / 二级;底部类型留给抽屉扩展)。
func BuildHeaderNav(info *AppTranslationInfo) []HeaderNavItemVM {
if info == nil || len(info.CategoryList) == 0 {
return nil
}
out := make([]HeaderNavItemVM, 0, len(info.CategoryList))
for _, cat := range info.CategoryList {
name := strings.TrimSpace(cat.Name)
if name == "" {
continue
}
// 底部导航不进顶部栏(仍可由抽屉单独渲染时可再扩展)
if cat.CategoryType == TranslationTypeBottom {
continue
}
item := HeaderNavItemVM{
ID: cat.ID,
Name: name,
Icon: strings.TrimSpace(cat.Icon),
SeoInfo: normalizeNavigationSEO(cat.SeoInfo),
}
href, external, download := normalizeNavURL(cat.URLValue, name)
item.Href = href
item.IsExternal = external
item.IsDownload = download
item.Key = navActiveKey(href, name, download)
if cat.CategoryType == TranslationTypeSub || len(cat.TranslationList) > 0 {
for _, child := range cat.TranslationList {
cname := strings.TrimSpace(child.Name)
if cname == "" {
continue
}
ch, cex, _ := normalizeNavURL(child.URLValue, cname)
if ch == "" {
continue
}
item.Children = append(item.Children, HeaderNavChildVM{
Name: cname,
Href: ch,
IsExternal: cex,
IsFriendAds: child.IsFriendAds,
})
}
}
if item.Href == "" && len(item.Children) == 0 && !item.IsDownload {
continue
}
if item.Href == "" && !item.IsDownload {
item.Href = "javascript:;"
}
out = append(out, item)
}
return out
}
// FindHeaderNavSEO 优先匹配当前请求的最长导航路径,navActive 用于无实际 URL 的导航兜底。
func FindHeaderNavSEO(items []HeaderNavItemVM, activeKey, requestPath string) (SeoValue, bool) {
requestPath = normalizeNavPath(requestPath)
bestLen := -1
var best SeoValue
for _, item := range items {
if item.IsExternal || item.IsDownload || !hasNavigationSEO(item.SeoInfo) {
continue
}
target := normalizeNavPath(item.Href)
matched := requestPath == target
if target != "/" && strings.HasPrefix(requestPath, target+"/") {
matched = true
}
if matched && len(target) > bestLen {
bestLen = len(target)
best = item.SeoInfo
}
}
if bestLen >= 0 {
return best, true
}
activeKey = strings.TrimSpace(activeKey)
if activeKey != "" {
for _, item := range items {
if item.Key == activeKey && hasNavigationSEO(item.SeoInfo) {
return item.SeoInfo, true
}
}
}
return SeoValue{}, false
}
func normalizeNavigationSEO(seo SeoValue) SeoValue {
seo.Title = strings.TrimSpace(seo.Title)
seo.Description = strings.TrimSpace(seo.Description)
keywords := make([]string, 0, len(seo.Keyworld))
seen := make(map[string]struct{}, len(seo.Keyworld))
for _, keyword := range seo.Keyworld {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
continue
}
if _, ok := seen[keyword]; ok {
continue
}
seen[keyword] = struct{}{}
keywords = append(keywords, keyword)
}
seo.Keyworld = keywords
return seo
}
func hasNavigationSEO(seo SeoValue) bool {
return seo.Title != "" || seo.Description != "" || len(seo.Keyworld) > 0
}
func normalizeNavPath(raw string) string {
raw = strings.TrimSpace(raw)
if parsed, err := url.Parse(raw); err == nil && parsed.Path != "" {
raw = parsed.Path
}
if raw == "" {
return "/"
}
if !strings.HasPrefix(raw, "/") {
raw = "/" + raw
}
raw = strings.TrimRight(raw, "/")
if raw == "" {
return "/"
}
return raw
}
func normalizeNavURL(raw, _ string) (href string, external, download bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false, false
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(raw, "//") {
return raw, true, false
}
if raw == "#" || strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "tel:") {
return raw, false, false
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Path == "" {
return raw, false, false
}
if parsed.Path != "/" && !strings.HasSuffix(parsed.Path, "/") {
parsed.Path += "/"
}
return parsed.String(), false, false
}
func navActiveKey(href, name string, download bool) string {
if download {
return "downloadApp"
}
path := href
if i := strings.Index(path, "?"); i >= 0 {
path = path[:i]
}
path = strings.TrimRight(path, "/")
if path == "" {
path = "/"
}
switch path {
case "/":
return "home"
case "/list":
return "list"
case "/rank":
return "rank"
case "/today-update":
return "todayUpdate"
case "/application":
return "application"
case "/tag":
return "tagList"
case "/topic":
return "topic"
case "/series":
return "aiSeries"
case "/home-road":
return "homeRoad"
case "/download-app":
return "downloadApp"
default:
return path
}
}
+83
View File
@@ -0,0 +1,83 @@
package translationser
import "testing"
func TestResolveNavigationActiveKey(t *testing.T) {
tests := map[string]string{
"/": "home",
"/list/ai-short-play": "aiShortPlay",
"/list/ai-series/2/page/3": "aiSeries",
"/series/original/page/3": "aiSeries",
"/tag/urban/page/2": "tagList",
"/tag-list/urban/page/2": "tagList",
"/topic/editor-picks/page/2": "topic",
"/rank/2/page/3": "rank",
"/category/latest-video/": "category",
"/application": "application",
"/home-road": "homeRoad",
"/detail/example-playlet": "list",
"/play/example-playlet/chapter-1": "list",
}
for path, want := range tests {
if got := ResolveNavigationActiveKey("", path); got != want {
t.Fatalf("ResolveNavigationActiveKey(%q) = %q, want %q", path, got, want)
}
}
}
func TestMarkHeaderNavActiveRequiresExactPath(t *testing.T) {
items := []HeaderNavItemVM{
{Name: "首页", Href: "/", Key: "home"},
{Name: "AI成人短剧", Href: "/category/today", Key: "list"},
{Name: "专题", Href: "/topic", Key: "topic"},
{Name: "排行榜", Href: "/rank", Key: "rank"},
}
marked := MarkHeaderNavActive(items, "/category/today/")
for i, item := range marked {
want := i == 1
if item.IsActive != want {
t.Fatalf("item %q active = %v, want %v", item.Name, item.IsActive, want)
}
}
marked = MarkHeaderNavActive(items, "/search/keyword")
for _, item := range marked {
if item.IsActive {
t.Fatalf("non-navigation path unexpectedly activated %q", item.Name)
}
}
for _, item := range items {
if item.IsActive {
t.Fatalf("source navigation cache was mutated: %#v", items)
}
}
}
func TestNormalizeNavURLKeepsConfiguredURL(t *testing.T) {
href, external, download := normalizeNavURL("/tag-list/fantasy/page/2", "专题")
if href != "/tag-list/fantasy/page/2" || external || download {
t.Fatalf("normalizeNavURL configured tag path = (%q, %v, %v)", href, external, download)
}
href, external, download = normalizeNavURL("/", "专题")
if href != "/" || external || download {
t.Fatalf("normalizeNavURL configured root = (%q, %v, %v)", href, external, download)
}
href, external, download = normalizeNavURL("/tag", "专题")
if href != "/tag" || external || download {
t.Fatalf("normalizeNavURL configured topic nav = (%q, %v, %v)", href, external, download)
}
href, external, download = normalizeNavURL("series", "AI成人剧集")
if href != "series" || external || download {
t.Fatalf("normalizeNavURL configured relative path = (%q, %v, %v)", href, external, download)
}
href, external, download = normalizeNavURL("https://old.example.com/home.pdf", "回家的路")
if href != "https://old.example.com/home.pdf" || !external || download {
t.Fatalf("normalizeNavURL configured external URL = (%q, %v, %v)", href, external, download)
}
href, external, download = normalizeNavURL("/download-app", "下载APP")
if href != "/download-app" || external || download {
t.Fatalf("normalizeNavURL configured download URL = (%q, %v, %v)", href, external, download)
}
}
+181
View File
@@ -0,0 +1,181 @@
package upstream
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
"videoweb/app/appg"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const maxBody = 4 << 20 // 4MB
var client = &http.Client{Timeout: 20 * time.Second}
// BaseURL 上游 API 根地址,来自 conf/app.yaml 的 serverAddr。
func BaseURL() string {
return strings.TrimRight(strings.TrimSpace(appg.Conf.ServerAddr), "/")
}
// GetJSON 带上 X-Real-IP / User-Agent / X-User-Agent,并从 cookie 透传 Authorization。
func GetJSON(c *gin.Context, path string, out any) error {
_, err := doJSON(c, http.MethodGet, path, nil, out)
return err
}
// GetPublicJSON 请求无需登录的公共数据,不透传客户端 Authorization。
// 公共配置接口会在收到过期 token 时返回会话失效,因此必须显式与登录态隔离。
func GetPublicJSON(c *gin.Context, path string, out any) error {
_, err := doJSONWithAuth(c, http.MethodGet, path, nil, out, false)
return err
}
// PostJSON 向上游 POST JSON,解码到 out;返回 HTTP 状态码。
// 注意:上游业务错误常见为 HTTP 200 + code!=200,此时仍会成功解码。
func PostJSON(c *gin.Context, path string, body any, out any) (int, error) {
return doJSON(c, http.MethodPost, path, body, out)
}
// MultipartFile 上游 multipart 文件字段。
type MultipartFile struct {
FieldName string
FileName string
Reader io.Reader
}
// PostMultipart 向上游 POST multipart/form-data。
func PostMultipart(c *gin.Context, path string, fields map[string]string, file *MultipartFile, out any) (int, error) {
base := BaseURL()
if base == "" {
return 0, fmt.Errorf("upstream serverAddr is empty")
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
return 0, err
}
}
if file != nil && file.Reader != nil {
part, err := w.CreateFormFile(file.FieldName, file.FileName)
if err != nil {
return 0, err
}
if _, err := io.Copy(part, io.LimitReader(file.Reader, maxBody)); err != nil {
return 0, err
}
}
if err := w.Close(); err != nil {
return 0, err
}
req, err := http.NewRequest(http.MethodPost, base+path, &buf)
if err != nil {
return 0, err
}
applyCommonHeaders(c, req)
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return resp.StatusCode, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp.StatusCode, fmt.Errorf("upstream %s status %d: %s", path, resp.StatusCode, truncate(string(raw), 200))
}
if out != nil {
if err := json.Unmarshal(raw, out); err != nil {
return resp.StatusCode, fmt.Errorf("decode %s: %w", path, err)
}
}
return resp.StatusCode, nil
}
func doJSON(c *gin.Context, method, path string, body any, out any) (int, error) {
return doJSONWithAuth(c, method, path, body, out, true)
}
func doJSONWithAuth(c *gin.Context, method, path string, body any, out any, forwardAuth bool) (int, error) {
base := BaseURL()
if base == "" {
return 0, fmt.Errorf("upstream serverAddr is empty")
}
fullURL := base + path
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, err
}
bodyReader = strings.NewReader(string(b))
}
req, err := http.NewRequest(method, fullURL, bodyReader)
if err != nil {
return 0, err
}
applyCommonHeaders(c, req)
if !forwardAuth {
req.Header.Del("Authorization")
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return resp.StatusCode, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp.StatusCode, fmt.Errorf("upstream %s status %d: %s", path, resp.StatusCode, truncate(string(raw), 200))
}
if out != nil {
if err := json.Unmarshal(raw, out); err != nil {
return resp.StatusCode, fmt.Errorf("decode %s: %w", path, err)
}
}
return resp.StatusCode, nil
}
func applyCommonHeaders(c *gin.Context, req *http.Request) {
headers := useragent.BuildUpstreamHeader(c, useragent.DefaultVer)
for k, v := range headers {
req.Header.Set(k, v)
}
req.Header.Set("Accept", "application/json")
if token := useragent.TokenFromRequest(c.Request); token != "" {
req.Header.Set("Authorization", token)
} else if c != nil && c.Request != nil {
if auth := strings.TrimSpace(c.GetHeader("Authorization")); auth != "" {
req.Header.Set("Authorization", auth)
}
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+65
View File
@@ -0,0 +1,65 @@
package upstream
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"videoweb/app/appg"
"github.com/gin-gonic/gin"
)
func TestGetPublicJSONDoesNotForwardAuthorization(t *testing.T) {
var receivedAuthorization string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuthorization = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200})
}))
defer server.Close()
previousServerAddr := appg.Conf.ServerAddr
appg.Conf.ServerAddr = server.URL
t.Cleanup(func() { appg.Conf.ServerAddr = previousServerAddr })
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.AddCookie(&http.Cookie{Name: "token", Value: "expired-token"})
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = request
var response map[string]any
if err := GetPublicJSON(context, "/public-config", &response); err != nil {
t.Fatalf("GetPublicJSON() error = %v", err)
}
if receivedAuthorization != "" {
t.Fatalf("Authorization = %q, want empty", receivedAuthorization)
}
}
func TestGetJSONStillForwardsAuthorization(t *testing.T) {
var receivedAuthorization string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuthorization = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200})
}))
defer server.Close()
previousServerAddr := appg.Conf.ServerAddr
appg.Conf.ServerAddr = server.URL
t.Cleanup(func() { appg.Conf.ServerAddr = previousServerAddr })
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.AddCookie(&http.Cookie{Name: "token", Value: "valid-token"})
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = request
var response map[string]any
if err := GetJSON(context, "/private-data", &response); err != nil {
t.Fatalf("GetJSON() error = %v", err)
}
if receivedAuthorization != "valid-token" {
t.Fatalf("Authorization = %q, want %q", receivedAuthorization, "valid-token")
}
}