@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
registerPath = "/api/app/auth/register"
|
||||
loginPath = "/api/app/auth/login"
|
||||
resetPath = "/api/app/auth/resetPassword"
|
||||
guestPath = "/api/app/auth/guest"
|
||||
getInfoPath = "/api/app/auth/getInfo"
|
||||
profilePath = "/api/app/auth/profile"
|
||||
uploadPath = "/api/app/upload/image"
|
||||
cookieMaxAge = 7 * 24 * 3600
|
||||
)
|
||||
|
||||
// Register 调用上游注册接口。
|
||||
func Register(c *gin.Context, req RegisterReq) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, registerPath, req, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// ResetPassword 通过账号和邮箱校验后重置密码。
|
||||
func ResetPassword(c *gin.Context, req ResetPasswordReq) (*APIEnvelope, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, resetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// Login 调用上游登录接口。
|
||||
func Login(c *gin.Context, req LoginReq) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, loginPath, req, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// GuestLogin 调用上游游客登录(按设备 ID 创建或找回游客账号)。
|
||||
func GuestLogin(c *gin.Context, req GuestLoginReq) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, guestPath, req, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// GetInfo 通过 token 拉取最新用户信息。
|
||||
func GetInfo(c *gin.Context) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if err := upstream.GetJSON(c, getInfoPath, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// UpdateProfile 修改资料(昵称 / 头像等)。
|
||||
func UpdateProfile(c *gin.Context, req UpdateProfileReq) (*APIEnvelope, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, profilePath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// UploadImage 上传图片;头像场景不加水印。
|
||||
func UploadImage(c *gin.Context, fileName string, r io.Reader) (*ImageUploadResp, error) {
|
||||
var resp ImageUploadResp
|
||||
_, err := upstream.PostMultipart(c, uploadPath, map[string]string{
|
||||
"watermark": "0",
|
||||
}, &upstream.MultipartFile{
|
||||
FieldName: "file",
|
||||
FileName: fileName,
|
||||
Reader: r,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func decodeLoginData(raw json.RawMessage) (*LoginResp, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" || string(raw) == `""` {
|
||||
return nil, fmt.Errorf("empty login data")
|
||||
}
|
||||
var user LoginResp
|
||||
if err := json.Unmarshal(raw, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// SetAuthCookies 只持久化 SSR 与前端登录态实际依赖的字段。
|
||||
// 个人简介、邮箱、邀请码、展示卡信息由 getInfo 即时提供,不再随每个页面响应重复写入;
|
||||
// sid 已存在于请求时也无需原样回写。ClearAuthCookies 仍保留这些历史键用于兼容清理。
|
||||
func SetAuthCookies(c *gin.Context, user *LoginResp) {
|
||||
if c == nil || user == nil {
|
||||
return
|
||||
}
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
if strings.TrimSpace(user.Token) != "" {
|
||||
setCookie(c, "token", user.Token, secure)
|
||||
}
|
||||
setCookie(c, "userId", strconv.FormatInt(user.UserID, 10), secure)
|
||||
setCookie(c, "accountNo", user.AccountNo, secure)
|
||||
setCookie(c, "nickName", user.NickName, secure)
|
||||
setCookie(c, "avatar", user.Avatar, secure)
|
||||
setCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10), secure)
|
||||
setCookie(c, "vipType", strconv.Itoa(user.VipType), secure)
|
||||
setCookie(c, "currentVipExpire", user.CurrentVipExpire, secure)
|
||||
setCookie(c, "userType", strconv.Itoa(user.Type), secure)
|
||||
isReg := "0"
|
||||
if user.IsRegister || user.Type == 1 {
|
||||
isReg = "1"
|
||||
}
|
||||
setCookie(c, "isRegister", isReg, secure)
|
||||
// 后续 SSR 服务必须能立即读取本次刚建立/刷新的会话;同时记录为本请求的
|
||||
// 权威结果,避免 HTML 渲染阶段再次调用 getInfo 并重复写整套 Cookie。
|
||||
patchAuthCookiesToRequest(c, user)
|
||||
rememberRequestAuthUser(c, user)
|
||||
}
|
||||
|
||||
func setCookie(c *gin.Context, name, value string, secure bool) {
|
||||
// Cookie Value 仅允许 ASCII;中文昵称等需 URL 编码。
|
||||
// 使用 PathEscape(空格→%20),与前端 encodeURIComponent 一致;
|
||||
// 避免 QueryEscape 的空格→+,否则 JS decodeURIComponent 会留下字面量 "+"。
|
||||
writeCookie(c, &http.Cookie{
|
||||
Name: name,
|
||||
Value: url.PathEscape(value),
|
||||
Path: "/",
|
||||
MaxAge: cookieMaxAge,
|
||||
HttpOnly: false,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(7 * 24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
// writeCookie 对同一响应中同名、同 Path、同 Domain 的 Cookie 做 upsert,避免会话恢复的
|
||||
// 「先清除、再重建」产生两套响应头,同时保留其他作用域下可能存在的同名 Cookie。
|
||||
func writeCookie(c *gin.Context, cookie *http.Cookie) {
|
||||
if c == nil || c.Writer == nil || cookie == nil || cookie.Name == "" {
|
||||
return
|
||||
}
|
||||
header := c.Writer.Header()
|
||||
existing := header.Values("Set-Cookie")
|
||||
if len(existing) > 0 {
|
||||
header.Del("Set-Cookie")
|
||||
for _, raw := range existing {
|
||||
if sameCookieScope(raw, cookie) {
|
||||
continue
|
||||
}
|
||||
header.Add("Set-Cookie", raw)
|
||||
}
|
||||
}
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
}
|
||||
|
||||
func sameCookieScope(raw string, target *http.Cookie) bool {
|
||||
response := &http.Response{Header: http.Header{"Set-Cookie": []string{raw}}}
|
||||
for _, existing := range response.Cookies() {
|
||||
if existing.Name == target.Name && existing.Path == target.Path && strings.EqualFold(existing.Domain, target.Domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cookieValue(c *gin.Context, name string) string {
|
||||
ck, err := c.Request.Cookie(name)
|
||||
if err != nil || ck == nil {
|
||||
return ""
|
||||
}
|
||||
raw := strings.TrimSpace(ck.Value)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// QueryUnescape 同时兼容历史 QueryEscape(+)与 PathEscape(%20)
|
||||
if decoded, err := url.QueryUnescape(raw); err == nil {
|
||||
return strings.TrimSpace(decoded)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ClearAuthCookies 清除登录相关 cookie。
|
||||
func ClearAuthCookies(c *gin.Context) {
|
||||
forgetRequestAuthUser(c)
|
||||
for _, name := range authCookieNames() {
|
||||
writeCookie(c, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func authCookieNames() []string {
|
||||
return []string{
|
||||
"token", "_token_", "sid", "_s_token_",
|
||||
"userId", "accountNo", "nickName", "introduction", "avatar",
|
||||
"email", "inviteCode", "recharge", "vipType", "currentVipExpire", "showCardId", "showCardName",
|
||||
"userType", "isRegister",
|
||||
}
|
||||
}
|
||||
|
||||
// AuthFromCookies 从 cookie 读取登录态,供 SSR 头部 / 个人中心展示。
|
||||
func AuthFromCookies(c *gin.Context) (loggedIn bool, userName, avatar, email string) {
|
||||
token := useragent.TokenFromRequest(c.Request)
|
||||
if token == "" {
|
||||
return false, "", "", ""
|
||||
}
|
||||
userName = cookieValue(c, "nickName")
|
||||
if userName == "" {
|
||||
userName = cookieValue(c, "accountNo")
|
||||
}
|
||||
avatar = usableAvatarURL(cookieValue(c, "avatar"))
|
||||
email = cookieValue(c, "email")
|
||||
return true, userName, avatar, email
|
||||
}
|
||||
|
||||
// IsRegisteredFromCookies 是否正式注册账号(非游客)。
|
||||
func IsRegisteredFromCookies(c *gin.Context) bool {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
return false
|
||||
}
|
||||
if cookieValue(c, "isRegister") == "1" {
|
||||
return true
|
||||
}
|
||||
return cookieValue(c, "userType") == "1"
|
||||
}
|
||||
|
||||
func usableAvatarURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "/static/") {
|
||||
return raw
|
||||
}
|
||||
// 无域名的相对路径暂不可用(解密拉取需要完整 URL)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const guestDevIDCookie = "guest_dev_id"
|
||||
const guestSkipAutoCookie = "guest_skip_auto"
|
||||
|
||||
// EnsureGuestOnEnter 页面进入时:无 token(未自己登录)则走游客登录并写 cookie。
|
||||
// 对应上游 POST /api/app/auth/guest。成功后会把会话补进当前 Request,便于同一次 SSR 读到登录态。
|
||||
// 手动退出后带 guest_skip_auto 时跳过本次;清掉标记后,下次进页再自动游客。
|
||||
func EnsureGuestOnEnter(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
if user := freshRequestAuthUser(c); user != nil {
|
||||
return user
|
||||
}
|
||||
if requestAutoAuthSuppressed(c) {
|
||||
return nil
|
||||
}
|
||||
// 已有任意会话(游客或正式账号)则跳过
|
||||
if useragent.TokenFromRequest(c.Request) != "" {
|
||||
return nil
|
||||
}
|
||||
// 手动退出:本次进页不自动游客,并清除标记,供下次进页恢复
|
||||
if IsGuestSkipAuto(c) {
|
||||
ClearGuestSkipAuto(c)
|
||||
dropRequestCookie(c, guestSkipAutoCookie)
|
||||
suppressRequestAutoAuth(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
devID := ensureGuestDevID(c)
|
||||
env, user, err := GuestLogin(c, GuestLoginReq{
|
||||
DevID: devID,
|
||||
DeviceID: devID,
|
||||
Channel: guestChannel(c),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("ensure guest on enter failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
if env == nil || env.Code != 200 || user == nil || strings.TrimSpace(user.Token) == "" {
|
||||
tip := ""
|
||||
if env != nil {
|
||||
tip = firstNonEmptyTip(env.Tip, env.Msg)
|
||||
}
|
||||
log.Error("ensure guest on enter rejected", log.E(fmt.Errorf("code=%v tip=%s", envCode(env), tip)))
|
||||
return nil
|
||||
}
|
||||
|
||||
SetAuthCookies(c, user)
|
||||
return user
|
||||
}
|
||||
|
||||
// SyncUserOnEnter 是 SSR 页面统一的会话同步入口。同一请求已经通过游客登录、
|
||||
// getInfo 或其他认证流程取得过最新用户时直接复用,避免再次访问上游和重复写 Cookie。
|
||||
func SyncUserOnEnter(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
if user := freshRequestAuthUser(c); user != nil {
|
||||
return user
|
||||
}
|
||||
if requestAutoAuthSuppressed(c) {
|
||||
return nil
|
||||
}
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
return EnsureGuestOnEnter(c)
|
||||
}
|
||||
return RefreshUserOnEnter(c)
|
||||
}
|
||||
|
||||
// RefreshUserOnEnter 页面进入时用 GET /api/app/auth/getInfo 刷新用户信息并写回 cookie。
|
||||
// 保证刷新后头部昵称、头像、金币等与上游一致(如解锁后余额变化)。
|
||||
func RefreshUserOnEnter(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
if user := freshRequestAuthUser(c); user != nil {
|
||||
return user
|
||||
}
|
||||
if requestAutoAuthSuppressed(c) {
|
||||
return nil
|
||||
}
|
||||
token := useragent.TokenFromRequest(c.Request)
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
env, user, err := GetInfo(c)
|
||||
if err != nil {
|
||||
log.Error("refresh user on enter failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
if env != nil && IsSessionExpiredCode(env.Code) {
|
||||
if retry, _ := OnSessionExpired(c, env.Code); retry {
|
||||
// 游客重建已经返回完整用户并写好 Cookie,无需立刻再 getInfo 一次。
|
||||
if fresh := freshRequestAuthUser(c); fresh != nil {
|
||||
return fresh
|
||||
}
|
||||
env, user, err = GetInfo(c)
|
||||
if err != nil {
|
||||
log.Error("refresh user on enter retry failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if env == nil || env.Code != 200 || user == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(user.Token) == "" {
|
||||
user.Token = useragent.TokenFromRequest(c.Request)
|
||||
if user.Token == "" {
|
||||
user.Token = token
|
||||
}
|
||||
}
|
||||
SetAuthCookies(c, user)
|
||||
return user
|
||||
}
|
||||
|
||||
// RefreshGuestSession 游客会话失效时(如上游 6001 用户不存在)重新游客登录。
|
||||
// 正式注册账号不处理,避免把用户踢成游客。
|
||||
func RefreshGuestSession(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
if IsRegisteredFromCookies(c) {
|
||||
return nil
|
||||
}
|
||||
forgetRequestAuthUser(c)
|
||||
dropRequestCookie(c, "token")
|
||||
dropRequestCookie(c, "_token_")
|
||||
return EnsureGuestOnEnter(c)
|
||||
}
|
||||
|
||||
func dropRequestCookie(c *gin.Context, name string) {
|
||||
if c == nil || c.Request == nil || name == "" {
|
||||
return
|
||||
}
|
||||
var parts []string
|
||||
for _, ck := range c.Request.Cookies() {
|
||||
if ck == nil || ck.Name == name {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, ck.Name+"="+ck.Value)
|
||||
}
|
||||
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
// MarkGuestSkipAuto 手动退出后标记:跳过紧接着的一次自动游客登录。
|
||||
func MarkGuestSkipAuto(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
suppressRequestAutoAuth(c)
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
writeCookie(c, &http.Cookie{
|
||||
Name: guestSkipAutoCookie,
|
||||
Value: "1",
|
||||
Path: "/",
|
||||
MaxAge: 0, // session cookie
|
||||
HttpOnly: false,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// IsGuestSkipAuto 是否处于「手动退出后跳过自动游客」状态。
|
||||
func IsGuestSkipAuto(c *gin.Context) bool {
|
||||
return cookieValue(c, guestSkipAutoCookie) == "1"
|
||||
}
|
||||
|
||||
// ClearGuestSkipAuto 清除手动退出跳过标记。
|
||||
func ClearGuestSkipAuto(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
writeCookie(c, &http.Cookie{
|
||||
Name: guestSkipAutoCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func ensureGuestDevID(c *gin.Context) string {
|
||||
if id := cookieValue(c, guestDevIDCookie); id != "" {
|
||||
return id
|
||||
}
|
||||
id := newGuestDevID()
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
setCookie(c, guestDevIDCookie, id, secure)
|
||||
patchRequestCookie(c, guestDevIDCookie, id)
|
||||
return id
|
||||
}
|
||||
|
||||
func newGuestDevID() string {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
now := time.Now()
|
||||
return fmt.Sprintf("%016X%d", uint64(now.UnixNano()), now.UnixMilli())
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(b)) + strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
}
|
||||
|
||||
// ResolveChannel 统一解析渠道码并按首次归因落盘。
|
||||
// 优先级:已有 channel cookie > 调用方传入值 > dc/ch/pc 查询参数;已有渠道不覆盖。
|
||||
func ResolveChannel(c *gin.Context, candidate string) string {
|
||||
channel := strings.TrimSpace(candidate)
|
||||
if c == nil || c.Request == nil {
|
||||
return channel
|
||||
}
|
||||
if stored := cookieValue(c, "channel"); stored != "" {
|
||||
return stored
|
||||
}
|
||||
if channel == "" {
|
||||
for _, key := range []string{"dc", "ch", "pc"} {
|
||||
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||
channel = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if channel == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
secure := c.Request.TLS != nil
|
||||
setCookie(c, "channel", channel, secure)
|
||||
patchRequestCookie(c, "channel", channel)
|
||||
return channel
|
||||
}
|
||||
|
||||
// guestChannel 为首屏服务端自动游客登录补渠道;cookie 用于复用前端首次归因结果。
|
||||
func guestChannel(c *gin.Context) string {
|
||||
return ResolveChannel(c, "")
|
||||
}
|
||||
|
||||
func patchAuthCookiesToRequest(c *gin.Context, user *LoginResp) {
|
||||
if c == nil || user == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(user.Token) != "" {
|
||||
patchRequestCookie(c, "token", user.Token)
|
||||
}
|
||||
patchRequestCookie(c, "userId", strconv.FormatInt(user.UserID, 10))
|
||||
patchRequestCookie(c, "accountNo", user.AccountNo)
|
||||
patchRequestCookie(c, "nickName", user.NickName)
|
||||
patchRequestCookie(c, "introduction", user.Introduction)
|
||||
patchRequestCookie(c, "avatar", user.Avatar)
|
||||
patchRequestCookie(c, "email", user.Email)
|
||||
patchRequestCookie(c, "inviteCode", user.InviteCode)
|
||||
patchRequestCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10))
|
||||
patchRequestCookie(c, "vipType", strconv.Itoa(user.VipType))
|
||||
patchRequestCookie(c, "currentVipExpire", user.CurrentVipExpire)
|
||||
patchRequestCookie(c, "showCardId", strconv.FormatInt(user.ShowCardID, 10))
|
||||
patchRequestCookie(c, "showCardName", user.ShowCardName)
|
||||
patchRequestCookie(c, "userType", strconv.Itoa(user.Type))
|
||||
isReg := "0"
|
||||
if user.IsRegister || user.Type == 1 {
|
||||
isReg = "1"
|
||||
}
|
||||
patchRequestCookie(c, "isRegister", isReg)
|
||||
}
|
||||
|
||||
func patchRequestCookie(c *gin.Context, name, value string) {
|
||||
if c == nil || c.Request == nil || name == "" {
|
||||
return
|
||||
}
|
||||
escaped := url.PathEscape(value)
|
||||
var parts []string
|
||||
for _, ck := range c.Request.Cookies() {
|
||||
if ck == nil || ck.Name == name {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, ck.Name+"="+ck.Value)
|
||||
}
|
||||
parts = append(parts, name+"="+escaped)
|
||||
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
func envCode(env *APIEnvelope) int {
|
||||
if env == nil {
|
||||
return 0
|
||||
}
|
||||
return env.Code
|
||||
}
|
||||
|
||||
func firstNonEmptyTip(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var authCookieNamesWritten = []string{
|
||||
"token",
|
||||
"userId",
|
||||
"accountNo",
|
||||
"nickName",
|
||||
"avatar",
|
||||
"recharge",
|
||||
"vipType",
|
||||
"currentVipExpire",
|
||||
"userType",
|
||||
"isRegister",
|
||||
}
|
||||
|
||||
func TestSyncUserOnEnterCreatesGuestWithoutRefreshingIt(t *testing.T) {
|
||||
var guestCalls atomic.Int32
|
||||
var getInfoCalls atomic.Int32
|
||||
|
||||
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case guestPath:
|
||||
guestCalls.Add(1)
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("guest method = %s, want POST", r.Method)
|
||||
}
|
||||
var request GuestLoginReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode guest request: %v", err)
|
||||
}
|
||||
if request.DevID == "" || request.DeviceID != request.DevID {
|
||||
t.Errorf("guest device ids = %#v, want matching non-empty ids", request)
|
||||
}
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "new-guest"))
|
||||
case getInfoPath:
|
||||
getInfoCalls.Add(1)
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "unexpected-refresh"))
|
||||
default:
|
||||
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
c, recorder := newAuthTestContext()
|
||||
user := SyncUserOnEnter(c)
|
||||
|
||||
if user == nil || user.Token != "guest-token" {
|
||||
t.Fatalf("SyncUserOnEnter user = %#v, want guest-token", user)
|
||||
}
|
||||
if got := guestCalls.Load(); got != 1 {
|
||||
t.Errorf("guest calls = %d, want 1", got)
|
||||
}
|
||||
if got := getInfoCalls.Load(); got != 0 {
|
||||
t.Errorf("getInfo calls = %d, want 0", got)
|
||||
}
|
||||
assertRequestToken(t, c, "guest-token")
|
||||
assertSingleAuthCookieSet(t, recorder)
|
||||
}
|
||||
|
||||
func TestSyncUserOnEnterRefreshesExistingTokenOnlyOnce(t *testing.T) {
|
||||
var guestCalls atomic.Int32
|
||||
var getInfoCalls atomic.Int32
|
||||
authorization := make(chan string, 1)
|
||||
|
||||
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case guestPath:
|
||||
guestCalls.Add(1)
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("unexpected-guest-token", "unexpected-guest"))
|
||||
case getInfoPath:
|
||||
getInfoCalls.Add(1)
|
||||
select {
|
||||
case authorization <- r.Header.Get("Authorization"):
|
||||
default:
|
||||
}
|
||||
// getInfo may omit token; the existing request token must be preserved.
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("", "fresh-user"))
|
||||
default:
|
||||
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
c, recorder := newAuthTestContext(
|
||||
&http.Cookie{Name: "token", Value: "existing-token"},
|
||||
&http.Cookie{Name: "sid", Value: "existing-sid"},
|
||||
)
|
||||
first := SyncUserOnEnter(c)
|
||||
second := SyncUserOnEnter(c)
|
||||
|
||||
if first == nil || first.Token != "existing-token" || first.NickName != "fresh-user" {
|
||||
t.Fatalf("first SyncUserOnEnter user = %#v, want refreshed user with existing token", first)
|
||||
}
|
||||
if second == nil || second.Token != "existing-token" || second.NickName != "fresh-user" {
|
||||
t.Fatalf("second SyncUserOnEnter user = %#v, want memoized refreshed user", second)
|
||||
}
|
||||
if got := guestCalls.Load(); got != 0 {
|
||||
t.Errorf("guest calls = %d, want 0", got)
|
||||
}
|
||||
if got := getInfoCalls.Load(); got != 1 {
|
||||
t.Errorf("getInfo calls = %d, want 1", got)
|
||||
}
|
||||
select {
|
||||
case got := <-authorization:
|
||||
if got != "existing-token" {
|
||||
t.Errorf("Authorization = %q, want existing-token", got)
|
||||
}
|
||||
default:
|
||||
t.Error("getInfo did not capture Authorization header")
|
||||
}
|
||||
assertRequestToken(t, c, "existing-token")
|
||||
if got := cookieValue(c, "nickName"); got != "fresh-user" {
|
||||
t.Errorf("request nickName cookie = %q, want fresh-user", got)
|
||||
}
|
||||
assertSingleAuthCookieSet(t, recorder)
|
||||
}
|
||||
|
||||
func TestSyncUserOnEnterReusesGuestCreatedBeforeRender(t *testing.T) {
|
||||
var guestCalls atomic.Int32
|
||||
var getInfoCalls atomic.Int32
|
||||
|
||||
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case guestPath:
|
||||
guestCalls.Add(1)
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "pre-render-guest"))
|
||||
case getInfoPath:
|
||||
getInfoCalls.Add(1)
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "unexpected-refresh"))
|
||||
default:
|
||||
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
c, recorder := newAuthTestContext()
|
||||
guest := EnsureGuestOnEnter(c)
|
||||
user := SyncUserOnEnter(c)
|
||||
|
||||
if guest == nil || guest.Token != "pre-render-token" {
|
||||
t.Fatalf("EnsureGuestOnEnter user = %#v, want pre-render guest", guest)
|
||||
}
|
||||
if user == nil || user.Token != "pre-render-token" || user.NickName != "pre-render-guest" {
|
||||
t.Fatalf("SyncUserOnEnter user = %#v, want memoized pre-render guest", user)
|
||||
}
|
||||
if got := guestCalls.Load(); got != 1 {
|
||||
t.Errorf("guest calls = %d, want 1", got)
|
||||
}
|
||||
if got := getInfoCalls.Load(); got != 0 {
|
||||
t.Errorf("getInfo calls = %d, want 0", got)
|
||||
}
|
||||
assertRequestToken(t, c, "pre-render-token")
|
||||
assertSingleAuthCookieSet(t, recorder)
|
||||
}
|
||||
|
||||
func TestRefreshUserOnEnterReturnsRebuiltGuestWithoutRefreshingItAgain(t *testing.T) {
|
||||
var guestCalls atomic.Int32
|
||||
var getInfoCalls atomic.Int32
|
||||
getInfoAuthorization := make(chan string, 1)
|
||||
guestAuthorization := make(chan string, 1)
|
||||
|
||||
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case getInfoPath:
|
||||
getInfoCalls.Add(1)
|
||||
select {
|
||||
case getInfoAuthorization <- r.Header.Get("Authorization"):
|
||||
default:
|
||||
}
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 6001, nil)
|
||||
case guestPath:
|
||||
guestCalls.Add(1)
|
||||
select {
|
||||
case guestAuthorization <- r.Header.Get("Authorization"):
|
||||
default:
|
||||
}
|
||||
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("rebuilt-guest-token", "rebuilt-guest"))
|
||||
default:
|
||||
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
c, recorder := newAuthTestContext(
|
||||
&http.Cookie{Name: "token", Value: "expired-guest-token"},
|
||||
&http.Cookie{Name: "isRegister", Value: "0"},
|
||||
&http.Cookie{Name: "userType", Value: "0"},
|
||||
)
|
||||
user := RefreshUserOnEnter(c)
|
||||
// The rebuilt guest must also be reusable by the page-level sync call.
|
||||
synced := SyncUserOnEnter(c)
|
||||
|
||||
if user == nil || user.Token != "rebuilt-guest-token" || user.NickName != "rebuilt-guest" {
|
||||
t.Fatalf("RefreshUserOnEnter user = %#v, want rebuilt guest", user)
|
||||
}
|
||||
if synced == nil || synced.Token != "rebuilt-guest-token" {
|
||||
t.Fatalf("SyncUserOnEnter user = %#v, want memoized rebuilt guest", synced)
|
||||
}
|
||||
if got := getInfoCalls.Load(); got != 1 {
|
||||
t.Errorf("getInfo calls = %d, want 1 (no refresh of rebuilt guest)", got)
|
||||
}
|
||||
if got := guestCalls.Load(); got != 1 {
|
||||
t.Errorf("guest calls = %d, want 1", got)
|
||||
}
|
||||
select {
|
||||
case got := <-getInfoAuthorization:
|
||||
if got != "expired-guest-token" {
|
||||
t.Errorf("getInfo Authorization = %q, want expired-guest-token", got)
|
||||
}
|
||||
default:
|
||||
t.Error("getInfo did not capture Authorization header")
|
||||
}
|
||||
select {
|
||||
case got := <-guestAuthorization:
|
||||
if got != "" {
|
||||
t.Errorf("guest Authorization = %q, want empty after expired token is removed", got)
|
||||
}
|
||||
default:
|
||||
t.Error("guest login did not capture Authorization header")
|
||||
}
|
||||
assertRequestToken(t, c, "rebuilt-guest-token")
|
||||
assertSingleAuthCookieSet(t, recorder)
|
||||
}
|
||||
|
||||
func TestSetAuthCookiesUpsertsSameNameWithinResponse(t *testing.T) {
|
||||
c, recorder := newAuthTestContext()
|
||||
SetAuthCookies(c, testLoginUser("first-token", "first-user"))
|
||||
SetAuthCookies(c, testLoginUser("latest-token", "latest-user"))
|
||||
|
||||
assertSingleAuthCookieSet(t, recorder)
|
||||
assertRequestToken(t, c, "latest-token")
|
||||
if got := cookieValue(c, "nickName"); got != "latest-user" {
|
||||
t.Errorf("request nickName cookie = %q, want latest-user", got)
|
||||
}
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.Name == "token" && cookie.Value != "latest-token" {
|
||||
t.Errorf("response token cookie = %q, want latest-token", cookie.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func useAuthTestUpstream(t *testing.T, handler http.Handler) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
previousServerAddr := appg.Conf.ServerAddr
|
||||
appg.Conf.ServerAddr = server.URL
|
||||
t.Cleanup(func() {
|
||||
appg.Conf.ServerAddr = previousServerAddr
|
||||
server.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func newAuthTestContext(cookies ...*http.Cookie) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "https://site.test/page", nil)
|
||||
for _, cookie := range cookies {
|
||||
c.Request.AddCookie(cookie)
|
||||
}
|
||||
return c, recorder
|
||||
}
|
||||
|
||||
func writeAuthTestEnvelope(w http.ResponseWriter, status, code int, data *LoginResp) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data *LoginResp `json:"data"`
|
||||
}{
|
||||
Code: code,
|
||||
Msg: http.StatusText(status),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func testLoginUser(token, nickName string) *LoginResp {
|
||||
return &LoginResp{
|
||||
Token: token,
|
||||
UserID: 101,
|
||||
AccountNo: "account-101",
|
||||
Email: "user101@example.test",
|
||||
NickName: nickName,
|
||||
// This field is intentionally large: it must stay out of Set-Cookie headers.
|
||||
Introduction: strings.Repeat("简介", 150),
|
||||
Avatar: "https://cdn.example.test/avatar.png",
|
||||
InviteCode: "INVITE101",
|
||||
VipType: 1,
|
||||
CurrentVipExpire: "2099-01-01T00:00:00Z",
|
||||
ShowCardID: 7,
|
||||
ShowCardName: "monthly",
|
||||
Type: 0,
|
||||
Recharge: 88,
|
||||
}
|
||||
}
|
||||
|
||||
func assertRequestToken(t *testing.T, c *gin.Context, want string) {
|
||||
t.Helper()
|
||||
if got := useragent.TokenFromRequest(c.Request); got != want {
|
||||
t.Errorf("request token = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSingleAuthCookieSet(t *testing.T, recorder *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
counts := make(map[string]int)
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
counts[cookie.Name]++
|
||||
}
|
||||
for name, count := range counts {
|
||||
if count > 1 {
|
||||
t.Errorf("Set-Cookie %q written %d times, want at most once", name, count)
|
||||
}
|
||||
}
|
||||
for _, name := range authCookieNamesWritten {
|
||||
if got := counts[name]; got != 1 {
|
||||
t.Errorf("auth Set-Cookie %q written %d times, want exactly once", name, got)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"introduction", "email", "inviteCode", "showCardId", "showCardName", "sid"} {
|
||||
if got := counts[name]; got != 0 {
|
||||
t.Errorf("non-session Set-Cookie %q written %d times, want none", name, got)
|
||||
}
|
||||
}
|
||||
var headerBytes int
|
||||
for _, raw := range recorder.Header().Values("Set-Cookie") {
|
||||
headerBytes += len("Set-Cookie: ") + len(raw) + len("\r\n")
|
||||
}
|
||||
if headerBytes >= 4096 {
|
||||
t.Errorf("Set-Cookie response headers = %d bytes, want below 4096", headerBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package authser
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
const requestAuthStateKey = "authser.request-auth-state"
|
||||
|
||||
// requestAuthState 只在单个 Gin 请求内有效,用来避免多个 SSR 服务重复刷新同一会话。
|
||||
// freshUser 表示本请求已经从上游取得并写回过的最新用户;suppressAutoAuth
|
||||
// 表示本请求明确不应再自动创建游客(例如用户刚退出或正式账号已过期)。
|
||||
type requestAuthState struct {
|
||||
freshUser *LoginResp
|
||||
suppressAutoAuth bool
|
||||
}
|
||||
|
||||
func authState(c *gin.Context) *requestAuthState {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if value, ok := c.Get(requestAuthStateKey); ok {
|
||||
if state, ok := value.(*requestAuthState); ok && state != nil {
|
||||
return state
|
||||
}
|
||||
}
|
||||
state := &requestAuthState{}
|
||||
c.Set(requestAuthStateKey, state)
|
||||
return state
|
||||
}
|
||||
|
||||
func freshRequestAuthUser(c *gin.Context) *LoginResp {
|
||||
state := authState(c)
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
return state.freshUser
|
||||
}
|
||||
|
||||
func rememberRequestAuthUser(c *gin.Context, user *LoginResp) {
|
||||
state := authState(c)
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.freshUser = user
|
||||
state.suppressAutoAuth = false
|
||||
}
|
||||
|
||||
func forgetRequestAuthUser(c *gin.Context) {
|
||||
if state := authState(c); state != nil {
|
||||
state.freshUser = nil
|
||||
}
|
||||
}
|
||||
|
||||
func suppressRequestAutoAuth(c *gin.Context) {
|
||||
if state := authState(c); state != nil {
|
||||
state.suppressAutoAuth = true
|
||||
}
|
||||
}
|
||||
|
||||
func allowRequestAutoAuth(c *gin.Context) {
|
||||
if state := authState(c); state != nil {
|
||||
state.suppressAutoAuth = false
|
||||
}
|
||||
}
|
||||
|
||||
func requestAutoAuthSuppressed(c *gin.Context) bool {
|
||||
state := authState(c)
|
||||
return state != nil && state.suppressAutoAuth
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package authser
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// TokenExpiredCode 上游约定:token 失效,需重新登录。
|
||||
const TokenExpiredCode = 1000
|
||||
|
||||
// IsSessionExpiredCode 是否为会话失效类业务码。
|
||||
func IsSessionExpiredCode(code int) bool {
|
||||
switch code {
|
||||
case TokenExpiredCode, 1002, 1012, 6001: // InvalidToken / NoToken / InvalidateToken / 用户不存在
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// OnSessionExpired 处理上游会话失效。
|
||||
// retry=true:游客已重新登录,调用方应重试原请求;
|
||||
// needLogin=true:注册账号已清 cookie,调用方应跳转登录弹窗/页。
|
||||
func OnSessionExpired(c *gin.Context, code int) (retry, needLogin bool) {
|
||||
if c == nil || !IsSessionExpiredCode(code) {
|
||||
return false, false
|
||||
}
|
||||
if IsRegisteredFromCookies(c) {
|
||||
ClearAuthCookies(c)
|
||||
dropRequestCookie(c, "token")
|
||||
dropRequestCookie(c, "_token_")
|
||||
// 正式账号失效后,本请求保持退出态,避免后续 HTML 渲染又静默转成游客。
|
||||
suppressRequestAutoAuth(c)
|
||||
return false, true
|
||||
}
|
||||
if RefreshGuestSession(c) != nil {
|
||||
return true, false
|
||||
}
|
||||
ClearAuthCookies(c)
|
||||
dropRequestCookie(c, "token")
|
||||
dropRequestCookie(c, "_token_")
|
||||
return false, false
|
||||
}
|
||||
|
||||
// RecoverContentSession 用于允许游客访问的内容读取接口。
|
||||
// 正式账号或游客 token 失效后,清理旧登录态、重新建立游客会话,调用方随后重试一次原请求。
|
||||
// 写操作仍应使用 OnSessionExpired,避免把用户操作静默降级为游客操作。
|
||||
func RecoverContentSession(c *gin.Context, code int) bool {
|
||||
if c == nil || !IsSessionExpiredCode(code) {
|
||||
return false
|
||||
}
|
||||
ClearAuthCookies(c)
|
||||
for _, name := range authCookieNames() {
|
||||
dropRequestCookie(c, name)
|
||||
}
|
||||
dropRequestCookie(c, guestSkipAutoCookie)
|
||||
ClearGuestSkipAuto(c)
|
||||
// 内容读取接口明确允许降级为游客,因此覆盖本请求可能已有的自动认证抑制。
|
||||
allowRequestAutoAuth(c)
|
||||
return EnsureGuestOnEnter(c) != nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package authser
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// RegisterReq 对应 swagger authapi.registerReq
|
||||
type RegisterReq struct {
|
||||
AccountNo string `json:"accountNo"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Password string `json:"password"`
|
||||
AgentInviteCode string `json:"agentInviteCode,omitempty"`
|
||||
DevID string `json:"devID,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// LoginReq 对应 swagger authapi.loginReq
|
||||
type LoginReq struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
DevID string `json:"devID,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// ResetPasswordReq 对应 swagger authapi.resetPasswordReq。
|
||||
type ResetPasswordReq struct {
|
||||
AccountNo string `json:"accountNo"`
|
||||
Email string `json:"email"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
// GuestLoginReq 对应 swagger authser.GuestLoginReq
|
||||
// POST /api/app/auth/guest
|
||||
type GuestLoginReq struct {
|
||||
DevID string `json:"devID"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Mac string `json:"mac,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProfileReq 对应 swagger authapi.updateProfileReq
|
||||
type UpdateProfileReq struct {
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
Introduction *string `json:"introduction,omitempty"`
|
||||
NickName *string `json:"nickName,omitempty"`
|
||||
OldPassword *string `json:"oldPassword,omitempty"`
|
||||
NewPassword *string `json:"newPassword,omitempty"`
|
||||
PrivacyMode *bool `json:"privacyMode,omitempty"`
|
||||
}
|
||||
|
||||
// LoginResp 对应 swagger authser.LoginResp
|
||||
// GET /api/app/auth/getInfo、登录/注册/游客成功 data。
|
||||
type LoginResp struct {
|
||||
Token string `json:"token"`
|
||||
UserID int64 `json:"userId"`
|
||||
AccountNo string `json:"accountNo"`
|
||||
Email string `json:"email"`
|
||||
NickName string `json:"nickName"`
|
||||
Introduction string `json:"introduction"` // 简介
|
||||
Avatar string `json:"avatar"` // 头像
|
||||
InviteCode string `json:"inviteCode"` // 邀请码
|
||||
VipType int `json:"vipType"` // 0没卡 1月卡 2年卡
|
||||
CurrentVipExpire string `json:"currentVipExpire"` // VIP 过期时间,需结合 vipType 判断是否有效
|
||||
ShowCardID int64 `json:"showCardId"` // 最新会员卡 id
|
||||
ShowCardName string `json:"showCardName"` // 最新会员卡名称,如「1个月会员」
|
||||
Status int `json:"status"` // 0正常 1封禁 2禁言
|
||||
Type int `json:"type"` // 身份类型:0游客 1普通用户
|
||||
IsNewUser bool `json:"isNewUser"` // 本次是否新建游客
|
||||
IsRegister bool `json:"isRegister"` // 是否已注册
|
||||
HasPassword *bool `json:"hasPassword,omitempty"` // 是否已设置密码;上游未返回时由账号状态推断
|
||||
LoginType int `json:"loginType"` // 登录方式
|
||||
PrivacyMode bool `json:"privacyMode"` // 隐私模式
|
||||
Score int64 `json:"score"` // 积分
|
||||
Recharge int64 `json:"recharge"` // 金币余额
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳。Data 在失败时可能是 string/null。
|
||||
type APIEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// ImageUploadData 对应 swagger uploadser.ImageUploadData
|
||||
type ImageUploadData struct {
|
||||
ID string `json:"id"`
|
||||
SourceNameFs string `json:"sourceNameFs"`
|
||||
}
|
||||
|
||||
// ImageUploadResp 对应 swagger uploadser.ImageUploadResp
|
||||
type ImageUploadResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data *ImageUploadData `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SettingPageVM 账号设置页展示模型。
|
||||
type SettingPageVM struct {
|
||||
UserID int64
|
||||
AccountNo string
|
||||
HasAccount bool
|
||||
HasPassword bool
|
||||
NickName string
|
||||
Introduction string
|
||||
Email string
|
||||
EmailMasked string
|
||||
EmailBound bool
|
||||
Avatar string // 可用头像 URL;空则模板用默认图
|
||||
InviteCode string
|
||||
PrivacyMode bool
|
||||
VipType int
|
||||
VipExpire string
|
||||
Recharge int64
|
||||
Score int64
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// BuildSettingVM 由 LoginResp 构建设置页 VM。
|
||||
func BuildSettingVM(user *LoginResp) SettingPageVM {
|
||||
if user == nil {
|
||||
return SettingPageVM{}
|
||||
}
|
||||
nick := strings.TrimSpace(user.NickName)
|
||||
account := strings.TrimSpace(user.AccountNo)
|
||||
registered := user.IsRegister || user.Type == 1
|
||||
hasAccount := registered && account != ""
|
||||
hasPassword := hasAccount
|
||||
if user.HasPassword != nil {
|
||||
hasPassword = *user.HasPassword
|
||||
}
|
||||
email := strings.TrimSpace(user.Email)
|
||||
intro := strings.TrimSpace(user.Introduction)
|
||||
display := nick
|
||||
if display == "" {
|
||||
display = account
|
||||
}
|
||||
if display == "" {
|
||||
display = "用户"
|
||||
}
|
||||
return SettingPageVM{
|
||||
UserID: user.UserID,
|
||||
AccountNo: account,
|
||||
HasAccount: hasAccount,
|
||||
HasPassword: hasPassword,
|
||||
NickName: nick,
|
||||
Introduction: intro,
|
||||
Email: email,
|
||||
EmailMasked: MaskEmail(email),
|
||||
EmailBound: email != "",
|
||||
Avatar: usableAvatarURL(user.Avatar),
|
||||
InviteCode: strings.TrimSpace(user.InviteCode),
|
||||
PrivacyMode: user.PrivacyMode,
|
||||
VipType: user.VipType,
|
||||
VipExpire: strings.TrimSpace(user.CurrentVipExpire),
|
||||
Recharge: user.Recharge,
|
||||
Score: user.Score,
|
||||
DisplayName: display,
|
||||
}
|
||||
}
|
||||
|
||||
// MaskEmail 邮箱脱敏,如 ab***@mail.com。
|
||||
func MaskEmail(email string) string {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return ""
|
||||
}
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at <= 0 || at == len(email)-1 {
|
||||
return email
|
||||
}
|
||||
local := email[:at]
|
||||
domain := email[at:]
|
||||
runes := []rune(local)
|
||||
if len(runes) <= 2 {
|
||||
return string(runes[:1]) + "***" + domain
|
||||
}
|
||||
return string(runes[:2]) + "***" + domain
|
||||
}
|
||||
|
||||
// FormatUserID 展示用 ID。
|
||||
func FormatUserID(id int64) string {
|
||||
if id <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", id)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func parseVipExpireMs(raw string) int64 {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
if n > 0 && n < 1e12 {
|
||||
return n * 1000
|
||||
}
|
||||
return n
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// UserIDFromCookies 读取 cookie userId。
|
||||
func UserIDFromCookies(c *gin.Context) int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "userId")), 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// VipTypeFromCookies 读取 cookie vipType。
|
||||
func VipTypeFromCookies(c *gin.Context) int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(cookieValue(c, "vipType")))
|
||||
return n
|
||||
}
|
||||
|
||||
// VipTypeLabel vipType 文案:0普通 1月卡 2年卡(与 91ly / 编辑资料注释一致)。
|
||||
func VipTypeLabel(vipType int) string {
|
||||
switch vipType {
|
||||
case 1:
|
||||
return "月卡会员"
|
||||
case 2:
|
||||
return "年卡会员"
|
||||
default:
|
||||
return "普通会员"
|
||||
}
|
||||
}
|
||||
|
||||
// ShowCardNameFromCookies 读取 cookie showCardName。
|
||||
func ShowCardNameFromCookies(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cookieValue(c, "showCardName"))
|
||||
}
|
||||
|
||||
// ShowCardIDFromCookies 读取 cookie showCardId。
|
||||
func ShowCardIDFromCookies(c *gin.Context) int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "showCardId")), 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// VipDisplayLabel 优先使用 getInfo.showCardName,否则回退 vipType 文案。
|
||||
func VipDisplayLabel(showCardName string, vipType int) string {
|
||||
if s := strings.TrimSpace(showCardName); s != "" {
|
||||
return s
|
||||
}
|
||||
return VipTypeLabel(vipType)
|
||||
}
|
||||
|
||||
// VipDisplayLabelFromCookies 从 cookie 组装会员展示名。
|
||||
func VipDisplayLabelFromCookies(c *gin.Context) string {
|
||||
return VipDisplayLabel(ShowCardNameFromCookies(c), VipTypeFromCookies(c))
|
||||
}
|
||||
|
||||
// IsActiveVipUser 根据 vipType + currentVipExpire 判断是否有效会员。
|
||||
func IsActiveVipUser(vipType int, currentVipExpire string) bool {
|
||||
if vipType == 0 {
|
||||
return false
|
||||
}
|
||||
expireMs := parseVipExpireMs(currentVipExpire)
|
||||
if expireMs <= 0 {
|
||||
return false
|
||||
}
|
||||
return expireMs > time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
// FormatVipExpireText 格式化到期时间展示。
|
||||
func FormatVipExpireText(currentVipExpire string) string {
|
||||
ms := parseVipExpireMs(currentVipExpire)
|
||||
if ms <= 0 {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).In(time.Local).Format("有效期至 2006/01/02")
|
||||
}
|
||||
|
||||
// VipExpireTextFromCookies 格式化到期时间展示。
|
||||
func VipExpireTextFromCookies(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return FormatVipExpireText(cookieValue(c, "currentVipExpire"))
|
||||
}
|
||||
|
||||
// IsActiveVipFromCookies vipType≠0 且 currentVipExpire 未过期(与 91ly 一致)。
|
||||
func IsActiveVipFromCookies(c *gin.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return IsActiveVipUser(VipTypeFromCookies(c), cookieValue(c, "currentVipExpire"))
|
||||
}
|
||||
|
||||
// RechargeFromCookies 读取 cookie recharge(金币余额)。
|
||||
func RechargeFromCookies(c *gin.Context) int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "recharge")), 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// SetRechargeCookie 更新金币余额 cookie(解锁/充值后同步)。
|
||||
func SetRechargeCookie(c *gin.Context, balance int64) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if balance < 0 {
|
||||
balance = 0
|
||||
}
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
setCookie(c, "recharge", strconv.FormatInt(balance, 10), secure)
|
||||
}
|
||||
|
||||
// FormatRecharge 金币余额千分位展示。
|
||||
func FormatRecharge(n int64) string {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
s := strconv.FormatInt(n, 10)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
rem := len(s) % 3
|
||||
if rem > 0 {
|
||||
b.WriteString(s[:rem])
|
||||
if len(s) > rem {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
}
|
||||
for i := rem; i < len(s); i += 3 {
|
||||
b.WriteString(s[i : i+3])
|
||||
if i+3 < len(s) {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const likeSetPath = "/api/app/comment/like/set"
|
||||
|
||||
// LikeSet 设置评论点赞/取消点赞。
|
||||
func LikeSet(c *gin.Context, req CommentLikeSetReq) (*APIEnvelope[CommentLikeSetResp], error) {
|
||||
if req.CommentID <= 0 {
|
||||
return nil, fmt.Errorf("comment like invalid commentId")
|
||||
}
|
||||
var env APIEnvelope[CommentLikeSetResp]
|
||||
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
listPath = "/api/app/comment/list"
|
||||
defaultCommentSize = 10
|
||||
)
|
||||
|
||||
// ListQuery 评论列表查询参数。
|
||||
type ListQuery struct {
|
||||
ObjectID int64
|
||||
ObjectType int
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// Normalize 校正分页与排序默认值。
|
||||
func (q *ListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultCommentSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
q.ObjectType = NormalizeObjectType(q.ObjectType)
|
||||
if q.SortOrder != SortLatest {
|
||||
q.SortOrder = SortHot
|
||||
}
|
||||
}
|
||||
|
||||
// FetchList 分页查询评论。
|
||||
func FetchList(c *gin.Context, q ListQuery) (*GetCommentListResp, error) {
|
||||
q.Normalize()
|
||||
if q.ObjectID <= 0 {
|
||||
return nil, fmt.Errorf("comment list invalid objectId")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("objectId", strconv.FormatInt(q.ObjectID, 10))
|
||||
params.Set("objectType", strconv.Itoa(q.ObjectType))
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
|
||||
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
requestPath := listPath + "?" + params.Encode()
|
||||
env, err := fetchListOnce(c, requestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) && authser.RecoverContentSession(c, env.Code) {
|
||||
env, err = fetchListOnce(c, requestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("comment list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
func fetchListOnce(c *gin.Context, path string) (*APIEnvelope[GetCommentListResp], error) {
|
||||
var env APIEnvelope[GetCommentListResp]
|
||||
if err := upstream.GetJSON(c, path, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchListOrEmpty 失败时返回空列表。
|
||||
func FetchListOrEmpty(c *gin.Context, q ListQuery) *GetCommentListResp {
|
||||
data, err := FetchList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch comment list failed", log.E(err))
|
||||
return &GetCommentListResp{
|
||||
ObjectID: q.ObjectID,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
if data == nil {
|
||||
return &GetCommentListResp{
|
||||
ObjectID: q.ObjectID,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ParseSortOrder 解析站点评论排序(1 热门 / 2 最新)。
|
||||
func ParseSortOrder(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n != SortLatest {
|
||||
return SortHot
|
||||
}
|
||||
return SortLatest
|
||||
}
|
||||
|
||||
// FormatCreatedAt 展示用时间格式化(非字段映射)。
|
||||
func FormatCreatedAt(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t.Local().Format("2006/1/2 15:04")
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// AvatarOrDefault 头像空时回退默认图。
|
||||
func AvatarOrDefault(avatar string) string {
|
||||
avatar = strings.TrimSpace(avatar)
|
||||
if avatar == "" {
|
||||
return "/static/images/profile/avatar.png"
|
||||
}
|
||||
return avatar
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const sendPath = "/api/app/comment/send"
|
||||
|
||||
// Send 发布评论(一级 / 二级)。
|
||||
func Send(c *gin.Context, req CommentSendReq) (*APIEnvelope[any], error) {
|
||||
req.Text = strings.TrimSpace(req.Text)
|
||||
if req.ObjectID <= 0 {
|
||||
return nil, fmt.Errorf("comment send invalid objectId")
|
||||
}
|
||||
req.ObjectType = NormalizeObjectType(req.ObjectType)
|
||||
if req.Text == "" {
|
||||
return nil, fmt.Errorf("comment send empty text")
|
||||
}
|
||||
if utf8.RuneCountInString(req.Text) > 200 {
|
||||
return nil, fmt.Errorf("comment send text too long")
|
||||
}
|
||||
if req.ParentsID < 0 {
|
||||
req.ParentsID = 0
|
||||
}
|
||||
if req.ReplyCommentID < 0 {
|
||||
req.ReplyCommentID = 0
|
||||
}
|
||||
// 二级评论:未显式指定 replyCommentId 时默认回复父评论
|
||||
if req.ParentsID > 0 && req.ReplyCommentID <= 0 {
|
||||
req.ReplyCommentID = req.ParentsID
|
||||
}
|
||||
|
||||
var env APIEnvelope[any]
|
||||
if _, err := upstream.PostJSON(c, sendPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package commentser
|
||||
|
||||
import "html/template"
|
||||
|
||||
// TemplateFuncs 评论展示辅助。
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatCommentTime": FormatCreatedAt,
|
||||
"commentAvatar": AvatarOrDefault,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package commentser
|
||||
|
||||
// CommentSendReq 对应 swagger proto.CommentSendReq
|
||||
// parentsId 为 0 发一级评论,大于 0 发二级评论;replyCommentId 为实际被回复的评论。
|
||||
type CommentSendReq struct {
|
||||
ObjectID int64 `json:"objectId"`
|
||||
ObjectType int `json:"objectType"`
|
||||
Text string `json:"text"`
|
||||
ParentsID int64 `json:"parentsId"`
|
||||
ReplyCommentID int64 `json:"replyCommentId"`
|
||||
}
|
||||
|
||||
// CommentLikeSetReq 对应 swagger proto.CommentLikeSetReq
|
||||
type CommentLikeSetReq struct {
|
||||
CommentID int64 `json:"commentId"`
|
||||
IsLike bool `json:"isLike"`
|
||||
}
|
||||
|
||||
// CommentLikeSetResp 对应 swagger proto.CommentLikeSetResp
|
||||
type CommentLikeSetResp struct {
|
||||
CommentID int64 `json:"commentId"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsLike int `json:"isLike"`
|
||||
}
|
||||
|
||||
// CommentInfo 对应 swagger proto.CommentInfo
|
||||
type CommentInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
ObjectID int64 `json:"objectId"`
|
||||
ObjectType int `json:"objectType"`
|
||||
Text string `json:"text"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
UserAvatar string `json:"userAvatar"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
CommentCount int64 `json:"commentCount"`
|
||||
IsLike int `json:"isLike"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Level int `json:"level,omitempty"`
|
||||
ParentsID int64 `json:"parentsId,omitempty"`
|
||||
ReplyCommentID int64 `json:"replyCommentId,omitempty"`
|
||||
ReplyUserName string `json:"replyUserName,omitempty"`
|
||||
Children []CommentInfo `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// GetCommentListResp 对应 swagger proto.GetCommentListResp
|
||||
type GetCommentListResp struct {
|
||||
List []CommentInfo `json:"list"`
|
||||
ObjectID int64 `json:"objectId"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
const (
|
||||
// ObjectTypePlaylet 短剧评论
|
||||
ObjectTypePlaylet = 10
|
||||
// ObjectTypeChapter 短剧剧集评论
|
||||
ObjectTypeChapter = 11
|
||||
// ObjectTypeVideo 视频评论
|
||||
ObjectTypeVideo = 20
|
||||
|
||||
SortHot = 1 // 热门
|
||||
SortLatest = 2 // 最新
|
||||
)
|
||||
|
||||
// NormalizeObjectType 校正评论对象类型:10-短剧 11-短剧剧集 20-视频。
|
||||
func NormalizeObjectType(objectType int) int {
|
||||
switch objectType {
|
||||
case ObjectTypeChapter, ObjectTypeVideo:
|
||||
return objectType
|
||||
default:
|
||||
return ObjectTypePlaylet
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
configInfoPath = "/api/app/config/info"
|
||||
configInfoCacheTTL = 60 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
configMu sync.Mutex
|
||||
configCache *AppConfigVM
|
||||
configAt time.Time
|
||||
)
|
||||
|
||||
// FetchConfigInfo 拉取 APP 公共配置。
|
||||
func FetchConfigInfo(c *gin.Context) (*ConfigInfoResp, error) {
|
||||
var env APIEnvelope[ConfigInfoResp]
|
||||
if err := upstream.GetPublicJSON(c, configInfoPath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("config info code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchAppConfigVMOrEmpty 带短缓存;刷新失败时优先返回最近一次成功配置。
|
||||
func FetchAppConfigVMOrEmpty(c *gin.Context) AppConfigVM {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
if configCache != nil && time.Since(configAt) < configInfoCacheTTL {
|
||||
return *configCache
|
||||
}
|
||||
|
||||
info, err := FetchConfigInfo(c)
|
||||
if err != nil {
|
||||
log.Error("fetch app config info failed", log.E(err))
|
||||
if configCache != nil {
|
||||
return *configCache
|
||||
}
|
||||
return ApplyTrackerFallback(AppConfigVM{})
|
||||
}
|
||||
vm := ApplyTrackerFallback(BuildAppConfigVM(info))
|
||||
configCache = &vm
|
||||
configAt = time.Now()
|
||||
return vm
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const richtextListPath = "/api/app/config/richtext/list"
|
||||
|
||||
// RichTextPageVM 富文本页模板数据。
|
||||
type RichTextPageVM struct {
|
||||
Title string
|
||||
Subtitle string
|
||||
ContentHTML template.HTML
|
||||
Found bool
|
||||
}
|
||||
|
||||
// FetchRichTextList 查询富文本列表(ids / titles 逗号分隔)。
|
||||
func FetchRichTextList(c *gin.Context, ids, titles string) ([]AppRichTextItemResp, error) {
|
||||
params := url.Values{}
|
||||
if s := strings.TrimSpace(ids); s != "" {
|
||||
params.Set("ids", s)
|
||||
}
|
||||
if s := strings.TrimSpace(titles); s != "" {
|
||||
params.Set("titles", s)
|
||||
}
|
||||
path := richtextListPath
|
||||
if q := params.Encode(); q != "" {
|
||||
path += "?" + q
|
||||
}
|
||||
|
||||
var env APIEnvelope[AppRichTextListResp]
|
||||
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("richtext list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return env.Data.List, nil
|
||||
}
|
||||
|
||||
// FetchRichTextByTitle 按标题取第一条启用内容;失败返回空页 VM。
|
||||
func FetchRichTextByTitle(c *gin.Context, title string) RichTextPageVM {
|
||||
title = strings.TrimSpace(title)
|
||||
vm := RichTextPageVM{Title: title}
|
||||
if title == "" {
|
||||
return vm
|
||||
}
|
||||
|
||||
list, err := FetchRichTextList(c, "", title)
|
||||
if err != nil {
|
||||
log.Error("fetch richtext by title failed", log.E(err), log.Any("title", title))
|
||||
return vm
|
||||
}
|
||||
|
||||
item := matchRichTextByTitle(list, title)
|
||||
if item == nil {
|
||||
return vm
|
||||
}
|
||||
|
||||
displayTitle := strings.TrimSpace(item.Desc)
|
||||
if displayTitle == "" {
|
||||
displayTitle = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if displayTitle == "" {
|
||||
displayTitle = title
|
||||
}
|
||||
|
||||
vm.Found = true
|
||||
vm.Title = displayTitle
|
||||
vm.Subtitle = strings.TrimSpace(item.Desc)
|
||||
if vm.Subtitle == displayTitle {
|
||||
vm.Subtitle = ""
|
||||
}
|
||||
vm.ContentHTML = template.HTML(item.Content)
|
||||
return vm
|
||||
}
|
||||
|
||||
func matchRichTextByTitle(list []AppRichTextItemResp, title string) *AppRichTextItemResp {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range list {
|
||||
if strings.TrimSpace(list[i].Title) == title || strings.TrimSpace(list[i].Desc) == title {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
return &list[0]
|
||||
}
|
||||
|
||||
// RichTextHref 返回富文本标题对应的站内 canonical 地址。
|
||||
func RichTextHref(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return "/richtext"
|
||||
}
|
||||
if href, ok := RichTextSemanticHref(title); ok {
|
||||
return href
|
||||
}
|
||||
return "/richtext/" + url.PathEscape(title)
|
||||
}
|
||||
|
||||
// RichTextSemanticHref 将固定栏目及上游常用别名收敛到唯一语义路由。
|
||||
func RichTextSemanticHref(title string) (string, bool) {
|
||||
normalized := strings.ToLower(strings.Join(strings.Fields(title), ""))
|
||||
switch normalized {
|
||||
case "关于我们", "aboutus":
|
||||
return "/about", true
|
||||
case "用户协议", "用户条款", "useragreement", "termsofservice":
|
||||
return "/terms", true
|
||||
case "隐私政策", "privacy", "privacypolicy":
|
||||
return "/privacy", true
|
||||
case "2257合规声明", "2257", "usc2257", "合规声明":
|
||||
return "/2257", true
|
||||
case "dmca版权声明", "dmca", "版权声明", "版权投诉":
|
||||
return "/dmca", true
|
||||
case "帮助中心", "疑问解答", "常见问题", "help", "helpcenter", "faq":
|
||||
return "/help", true
|
||||
case "问题反馈", "意见反馈", "联系我们", "feedback", "contact", "contactus":
|
||||
return "/feedback", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/appg"
|
||||
)
|
||||
|
||||
// ResolveTrackerAppID 优先 yaml,否则固定 JHA-1045。
|
||||
func ResolveTrackerAppID() string {
|
||||
return appg.TrackerAppID()
|
||||
}
|
||||
|
||||
// ResolveReportSDKConfig 上游 reportSdkConfig 优先,本地 yaml 兜底。
|
||||
func ResolveReportSDKConfig(fromAPI string) string {
|
||||
if v := strings.TrimSpace(fromAPI); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(appg.Conf.Tracker.EncryptedConfig)
|
||||
}
|
||||
|
||||
// ApplyTrackerFallback 补齐 AppConfigVM 中的上报密文。
|
||||
func ApplyTrackerFallback(vm AppConfigVM) AppConfigVM {
|
||||
vm.ReportSDKConfig = ResolveReportSDKConfig(vm.ReportSDKConfig)
|
||||
return vm
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package configser
|
||||
|
||||
// FooterInfoResp 对应 swagger proto.FooterInfoResp
|
||||
type FooterInfoResp struct {
|
||||
FooterInfo FooterInfo `json:"footerInfo"`
|
||||
}
|
||||
|
||||
// FooterInfo 对应 swagger proto.FooterInfo
|
||||
// GET /api/app/config/footer/info — 模板直接使用本结构,不做自定义 VM 映射。
|
||||
type FooterInfo struct {
|
||||
Brand FooterBrand `json:"brand"`
|
||||
NavList []TranslationCategory `json:"navList"`
|
||||
ContactList []FooterContact `json:"contactList"`
|
||||
}
|
||||
|
||||
// FooterBrand 对应 swagger proto.FooterBrand
|
||||
type FooterBrand struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// TranslationCategory 对应 swagger proto.TranslationCategory(footer.navList 元素)
|
||||
type TranslationCategory struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
URLValue string `json:"urlValue"`
|
||||
CategoryType int `json:"categoryType"`
|
||||
TranslationList []TranslationInfo `json:"translationList"`
|
||||
SeoInfo SeoValue `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// SeoValue 对应 swagger pubdef.SeoValue(导航分类 seoInfo)
|
||||
type SeoValue struct {
|
||||
Title string `json:"title"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// TranslationInfo 对应 swagger proto.TranslationInfo
|
||||
type TranslationInfo struct {
|
||||
Name string `json:"name"`
|
||||
URLValue string `json:"urlValue"`
|
||||
IsFriendAds bool `json:"isFriendAds"`
|
||||
}
|
||||
|
||||
// FooterContact 对应 swagger proto.FooterContact
|
||||
type FooterContact struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// ConfigInfoResp 对应 swagger proto.ConfigInfoResp
|
||||
// GET /api/app/config/info
|
||||
// reportSdkConfig 与 proto.ConfigInfo 对齐;上游若尚未下发则为空,可由本地 yaml 兜底。
|
||||
// config 为上游实际下发的完整站点配置(swagger 未完整声明,但含 potaoGroup 等字段)。
|
||||
type ConfigInfoResp struct {
|
||||
NewAddressInfo NewAddressInfo `json:"newAddressInfo"`
|
||||
Config ConfigInfo `json:"config"`
|
||||
Advitise AdvitiseData `json:"advitise"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
SeoNavInfo SeoNavInfo `json:"seoNavInfo"`
|
||||
NavPageList []NavPageItem `json:"navPageList"`
|
||||
ReportSDKConfig string `json:"reportSdkConfig"`
|
||||
}
|
||||
|
||||
// ConfigInfo 对应上游 data.config(proto.ConfigInfo 子集,按页面需要取用)。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/APP配置相关接口/get_api_app_config_info
|
||||
type ConfigInfo struct {
|
||||
AppAddr string `json:"appAddr"`
|
||||
OfficialAppAddr string `json:"officialAppAddr"`
|
||||
ForeverURLAddr string `json:"foreverUrlAddr"`
|
||||
NewURLAddr string `json:"newUrlAddr"`
|
||||
NewURLAddr1 string `json:"newUrlAddr1"`
|
||||
NewURLAddr2 string `json:"newUrlAddr2"`
|
||||
GmailAddr string `json:"gmailAddr"`
|
||||
QQ string `json:"qq"`
|
||||
WeiChart string `json:"weiChart"`
|
||||
TGGroup string `json:"tgGroup"`
|
||||
Telegram string `json:"telegram"`
|
||||
TelegramAdd string `json:"telegramAdd"`
|
||||
PotaoGroup string `json:"potaoGroup"`
|
||||
CustomerServiceHost string `json:"customerServiceHost"`
|
||||
PDFAddr string `json:"pdfAddr"`
|
||||
TwitterAddr string `json:"twitterAddr"`
|
||||
GitAddr string `json:"gitAddr"`
|
||||
FreeVPN string `json:"freeVPN"`
|
||||
}
|
||||
|
||||
// AdvitiseData 对应 GET /api/app/config/info 返回的完整 advitise 数据。
|
||||
type AdvitiseData struct {
|
||||
HomeList []Advertisement `json:"homeList"`
|
||||
DetailTopList []Advertisement `json:"detailTopList"`
|
||||
NineGridList []Advertisement `json:"nineGridList"`
|
||||
DetailBannerList []Advertisement `json:"detailbannerList"`
|
||||
LaunchScreenList []Advertisement `json:"launchScreenList"`
|
||||
FloatingList []Advertisement `json:"floatingList"`
|
||||
AList []Advertisement `json:"AList"`
|
||||
BList []Advertisement `json:"BList"`
|
||||
DialogList []Advertisement `json:"dialogList"`
|
||||
VideoInfoList []Advertisement `json:"videoInfoList"`
|
||||
VideoList []Advertisement `json:"videoList"`
|
||||
DownList []Advertisement `json:"downList"`
|
||||
FriendList []Advertisement `json:"friendList"`
|
||||
HomeBanner []Advertisement `json:"homeBanner"`
|
||||
BottomBanner []Advertisement `json:"bottomBanner"`
|
||||
TitleList []Advertisement `json:"titleList"`
|
||||
PlayList []Advertisement `json:"playList"`
|
||||
PauseyList []Advertisement `json:"pauseyList"`
|
||||
}
|
||||
|
||||
// Advertisement 对应 swagger proxy.AdvitiseInfo。
|
||||
type Advertisement struct {
|
||||
ID string `json:"id"`
|
||||
Images string `json:"images"`
|
||||
Link string `json:"link"`
|
||||
Location int `json:"location"`
|
||||
Title string `json:"title"`
|
||||
AdvertiseCode string `json:"advertiseCode"`
|
||||
AdvertiseLocationCode string `json:"advertiseLocationCode"`
|
||||
AdvertiseLocationName string `json:"advertiseLocationName"`
|
||||
}
|
||||
|
||||
// NewAddressInfo 对应 swagger proto.NewAddressInfo(GET /api/app/config/info)。
|
||||
type NewAddressInfo struct {
|
||||
NewURLAddr string `json:"newUrlAddr"`
|
||||
NewURLAddr1 string `json:"newUrlAddr1"`
|
||||
NewURLAddr2 string `json:"newUrlAddr2"`
|
||||
ForeverURLAddr string `json:"foreverUrlAddr"`
|
||||
GmailAddr string `json:"gmailAddr"`
|
||||
QQ string `json:"qq"`
|
||||
TGGroup string `json:"tgGroup"`
|
||||
Telegram string `json:"telegram"`
|
||||
PotaoGroup string `json:"potaoGroup"`
|
||||
CustomerServiceHost string `json:"customerServiceHost"`
|
||||
AppAddr string `json:"appAddr"`
|
||||
OfficialAppAddr string `json:"officialAppAddr"`
|
||||
PDFAddr string `json:"pdfAddr"`
|
||||
TwitterAddr string `json:"twitterAddr"`
|
||||
}
|
||||
|
||||
// SeoInfo 对应 swagger proto.SeoInfo
|
||||
type SeoInfo struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Keywords []string `json:"keywords"`
|
||||
CanonicalURL string `json:"canonicalUrl"`
|
||||
WebSiteName string `json:"webSiteName"`
|
||||
}
|
||||
|
||||
// SeoNavInfo 对应 swagger proto.SeoNavInfo
|
||||
// GET /api/app/config/info 的 seoNavInfo。
|
||||
type SeoNavInfo struct {
|
||||
DhTitle string `json:"dhTitle"`
|
||||
DhDescription string `json:"dhDescription"`
|
||||
DhKeyworld []string `json:"dhKeyworld"`
|
||||
DhSiteName string `json:"dhSiteName"`
|
||||
}
|
||||
|
||||
// NavPageItem 对应 swagger proto.NavPageItem(GET /api/app/config/info 的 navPageList)。
|
||||
type NavPageItem struct {
|
||||
Line int `json:"line"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// AppRichTextItemResp 对应 swagger proto.AppRichTextItemResp
|
||||
// GET /api/app/config/richtext/list
|
||||
type AppRichTextItemResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Desc string `json:"desc"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// AppRichTextListResp 对应 swagger proto.AppRichTextListResp
|
||||
type AppRichTextListResp struct {
|
||||
List []AppRichTextItemResp `json:"list"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AppConfigVM 注入全站页面的公共配置(字段名与接口 newAddressInfo / seoInfo 对齐)。
|
||||
type AppConfigVM struct {
|
||||
// Config 为 data.config 原样字段(页脚社交等优先用此对象)。
|
||||
Config ConfigInfo `json:"config"`
|
||||
ForeverURLAddr string `json:"foreverUrlAddr"`
|
||||
NewURLAddr string `json:"newUrlAddr"`
|
||||
NewURLAddr1 string `json:"newUrlAddr1"`
|
||||
NewURLAddr2 string `json:"newUrlAddr2"`
|
||||
GmailAddr string `json:"gmailAddr"`
|
||||
QQ string `json:"qq"`
|
||||
TGGroup string `json:"tgGroup"`
|
||||
Telegram string `json:"telegram"`
|
||||
TelegramAdd string `json:"telegramAdd"`
|
||||
PotaoGroup string `json:"potaoGroup"`
|
||||
CustomerServiceHost string `json:"customerServiceHost"`
|
||||
AppAddr string `json:"appAddr"`
|
||||
OfficialAppAddr string `json:"officialAppAddr"`
|
||||
PDFAddr string `json:"pdfAddr"`
|
||||
TwitterAddr string `json:"twitterAddr"`
|
||||
GitAddr string `json:"gitAddr"`
|
||||
FreeVPN string `json:"freeVPN"`
|
||||
SeoInfo SeoInfo `json:"seoInfo,omitempty"`
|
||||
SeoNavInfo SeoNavInfo `json:"seoNavInfo,omitempty"`
|
||||
NavPageList []NavPageItem `json:"navPageList,omitempty"`
|
||||
ReportSDKConfig string `json:"reportSdkConfig,omitempty"`
|
||||
Advitise AdvitiseData `json:"advitise"`
|
||||
DetailTopList []Advertisement `json:"detailTopList,omitempty"`
|
||||
NineGridList []Advertisement `json:"nineGridList,omitempty"`
|
||||
DetailBannerList []Advertisement `json:"detailbannerList,omitempty"`
|
||||
BottomBanner []Advertisement `json:"bottomBanner,omitempty"`
|
||||
DialogList []Advertisement `json:"dialogList,omitempty"`
|
||||
}
|
||||
|
||||
// BuildAppConfigVM 从上游配置组装前端可用字段;社交/地址优先取 data.config。
|
||||
func BuildAppConfigVM(info *ConfigInfoResp) AppConfigVM {
|
||||
if info == nil {
|
||||
return AppConfigVM{}
|
||||
}
|
||||
cfg := sanitizeConfigInfo(info.Config)
|
||||
addr := info.NewAddressInfo
|
||||
return AppConfigVM{
|
||||
Config: cfg,
|
||||
ForeverURLAddr: firstNonEmptyURL(cfg.ForeverURLAddr, addr.ForeverURLAddr),
|
||||
NewURLAddr: firstNonEmptyURL(cfg.NewURLAddr, addr.NewURLAddr),
|
||||
NewURLAddr1: firstNonEmptyURL(cfg.NewURLAddr1, addr.NewURLAddr1),
|
||||
NewURLAddr2: firstNonEmptyURL(cfg.NewURLAddr2, addr.NewURLAddr2),
|
||||
GmailAddr: firstNonEmpty(normalizeEmailAddress(cfg.GmailAddr), normalizeEmailAddress(addr.GmailAddr)),
|
||||
QQ: firstNonEmpty(cfg.QQ, strings.TrimSpace(addr.QQ)),
|
||||
TGGroup: firstNonEmptyURL(cfg.TGGroup, addr.TGGroup),
|
||||
Telegram: firstNonEmptyURL(cfg.Telegram, addr.Telegram),
|
||||
TelegramAdd: sanitizeURL(cfg.TelegramAdd),
|
||||
PotaoGroup: firstNonEmptyURL(cfg.PotaoGroup, addr.PotaoGroup),
|
||||
CustomerServiceHost: firstNonEmptyURL(cfg.CustomerServiceHost, addr.CustomerServiceHost),
|
||||
AppAddr: firstNonEmptyURL(cfg.AppAddr, addr.AppAddr),
|
||||
OfficialAppAddr: firstNonEmptyURL(cfg.OfficialAppAddr, addr.OfficialAppAddr),
|
||||
PDFAddr: firstNonEmptyURL(cfg.PDFAddr, addr.PDFAddr),
|
||||
TwitterAddr: firstNonEmptyURL(cfg.TwitterAddr, addr.TwitterAddr),
|
||||
GitAddr: sanitizeURL(cfg.GitAddr),
|
||||
FreeVPN: sanitizeURL(cfg.FreeVPN),
|
||||
ReportSDKConfig: strings.TrimSpace(info.ReportSDKConfig),
|
||||
NavPageList: buildNavPageList(info.NavPageList),
|
||||
Advitise: info.Advitise,
|
||||
DetailTopList: buildAdvertisementList(info.Advitise.DetailTopList),
|
||||
NineGridList: buildAdvertisementList(info.Advitise.NineGridList),
|
||||
DetailBannerList: buildAdvertisementList(info.Advitise.DetailBannerList),
|
||||
BottomBanner: buildAdvertisementList(info.Advitise.BottomBanner),
|
||||
DialogList: buildAdvertisementList(info.Advitise.DialogList),
|
||||
SeoInfo: SeoInfo{
|
||||
Title: strings.TrimSpace(info.SeoInfo.Title),
|
||||
Description: strings.TrimSpace(info.SeoInfo.Description),
|
||||
Keywords: info.SeoInfo.Keywords,
|
||||
CanonicalURL: strings.TrimSpace(info.SeoInfo.CanonicalURL),
|
||||
WebSiteName: strings.TrimSpace(info.SeoInfo.WebSiteName),
|
||||
},
|
||||
SeoNavInfo: SeoNavInfo{
|
||||
DhTitle: strings.TrimSpace(info.SeoNavInfo.DhTitle),
|
||||
DhDescription: strings.TrimSpace(info.SeoNavInfo.DhDescription),
|
||||
DhKeyworld: info.SeoNavInfo.DhKeyworld,
|
||||
DhSiteName: strings.TrimSpace(info.SeoNavInfo.DhSiteName),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeConfigInfo(cfg ConfigInfo) ConfigInfo {
|
||||
return ConfigInfo{
|
||||
AppAddr: sanitizeURL(cfg.AppAddr),
|
||||
OfficialAppAddr: sanitizeURL(cfg.OfficialAppAddr),
|
||||
ForeverURLAddr: sanitizeURL(cfg.ForeverURLAddr),
|
||||
NewURLAddr: sanitizeURL(cfg.NewURLAddr),
|
||||
NewURLAddr1: sanitizeURL(cfg.NewURLAddr1),
|
||||
NewURLAddr2: sanitizeURL(cfg.NewURLAddr2),
|
||||
GmailAddr: normalizeEmailAddress(cfg.GmailAddr),
|
||||
QQ: strings.TrimSpace(cfg.QQ),
|
||||
WeiChart: strings.TrimSpace(cfg.WeiChart),
|
||||
TGGroup: sanitizeURL(cfg.TGGroup),
|
||||
Telegram: sanitizeURL(cfg.Telegram),
|
||||
TelegramAdd: sanitizeURL(cfg.TelegramAdd),
|
||||
PotaoGroup: sanitizeURL(cfg.PotaoGroup),
|
||||
CustomerServiceHost: sanitizeURL(cfg.CustomerServiceHost),
|
||||
PDFAddr: sanitizeURL(cfg.PDFAddr),
|
||||
TwitterAddr: sanitizeURL(cfg.TwitterAddr),
|
||||
GitAddr: sanitizeURL(cfg.GitAddr),
|
||||
FreeVPN: sanitizeURL(cfg.FreeVPN),
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildAdvertisementList(list []Advertisement) []Advertisement {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Advertisement, 0, len(list))
|
||||
for _, item := range list {
|
||||
item.ID = strings.TrimSpace(item.ID)
|
||||
item.AdvertiseCode = strings.TrimSpace(item.AdvertiseCode)
|
||||
item.AdvertiseLocationCode = strings.TrimSpace(item.AdvertiseLocationCode)
|
||||
item.AdvertiseLocationName = strings.TrimSpace(item.AdvertiseLocationName)
|
||||
if item.AdvertiseCode != "" {
|
||||
item.ID = item.AdvertiseCode
|
||||
}
|
||||
item.Images = sanitizeURL(item.Images)
|
||||
item.Link = sanitizeURL(item.Link)
|
||||
item.Title = strings.TrimSpace(item.Title)
|
||||
if item.Images == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NineGridRow 按首页设计每行返回两个 banner,超出当前行时返回空切片。
|
||||
func (c AppConfigVM) NineGridRow(row int) []Advertisement {
|
||||
if row < 0 {
|
||||
return nil
|
||||
}
|
||||
start := row * 2
|
||||
if start >= len(c.NineGridList) {
|
||||
return nil
|
||||
}
|
||||
end := start + 2
|
||||
if end > len(c.NineGridList) {
|
||||
end = len(c.NineGridList)
|
||||
}
|
||||
return c.NineGridList[start:end]
|
||||
}
|
||||
|
||||
// QRText 应用下载二维码及跳转地址:仅使用应用下载地址 appAddr。
|
||||
func (c AppConfigVM) QRText() string {
|
||||
return strings.TrimSpace(c.AppAddr)
|
||||
}
|
||||
|
||||
func buildNavPageList(list []NavPageItem) []NavPageItem {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]NavPageItem, 0, len(list))
|
||||
for _, item := range list {
|
||||
u := sanitizeURL(item.URL)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, NavPageItem{Line: item.Line, URL: u})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (n NavPageItem) Label() string {
|
||||
digits := []string{"", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"}
|
||||
if n.Line >= 1 && n.Line <= 10 {
|
||||
return "线路" + digits[n.Line]
|
||||
}
|
||||
if n.Line > 0 {
|
||||
return "线路" + strconv.Itoa(n.Line)
|
||||
}
|
||||
return "线路"
|
||||
}
|
||||
|
||||
func sanitizeURL(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimRight(v, "\\")
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// normalizeEmailAddress 兼容上游把邮箱误配为 mailto/http(s) 地址的情况。
|
||||
func normalizeEmailAddress(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
for {
|
||||
lower := strings.ToLower(value)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "mailto:"):
|
||||
value = strings.TrimSpace(value[len("mailto:"):])
|
||||
case strings.HasPrefix(lower, "https://"):
|
||||
value = strings.TrimSpace(value[len("https://"):])
|
||||
case strings.HasPrefix(lower, "http://"):
|
||||
value = strings.TrimSpace(value[len("http://"):])
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyURL(values ...string) string {
|
||||
for _, v := range values {
|
||||
if u := sanitizeURL(v); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TelegramURL 客服/商务 TG:优先 telegram,其次 telegramAdd、tgGroup。
|
||||
func (c ConfigInfo) TelegramURL() string {
|
||||
return firstNonEmptyURL(c.Telegram, c.TelegramAdd, c.TGGroup)
|
||||
}
|
||||
|
||||
// TelegramHandle 从 TG 链接提取展示用 @用户名;无法解析时返回空。
|
||||
func (c ConfigInfo) TelegramHandle() string {
|
||||
raw := c.TelegramURL()
|
||||
raw = strings.TrimRight(raw, "/")
|
||||
if i := strings.LastIndex(raw, "/"); i >= 0 && i+1 < len(raw) {
|
||||
raw = raw[i+1:]
|
||||
}
|
||||
raw = strings.TrimPrefix(strings.TrimSpace(raw), "@")
|
||||
if raw == "" || strings.Contains(raw, ".") || strings.Contains(raw, "?") {
|
||||
return ""
|
||||
}
|
||||
return "@" + raw
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package feedbackser
|
||||
|
||||
// FeedbackReq 对应 swagger proto.FeedbackReq。
|
||||
// type(接口说明):1-功能建议、2-播放问题、3-账号问题、4-其他
|
||||
type FeedbackReq struct {
|
||||
Type int `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Contact string `json:"contact,omitempty"`
|
||||
Screenshots []string `json:"screenshots,omitempty"`
|
||||
}
|
||||
|
||||
// ContactUsAddReq 对应 swagger proto.ContactUsAddReq。
|
||||
type ContactUsAddReq struct {
|
||||
Type int `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Email string `json:"email"`
|
||||
Attachments []string `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
type ContactUsAddResp struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const boughtListPath = "/api/app/playlet/bought/list"
|
||||
|
||||
// PlayletBoughtData 对应 proto.PlayletBoughtData;只列出页面所需字段。
|
||||
type PlayletBoughtData struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PlayName string `json:"playName"`
|
||||
Slug string `json:"slug"`
|
||||
Cover string `json:"cover"`
|
||||
ThemeNames []string `json:"themeNames"`
|
||||
TotalEpisode int `json:"totalEpisode"`
|
||||
BoughtChapterCount int `json:"boughtChapterCount"`
|
||||
CostCoins int64 `json:"costCoins"`
|
||||
BoughtAt string `json:"boughtAt"`
|
||||
LastChapterID int64 `json:"lastChapterId"`
|
||||
LastEpisode int `json:"lastEpisode"`
|
||||
WatchedCount int `json:"watchedCount"`
|
||||
}
|
||||
|
||||
type PlayletBoughtListResp struct {
|
||||
List []PlayletBoughtData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
func FetchBoughtList(c *gin.Context, pageNum, pageSize int) (*PlayletBoughtListResp, int, error) {
|
||||
if pageNum < 1 {
|
||||
pageNum = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(pageNum))
|
||||
params.Set("pageSize", strconv.Itoa(pageSize))
|
||||
var env APIEnvelope[PlayletBoughtListResp]
|
||||
if err := upstream.GetJSON(c, boughtListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("playlet bought list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
categoryDetailPath = "/api/app/playlet/recommend/list"
|
||||
defaultCategoryPageSize = 25
|
||||
)
|
||||
|
||||
// CategoryDetailQuery 分类详情分页参数。categorySlug 非空时优先使用 slug。
|
||||
type CategoryDetailQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
CategoryID int64
|
||||
CategorySlug string
|
||||
}
|
||||
|
||||
func (q *CategoryDetailQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultCategoryPageSize
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
if q.CategoryID < 0 {
|
||||
q.CategoryID = 0
|
||||
}
|
||||
q.CategorySlug = strings.TrimSpace(q.CategorySlug)
|
||||
}
|
||||
|
||||
func categoryDetailListURL(q CategoryDetailQuery) string {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
if q.CategorySlug != "" {
|
||||
params.Set("categorySlug", q.CategorySlug)
|
||||
} else if q.CategoryID > 0 {
|
||||
params.Set("categoryId", strconv.FormatInt(q.CategoryID, 10))
|
||||
}
|
||||
return categoryDetailPath + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// FetchCategoryDetail 根据分类 slug 或 ID 获取分类详情列表。
|
||||
func FetchCategoryDetail(c *gin.Context, q CategoryDetailQuery) (*PlayletRecommendListResp, error) {
|
||||
var env APIEnvelope[PlayletRecommendListResp]
|
||||
if err := upstream.GetPublicJSON(c, categoryDetailListURL(q), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet category detail code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchCategoryDetailOrEmpty 保持页面可渲染,接口失败时返回当前分页的空结果。
|
||||
func FetchCategoryDetailOrEmpty(c *gin.Context, q CategoryDetailQuery) *PlayletRecommendListResp {
|
||||
q.Normalize()
|
||||
data, err := FetchCategoryDetail(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet category detail failed", log.E(err))
|
||||
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func categoryDetailPageURL(categoryID int64, categorySlug string, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/category"
|
||||
if segment := pathSegment(categorySlug, categoryID); segment != "" {
|
||||
path += "/" + segment
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func categoryDetailURL(categorySlug string, categoryID int64) string {
|
||||
return categoryDetailPageURL(categoryID, categorySlug, 1)
|
||||
}
|
||||
|
||||
// ParseCategoryDetailQuery 从语义化路由读取分类标识和页码。
|
||||
func ParseCategoryDetailQuery(identifier, pageStr string) CategoryDetailQuery {
|
||||
q := CategoryDetailQuery{PageSize: defaultCategoryPageSize}
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if id, err := strconv.ParseInt(identifier, 10, 64); err == nil && id > 0 {
|
||||
q.CategoryID = id
|
||||
} else {
|
||||
q.CategorySlug = identifier
|
||||
}
|
||||
if page, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = page
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
func CategoryDetailCanonicalPath(q CategoryDetailQuery) string {
|
||||
q.Normalize()
|
||||
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, q.PageNum)
|
||||
}
|
||||
|
||||
func BuildCategoryDetailPagination(q CategoryDetailQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(page int) string {
|
||||
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, page)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
func CategoryDetailPageTitle(categoryName string, pageNum int, seo SeoInfo) string {
|
||||
name := strings.TrimSpace(categoryName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(seo.Title)
|
||||
}
|
||||
if name == "" {
|
||||
name = "分类短剧"
|
||||
}
|
||||
return name + PageTitleSuffix(pageNum)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
chapterDetailPath = "/api/app/playlet/chapter/detail"
|
||||
chapterListPath = "/api/app/playlet/chapter/list"
|
||||
)
|
||||
|
||||
// PlayQuery 播放页参数。
|
||||
type PlayQuery struct {
|
||||
PlayletID int64
|
||||
PlayletSlug string
|
||||
ChapterID int64
|
||||
}
|
||||
|
||||
// ParsePlayQuery 解析播放页的路径参数。
|
||||
func ParsePlayQuery(playletKey, chapterIDRaw string) PlayQuery {
|
||||
q := PlayQuery{PlayletSlug: strings.TrimSpace(playletKey)}
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(playletKey), 10, 64); err == nil && n > 0 {
|
||||
q.PlayletID = n
|
||||
q.PlayletSlug = ""
|
||||
}
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(chapterIDRaw), 10, 64); err == nil && n > 0 {
|
||||
q.ChapterID = n
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// FetchChapterDetail 查询剧集播放详情。
|
||||
func FetchChapterDetail(c *gin.Context, chapterID, playletID int64, playletSlug string) (*PlayletChapterInfoResp, error) {
|
||||
if chapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter detail invalid chapterId")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("chapterId", strconv.FormatInt(chapterID, 10))
|
||||
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
|
||||
params.Set("playletSlug", playletSlug)
|
||||
} else if playletID > 0 {
|
||||
params.Set("playletId", strconv.FormatInt(playletID, 10))
|
||||
}
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
var env APIEnvelope[PlayletChapterInfoResp]
|
||||
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("chapter detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
env.Data.SeoInfo.Normalize()
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchChapterDetailOrNil 失败返回 nil。
|
||||
func FetchChapterDetailOrNil(c *gin.Context, chapterID, playletID int64, playletSlug string) *PlayletChapterInfoResp {
|
||||
data, err := FetchChapterDetail(c, chapterID, playletID, playletSlug)
|
||||
if err != nil {
|
||||
log.Error("fetch chapter detail failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
if data == nil || data.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchChapterList 根据短剧 slug 或 ID 查询选集列表,slug 优先。
|
||||
func FetchChapterList(c *gin.Context, playletID int64, playletSlug string) (*PlayletChapterListResp, error) {
|
||||
params := url.Values{}
|
||||
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
|
||||
params.Set("playletSlug", playletSlug)
|
||||
} else if playletID > 0 {
|
||||
params.Set("playletId", strconv.FormatInt(playletID, 10))
|
||||
} else {
|
||||
return nil, fmt.Errorf("chapter list missing playletSlug or playletId")
|
||||
}
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
var env APIEnvelope[PlayletChapterListResp]
|
||||
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet chapter list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// ResolvePlayChapterID 无 chapterId 时取第 1 集(currentEpisode=1),否则取列表首条。
|
||||
func ResolvePlayChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
|
||||
if chapterID > 0 {
|
||||
return chapterID
|
||||
}
|
||||
if playlet == nil {
|
||||
return 0
|
||||
}
|
||||
var firstID int64
|
||||
for _, ch := range playlet.PlayletChapterInfo {
|
||||
if ch.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if firstID == 0 {
|
||||
firstID = ch.ID
|
||||
}
|
||||
if ch.CurrentEpisode == 1 {
|
||||
return ch.ID
|
||||
}
|
||||
}
|
||||
return firstID
|
||||
}
|
||||
|
||||
// FindChapter 在详情选集中按 chapterId 查找。
|
||||
func FindChapter(playlet *PlayletInfoResp, chapterID int64) *PlayletChapterData {
|
||||
if playlet == nil || chapterID <= 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range playlet.PlayletChapterInfo {
|
||||
if playlet.PlayletChapterInfo[i].ID == chapterID {
|
||||
return &playlet.PlayletChapterInfo[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NextChapterID 返回下一集 chapterId;没有则 0。
|
||||
func NextChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
|
||||
if playlet == nil || chapterID <= 0 {
|
||||
return 0
|
||||
}
|
||||
found := false
|
||||
for _, ch := range playlet.PlayletChapterInfo {
|
||||
if found && ch.ID > 0 {
|
||||
return ch.ID
|
||||
}
|
||||
if ch.ID == chapterID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FirstUnlockCost 取第一个需金币解锁的单集价格。
|
||||
func FirstUnlockCost(chapters []PlayletChapterData) int {
|
||||
for _, ch := range chapters {
|
||||
if ch.NeedCoinGate() {
|
||||
return ch.UnlockCost()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ChapterFromList 将详情选集条目转成章节详情结构(chapter/detail 失败时的 UI 兜底)。
|
||||
func ChapterFromList(ch PlayletChapterData) *PlayletChapterInfoResp {
|
||||
return &PlayletChapterInfoResp{
|
||||
ID: ch.ID,
|
||||
PlayletID: ch.PlayletID,
|
||||
CurrentEpisode: ch.CurrentEpisode,
|
||||
Title: ch.Title,
|
||||
MD5: ch.MD5,
|
||||
Hash: ch.Hash,
|
||||
Height: ch.Height,
|
||||
Width: ch.Width,
|
||||
Ratio: ch.Ratio,
|
||||
VideoURL: ch.VideoURL,
|
||||
MediaSize: ch.MediaSize,
|
||||
MediaTime: ch.MediaTime,
|
||||
PayType: ch.PayType,
|
||||
SingleCoins: ch.SingleCoins,
|
||||
CostCoins: ch.CostCoins,
|
||||
Balance: ch.Balance,
|
||||
CanWatch: ch.CanWatch,
|
||||
IsUnlocked: ch.IsUnlocked,
|
||||
NeedUnlock: ch.NeedUnlock,
|
||||
NeedVip: ch.NeedVip,
|
||||
VipValid: ch.VipValid,
|
||||
}
|
||||
}
|
||||
|
||||
// AllowWatch 是否可播放(优先 canWatch;兼容仅返回 videoUrl 的旧数据)。
|
||||
func (c *PlayletChapterInfoResp) AllowWatch() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if c.CanWatch {
|
||||
return true
|
||||
}
|
||||
if c.NeedUnlock || c.NeedVip {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(c.VideoURL) != "" && c.PayType == 0
|
||||
}
|
||||
|
||||
// UnlockCost 金币解锁消耗:优先 costCoins。
|
||||
func (c *PlayletChapterInfoResp) UnlockCost() int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
if c.CostCoins > 0 {
|
||||
return c.CostCoins
|
||||
}
|
||||
if c.SingleCoins > 0 {
|
||||
return int64(c.SingleCoins)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AllowWatch 详情选集是否可直达播放(优先 canWatch)。
|
||||
func (c PlayletChapterData) AllowWatch() bool {
|
||||
if c.CanWatch {
|
||||
return true
|
||||
}
|
||||
if c.NeedUnlock || c.NeedVip {
|
||||
return false
|
||||
}
|
||||
if c.IsUnlocked {
|
||||
return true
|
||||
}
|
||||
return c.PayType == 0
|
||||
}
|
||||
|
||||
// NeedCoinGate 是否弹出金币解锁。
|
||||
func (c PlayletChapterData) NeedCoinGate() bool {
|
||||
if c.AllowWatch() {
|
||||
return false
|
||||
}
|
||||
if c.NeedUnlock {
|
||||
return true
|
||||
}
|
||||
return c.PayType == 1
|
||||
}
|
||||
|
||||
// NeedVipGate 是否弹出 VIP 开通。
|
||||
func (c PlayletChapterData) NeedVipGate() bool {
|
||||
if c.AllowWatch() || c.NeedCoinGate() {
|
||||
return false
|
||||
}
|
||||
if c.NeedVip {
|
||||
return true
|
||||
}
|
||||
return c.PayType == 2
|
||||
}
|
||||
|
||||
// UnlockCost 金币解锁消耗:优先 costCoins。
|
||||
func (c PlayletChapterData) UnlockCost() int {
|
||||
if c.CostCoins > 0 {
|
||||
return int(c.CostCoins)
|
||||
}
|
||||
return c.SingleCoins
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const chapterCollectListPath = "/api/app/playlet/chapter/collect/list"
|
||||
|
||||
// PlayletChapterCollectData 对应 swagger proto.PlayletChapterCollectData
|
||||
type PlayletChapterCollectData struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
PlayletSlug string `json:"playletSlug"`
|
||||
PlayletTitle string `json:"playletTitle"`
|
||||
PlayletCover string `json:"playletCover"`
|
||||
CurrentEpisode int `json:"currentEpisode"`
|
||||
TotalEpisode int `json:"totalEpisode"`
|
||||
UpdateStatus int `json:"updateStatus"`
|
||||
CountBrowse int64 `json:"countBrowse"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
CollectedAt string `json:"collectedAt"`
|
||||
MediaTime int `json:"mediaTime"`
|
||||
PayType int `json:"payType"`
|
||||
SingleCoins int `json:"singleCoins"`
|
||||
VideoURL string `json:"videoUrl"`
|
||||
}
|
||||
|
||||
// PlayletChapterCollectListResp 对应 swagger proto.PlayletChapterCollectListResp
|
||||
type PlayletChapterCollectListResp struct {
|
||||
List []PlayletChapterCollectData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// FetchChapterCollectList 拉取当前用户剧集收藏列表。
|
||||
// 第二个返回值为上游业务 code(网络错误时为 0)。
|
||||
func FetchChapterCollectList(c *gin.Context, q CollectListQuery) (*PlayletChapterCollectListResp, int, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[PlayletChapterCollectListResp]
|
||||
if err := upstream.GetJSON(c, chapterCollectListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("playlet chapter collect list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
|
||||
// FetchChapterCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
|
||||
func FetchChapterCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletChapterCollectListResp {
|
||||
data, code, err := FetchChapterCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
data, _, err = FetchChapterCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
log.Error("fetch playlet chapter collect list failed", log.E(err))
|
||||
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
chapterLikeSetPath = "/api/app/playlet/chapter/like/set"
|
||||
chapterCollectSetPath = "/api/app/playlet/chapter/collect/set"
|
||||
)
|
||||
|
||||
// PlayletChapterLikeSetReq 对应 swagger proto.PlayletChapterLikeSetReq
|
||||
type PlayletChapterLikeSetReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
IsLike bool `json:"isLike"`
|
||||
}
|
||||
|
||||
// PlayletChapterLikeSetResp 对应 swagger proto.PlayletChapterLikeSetResp
|
||||
type PlayletChapterLikeSetResp struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsLike int `json:"isLike"`
|
||||
}
|
||||
|
||||
// ChapterLikeSet 设置剧集点赞/取消点赞。
|
||||
func ChapterLikeSet(c *gin.Context, req PlayletChapterLikeSetReq) (*APIEnvelope[PlayletChapterLikeSetResp], error) {
|
||||
if req.ChapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter like invalid chapterId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterLikeSetResp]
|
||||
if _, err := upstream.PostJSON(c, chapterLikeSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// PlayletChapterCollectSetReq 对应 swagger proto.PlayletChapterCollectSetReq
|
||||
type PlayletChapterCollectSetReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
IsCollect bool `json:"isCollect"`
|
||||
}
|
||||
|
||||
// PlayletChapterCollectSetResp 对应 swagger proto.PlayletChapterCollectSetResp
|
||||
type PlayletChapterCollectSetResp struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
}
|
||||
|
||||
// ChapterCollectSet 设置剧集收藏/取消收藏。
|
||||
func ChapterCollectSet(c *gin.Context, req PlayletChapterCollectSetReq) (*APIEnvelope[PlayletChapterCollectSetResp], error) {
|
||||
if req.ChapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter collect invalid chapterId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterCollectSetResp]
|
||||
if _, err := upstream.PostJSON(c, chapterCollectSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const chapterUnlockPath = "/api/app/playlet/chapter/unlock"
|
||||
|
||||
// PlayletChapterUnlockReq 对应 swagger proto.PlayletChapterUnlockReq
|
||||
// POST /api/app/playlet/chapter/unlock
|
||||
type PlayletChapterUnlockReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
}
|
||||
|
||||
// PlayletChapterUnlockResp 对应 swagger proto.PlayletChapterUnlockResp
|
||||
type PlayletChapterUnlockResp struct {
|
||||
Balance int64 `json:"balance"`
|
||||
CanWatch bool `json:"canWatch"`
|
||||
ChangeCoin int64 `json:"changeCoin"`
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
CostCoins int64 `json:"costCoins"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
Unlocked bool `json:"unlocked"`
|
||||
}
|
||||
|
||||
// ChapterUnlock 使用金币解锁剧集(幂等)。
|
||||
func ChapterUnlock(c *gin.Context, req PlayletChapterUnlockReq) (*APIEnvelope[PlayletChapterUnlockResp], error) {
|
||||
if req.ChapterID <= 0 || req.PlayletID <= 0 {
|
||||
return nil, fmt.Errorf("chapter unlock invalid chapterId/playletId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterUnlockResp]
|
||||
if _, err := upstream.PostJSON(c, chapterUnlockPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const chapterWatchReportPath = "/api/app/playlet/chapter/watch/report"
|
||||
|
||||
// PlayletChapterWatchReportReq 对应 swagger proto.PlayletChapterWatchReportReq
|
||||
type PlayletChapterWatchReportReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId,omitempty"`
|
||||
}
|
||||
|
||||
// PlayletChapterWatchReportResp 对应 swagger proto.PlayletChapterWatchReportResp
|
||||
type PlayletChapterWatchReportResp struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
CurrentEpisode int `json:"currentEpisode"`
|
||||
LastChapterID int64 `json:"lastChapterId"`
|
||||
LastEpisode int `json:"lastEpisode"`
|
||||
WatchedCount int `json:"watchedCount"`
|
||||
UnwatchedCount int `json:"unwatchedCount"`
|
||||
}
|
||||
|
||||
// ChapterWatchReport 上报短剧剧集观看。
|
||||
func ChapterWatchReport(c *gin.Context, req PlayletChapterWatchReportReq) (*APIEnvelope[PlayletChapterWatchReportResp], error) {
|
||||
if req.ChapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter watch report invalid chapterId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterWatchReportResp]
|
||||
if _, err := upstream.PostJSON(c, chapterWatchReportPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
collectSetPath = "/api/app/playlet/collect/set"
|
||||
collectListPath = "/api/app/playlet/collect/list"
|
||||
defaultCollectPageSize = 40
|
||||
|
||||
CollectTypePlaylet = "playlet"
|
||||
CollectTypeChapter = "chapter"
|
||||
)
|
||||
|
||||
// PlayletCollectSetReq 对应 swagger proto.PlayletCollectSetReq
|
||||
type PlayletCollectSetReq struct {
|
||||
ID int64 `json:"id"`
|
||||
IsCollect bool `json:"isCollect"`
|
||||
}
|
||||
|
||||
// PlayletCollectSetResp 对应 swagger proto.PlayletCollectSetResp
|
||||
type PlayletCollectSetResp struct {
|
||||
ID int64 `json:"id"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
}
|
||||
|
||||
// PlayletCollectData 对应 swagger proto.PlayletCollectData
|
||||
type PlayletCollectData struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PlayName string `json:"playName"`
|
||||
Slug string `json:"slug"`
|
||||
Cover string `json:"cover"`
|
||||
Desc string `json:"desc"`
|
||||
TagIDs []int64 `json:"tagIds"`
|
||||
TagNames []string `json:"tagNames"`
|
||||
TagSlugs []string `json:"tagSlugs"`
|
||||
ThemeIDs []int64 `json:"themeIds"`
|
||||
ThemeNames []string `json:"themeNames"`
|
||||
ThemeSlugs []string `json:"themeSlugs"`
|
||||
TotalEpisode int `json:"totalEpisode"`
|
||||
UpdateStatus int `json:"updateStatus"`
|
||||
CountBrowse int64 `json:"countBrowse"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsFeatured bool `json:"isFeatured"`
|
||||
IsNew bool `json:"isNew"`
|
||||
IsRecommend bool `json:"isRecommend"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
CollectedAt string `json:"collectedAt"`
|
||||
WatchedCount int `json:"watchedCount"`
|
||||
UnwatchedCount int `json:"unwatchedCount"`
|
||||
LastChapterID int64 `json:"lastChapterId"`
|
||||
LastEpisode int `json:"lastEpisode"`
|
||||
}
|
||||
|
||||
// PlayletCollectListResp 对应 swagger proto.PlayletCollectListResp
|
||||
type PlayletCollectListResp struct {
|
||||
List []PlayletCollectData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// CollectListQuery 收藏列表分页参数。
|
||||
type CollectListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
WatchStatus int // 0 全部、1 追剧中、2 已看完;仅短剧收藏列表使用
|
||||
Type string // playlet | chapter
|
||||
}
|
||||
|
||||
// NormalizeCollectType 归一化收藏类型。
|
||||
func NormalizeCollectType(raw string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(raw)) {
|
||||
case CollectTypeChapter, "2", "episode", "ep":
|
||||
return CollectTypeChapter
|
||||
default:
|
||||
return CollectTypePlaylet
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *CollectListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultCollectPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.WatchStatus < 0 || q.WatchStatus > 2 {
|
||||
q.WatchStatus = 0
|
||||
}
|
||||
q.Type = NormalizeCollectType(q.Type)
|
||||
}
|
||||
|
||||
// CollectSet 设置短剧收藏/取消收藏。
|
||||
func CollectSet(c *gin.Context, req PlayletCollectSetReq) (*APIEnvelope[PlayletCollectSetResp], error) {
|
||||
if req.ID <= 0 {
|
||||
return nil, fmt.Errorf("playlet collect invalid id")
|
||||
}
|
||||
var env APIEnvelope[PlayletCollectSetResp]
|
||||
if _, err := upstream.PostJSON(c, collectSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchCollectList 拉取当前用户短剧收藏列表。
|
||||
// 第二个返回值为上游业务 code(网络错误时为 0)。
|
||||
func FetchCollectList(c *gin.Context, q CollectListQuery) (*PlayletCollectListResp, int, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("watchStatus", strconv.Itoa(q.WatchStatus))
|
||||
|
||||
var env APIEnvelope[PlayletCollectListResp]
|
||||
if err := upstream.GetJSON(c, collectListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("playlet collect list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
|
||||
// FetchCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
|
||||
func FetchCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletCollectListResp {
|
||||
data, code, err := FetchCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
data, _, err = FetchCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
log.Error("fetch playlet collect list failed", log.E(err))
|
||||
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func collectPageURL(pageNum int, collectType string) string {
|
||||
collectType = NormalizeCollectType(collectType)
|
||||
path := "/collect"
|
||||
if collectType != CollectTypePlaylet {
|
||||
path += "/" + url.PathEscape(collectType)
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func CollectCanonicalPath(q CollectListQuery) string {
|
||||
q.Normalize()
|
||||
return collectPageURL(q.PageNum, q.Type)
|
||||
}
|
||||
|
||||
// BuildCollectPagination 收藏列表分页。
|
||||
func BuildCollectPagination(q CollectListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return collectPageURL(p, q.Type)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// ParseCollectListQuery 从 query 解析分页与类型(playlet 短剧 / chapter 剧集)。
|
||||
func ParseCollectListQuery(pageStr, typeStr string) CollectListQuery {
|
||||
q := CollectListQuery{
|
||||
PageSize: defaultCollectPageSize,
|
||||
Type: NormalizeCollectType(typeStr),
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// collectProgressText 进度文案:优先最近观看集,其次未看/已看完。
|
||||
func collectProgressText(item PlayletCollectData) string {
|
||||
if item.LastEpisode > 0 {
|
||||
return fmt.Sprintf("第%d集", item.LastEpisode)
|
||||
}
|
||||
if item.TotalEpisode > 0 && item.UnwatchedCount <= 0 {
|
||||
return "已看完"
|
||||
}
|
||||
if item.UnwatchedCount > 0 {
|
||||
return fmt.Sprintf("%d集未看", item.UnwatchedCount)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// collectTagText 收藏卡片标签:仅展示 TagNames 第一项,无标签时不显示。
|
||||
func collectTagText(item PlayletCollectData) string {
|
||||
return firstName(item.TagNames)
|
||||
}
|
||||
|
||||
// chapterCollectEpisodeLabel 剧集收藏角标:总集数。
|
||||
func chapterCollectEpisodeLabel(item PlayletChapterCollectData) string {
|
||||
return episodeText(item.TotalEpisode)
|
||||
}
|
||||
|
||||
// chapterCollectDesc 剧集收藏副标题。
|
||||
func chapterCollectDesc(item PlayletChapterCollectData) string {
|
||||
if t := strings.TrimSpace(item.ChapterTitle); t != "" {
|
||||
return t
|
||||
}
|
||||
if item.CurrentEpisode > 0 {
|
||||
return fmt.Sprintf("第%d集", item.CurrentEpisode)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// chapterCollectMeta 剧集收藏右侧文案:第 N 集。
|
||||
func chapterCollectMeta(item PlayletChapterCollectData) string {
|
||||
if item.CurrentEpisode > 0 {
|
||||
return fmt.Sprintf("第%d集", item.CurrentEpisode)
|
||||
}
|
||||
return updateStatusText(item.UpdateStatus)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
guessLikeListPath = "/api/app/playlet/guess-like/list"
|
||||
defaultGuessLikeSize = 6
|
||||
)
|
||||
|
||||
// PlayletGuessLikeListResp 对应 swagger proto.PlayletGuessLikeListResp。
|
||||
// GET /api/app/playlet/guess-like/list
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_guess_like_list
|
||||
type PlayletGuessLikeListResp struct {
|
||||
List []CommonData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// GuessLikeQuery 猜你喜欢分页参数。
|
||||
type GuessLikeQuery struct {
|
||||
PlayletID int64
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *GuessLikeQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultGuessLikeSize
|
||||
}
|
||||
if q.PageSize > 30 {
|
||||
q.PageSize = 30
|
||||
}
|
||||
}
|
||||
|
||||
// ParseGuessLikePage 解析播放页 /recommendations/page/:guessPage。
|
||||
func ParseGuessLikePage(c *gin.Context) int {
|
||||
n, err := strconv.Atoi(c.Param("guessPage"))
|
||||
if err != nil || n <= 0 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// FetchGuessLikeList 分页查询猜你喜欢。
|
||||
func FetchGuessLikeList(c *gin.Context, q GuessLikeQuery) (*PlayletGuessLikeListResp, error) {
|
||||
q.Normalize()
|
||||
if q.PlayletID <= 0 {
|
||||
return nil, fmt.Errorf("guess-like missing playletId")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("playletId", strconv.FormatInt(q.PlayletID, 10))
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[PlayletGuessLikeListResp]
|
||||
if err := upstream.GetPublicJSON(c, guessLikeListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet guess-like list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchGuessLikeListOrEmpty 失败时返回空列表。
|
||||
func FetchGuessLikeListOrEmpty(c *gin.Context, q GuessLikeQuery) *PlayletGuessLikeListResp {
|
||||
q.Normalize()
|
||||
data, err := FetchGuessLikeList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet guess-like list failed", log.E(err))
|
||||
return &PlayletGuessLikeListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletGuessLikeListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data.PageNum <= 0 {
|
||||
data.PageNum = q.PageNum
|
||||
}
|
||||
if data.PageSize <= 0 {
|
||||
data.PageSize = q.PageSize
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// GuessLikeTotalPages 总页数。
|
||||
func GuessLikeTotalPages(pageSize int, totalCount int64) int {
|
||||
if pageSize <= 0 || totalCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||
}
|
||||
|
||||
// GuessLikeNextPage 下一页;到末页后回到第 1 页。
|
||||
func GuessLikeNextPage(pageNum, pageSize int, totalCount int64) int {
|
||||
totalPages := GuessLikeTotalPages(pageSize, totalCount)
|
||||
if totalPages <= 1 {
|
||||
return 1
|
||||
}
|
||||
if pageNum < 1 {
|
||||
pageNum = 1
|
||||
}
|
||||
next := pageNum + 1
|
||||
if next > totalPages {
|
||||
return 1
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// GuessLikePageURL 播放页猜你喜欢分页链接(换一批)。
|
||||
func GuessLikePageURL(slug string, playletID, chapterID int64, pageNum int) string {
|
||||
base := chapterPlayURL(slug, playletID, chapterID)
|
||||
if base == "#" {
|
||||
return base
|
||||
}
|
||||
if pageNum <= 1 {
|
||||
return base
|
||||
}
|
||||
return base + "/recommendations/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
historyListPath = "/api/app/playlet/history/list"
|
||||
historyClearPath = "/api/app/playlet/history/clear"
|
||||
historyDeletePath = "/api/app/playlet/history/delete"
|
||||
defaultHistoryPageSize = 40
|
||||
)
|
||||
|
||||
// PlayletHistoryData 对应 swagger proto.PlayletHistoryData
|
||||
// GET /api/app/playlet/history/list
|
||||
type PlayletHistoryData struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
IsFeatured bool `json:"isFeatured"`
|
||||
IsNew bool `json:"isNew"`
|
||||
IsRecommend bool `json:"isRecommend"`
|
||||
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"`
|
||||
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"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
WatchedCount int `json:"watchedCount"`
|
||||
UnwatchedCount int `json:"unwatchedCount"`
|
||||
LastChapterID int64 `json:"lastChapterId"`
|
||||
LastEpisode int `json:"lastEpisode"`
|
||||
IsFinished bool `json:"isFinished"`
|
||||
HistoryAt string `json:"historyAt"`
|
||||
}
|
||||
|
||||
// PlayletHistoryListResp 对应 swagger proto.PlayletHistoryListResp
|
||||
type PlayletHistoryListResp struct {
|
||||
List []PlayletHistoryData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// PlayletHistoryClearResp 对应 swagger proto.PlayletHistoryClearResp
|
||||
type PlayletHistoryClearResp struct {
|
||||
DeletedCount int `json:"deletedCount"`
|
||||
}
|
||||
|
||||
// PlayletHistoryDeleteReq 对应 swagger proto.PlayletHistoryDeleteReq
|
||||
type PlayletHistoryDeleteReq struct {
|
||||
PlayletID int64 `json:"playletId"`
|
||||
}
|
||||
|
||||
// PlayletHistoryDeleteResp 对应 swagger proto.PlayletHistoryDeleteResp
|
||||
type PlayletHistoryDeleteResp struct {
|
||||
DeletedCount int `json:"deletedCount"`
|
||||
}
|
||||
|
||||
// HistoryListQuery 观看历史分页参数。
|
||||
type HistoryListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *HistoryListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultHistoryPageSize
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
}
|
||||
|
||||
// FetchHistoryList 分页查询当前用户观看历史。
|
||||
// 第二个返回值为上游业务 code(网络错误时为 0)。
|
||||
func FetchHistoryList(c *gin.Context, q HistoryListQuery) (*PlayletHistoryListResp, int, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[PlayletHistoryListResp]
|
||||
if err := upstream.GetJSON(c, historyListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("playlet history list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
|
||||
// FetchHistoryListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
|
||||
func FetchHistoryListOrEmpty(c *gin.Context, q HistoryListQuery) *PlayletHistoryListResp {
|
||||
data, code, err := FetchHistoryList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
data, _, err = FetchHistoryList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
log.Error("fetch playlet history list failed", log.E(err))
|
||||
return &PlayletHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
|
||||
// HistoryClear 清空当前用户短剧观看历史。
|
||||
// POST /api/app/playlet/history/clear,无请求体。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_clear
|
||||
func HistoryClear(c *gin.Context) (*APIEnvelope[PlayletHistoryClearResp], error) {
|
||||
var env APIEnvelope[PlayletHistoryClearResp]
|
||||
if _, err := upstream.PostJSON(c, historyClearPath, nil, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// HistoryDelete 删除单条短剧观看历史。
|
||||
// POST /api/app/playlet/history/delete
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/post_api_app_playlet_history_delete
|
||||
func HistoryDelete(c *gin.Context, req PlayletHistoryDeleteReq) (*APIEnvelope[PlayletHistoryDeleteResp], error) {
|
||||
if req.PlayletID <= 0 {
|
||||
return nil, fmt.Errorf("playlet history delete invalid playletId")
|
||||
}
|
||||
var env APIEnvelope[PlayletHistoryDeleteResp]
|
||||
if _, err := upstream.PostJSON(c, historyDeletePath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func historyPageURL(pageNum int) string {
|
||||
if pageNum <= 1 {
|
||||
return "/history"
|
||||
}
|
||||
return "/history/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
|
||||
func HistoryCanonicalPath(q HistoryListQuery) string {
|
||||
q.Normalize()
|
||||
return historyPageURL(q.PageNum)
|
||||
}
|
||||
|
||||
// BuildHistoryPagination 观看历史分页。
|
||||
func BuildHistoryPagination(q HistoryListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return historyPageURL(p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// ParseHistoryListQuery 从 query 解析分页。
|
||||
func ParseHistoryListQuery(pageStr string) HistoryListQuery {
|
||||
q := HistoryListQuery{PageSize: defaultHistoryPageSize}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
func historyFinished(item PlayletHistoryData) bool {
|
||||
if item.IsFinished {
|
||||
return true
|
||||
}
|
||||
return item.TotalEpisode > 0 && item.UnwatchedCount <= 0 && item.WatchedCount > 0
|
||||
}
|
||||
|
||||
// historyStatus 卡片筛选状态:finished / unfinished。
|
||||
func historyStatus(item PlayletHistoryData) string {
|
||||
if historyFinished(item) {
|
||||
return "finished"
|
||||
}
|
||||
return "unfinished"
|
||||
}
|
||||
|
||||
// historyProgressText PC 卡片未看文案,使用接口 unwatchedCount / isFinished。
|
||||
func historyProgressText(item PlayletHistoryData) string {
|
||||
if historyFinished(item) {
|
||||
return "已看完"
|
||||
}
|
||||
if item.UnwatchedCount > 0 {
|
||||
return fmt.Sprintf("%d集未看", item.UnwatchedCount)
|
||||
}
|
||||
if item.LastEpisode > 0 {
|
||||
return fmt.Sprintf("看到第%d集", item.LastEpisode)
|
||||
}
|
||||
return "未看完"
|
||||
}
|
||||
|
||||
// historyWatchLine H5 进度行。
|
||||
func historyWatchLine(item PlayletHistoryData) string {
|
||||
if historyFinished(item) {
|
||||
if item.LastEpisode > 0 {
|
||||
return fmt.Sprintf("第%d集 · 已看完", item.LastEpisode)
|
||||
}
|
||||
return "已看完"
|
||||
}
|
||||
ep := "未开始"
|
||||
if item.LastEpisode > 0 {
|
||||
ep = fmt.Sprintf("第%d集", item.LastEpisode)
|
||||
}
|
||||
if item.UnwatchedCount > 0 {
|
||||
return ep + fmt.Sprintf(" · 还有%d集未看", item.UnwatchedCount)
|
||||
}
|
||||
return ep + " · 未看完"
|
||||
}
|
||||
|
||||
// historyTagText 历史卡片标签:仅展示 TagNames 第一项,无标签时不显示。
|
||||
func historyTagText(item PlayletHistoryData) string {
|
||||
return firstName(item.TagNames)
|
||||
}
|
||||
|
||||
// historyPlayURL 继续播放:优先最近观看剧集。
|
||||
func historyPlayURL(item PlayletHistoryData) string {
|
||||
return chapterPlayURL(item.Slug, item.ID, item.LastChapterID)
|
||||
}
|
||||
|
||||
// historyContinueMeta 首页「继续观看」文案:看至第 N 集 / 剩 M 集。
|
||||
func historyContinueMeta(item PlayletHistoryData) string {
|
||||
if item.LastEpisode <= 0 {
|
||||
return "观看记录"
|
||||
}
|
||||
meta := fmt.Sprintf("看至第 %d 集", item.LastEpisode)
|
||||
if item.UnwatchedCount > 0 {
|
||||
meta += fmt.Sprintf(" 剩 %d 集", item.UnwatchedCount)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// historyProgressPct 观看进度百分比,供卡片 --p 使用。
|
||||
func historyProgressPct(item PlayletHistoryData) string {
|
||||
progress := 0
|
||||
if item.TotalEpisode > 0 {
|
||||
progress = item.WatchedCount * 100 / item.TotalEpisode
|
||||
}
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
if progress > 100 {
|
||||
progress = 100
|
||||
}
|
||||
return fmt.Sprintf("%d%%", progress)
|
||||
}
|
||||
|
||||
// FormatHistoryAt 最近观看时间展示。
|
||||
func FormatHistoryAt(raw string) string {
|
||||
return formatHistoryAt(raw)
|
||||
}
|
||||
|
||||
// FormatHistoryListTime 观看历史列表时间:今天/昨天带时刻,更早仅日期。
|
||||
func FormatHistoryListTime(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
t, ok := parseHistoryTime(raw)
|
||||
if !ok {
|
||||
return raw
|
||||
}
|
||||
now := time.Now()
|
||||
startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
dayDiff := int(startToday.Sub(startThat).Hours() / 24)
|
||||
hm := t.Format("15:04")
|
||||
switch {
|
||||
case dayDiff <= 0:
|
||||
return "今天 " + hm
|
||||
case dayDiff == 1:
|
||||
return "昨天 " + hm
|
||||
default:
|
||||
return fmt.Sprintf("%d 月 %d 日", int(t.Month()), t.Day())
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryDayBucket 列表分组标题:今天 / 昨天 / 更早。
|
||||
func HistoryDayBucket(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
t, ok := parseHistoryTime(raw)
|
||||
if !ok {
|
||||
return "更早"
|
||||
}
|
||||
now := time.Now()
|
||||
startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
dayDiff := int(startToday.Sub(startThat).Hours() / 24)
|
||||
switch {
|
||||
case dayDiff <= 0:
|
||||
return "今天"
|
||||
case dayDiff == 1:
|
||||
return "昨天"
|
||||
default:
|
||||
return "更早"
|
||||
}
|
||||
}
|
||||
|
||||
func formatHistoryAt(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
t, ok := parseHistoryTime(raw)
|
||||
if !ok {
|
||||
return raw
|
||||
}
|
||||
now := time.Now()
|
||||
startToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
startThat := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
dayDiff := int(startToday.Sub(startThat).Hours() / 24)
|
||||
hm := t.Format("15:04")
|
||||
switch {
|
||||
case dayDiff <= 0:
|
||||
return "今天 " + hm
|
||||
case dayDiff == 1:
|
||||
return "昨天 " + hm
|
||||
case dayDiff == 2:
|
||||
return "前天 " + hm
|
||||
case dayDiff < 7:
|
||||
return fmt.Sprintf("%d天前 %s", dayDiff, hm)
|
||||
case dayDiff < 14:
|
||||
return "一周前 " + hm
|
||||
default:
|
||||
return fmt.Sprintf("%d 月 %d 日", int(t.Month()), t.Day())
|
||||
}
|
||||
}
|
||||
|
||||
func parseHistoryTime(raw string) (time.Time, bool) {
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05.000",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
|
||||
return t, true
|
||||
}
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t.In(time.Local), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// ParseHistoryTime 解析观看历史时间字符串。
|
||||
func ParseHistoryTime(raw string) (time.Time, bool) {
|
||||
return parseHistoryTime(strings.TrimSpace(raw))
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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([]CommonData(nil), src.HomeBannerData...)
|
||||
}
|
||||
if src.HomeConfigData != nil {
|
||||
out.HomeConfigData = make([]HomeConfigData, len(src.HomeConfigData))
|
||||
for i, cfg := range src.HomeConfigData {
|
||||
out.HomeConfigData[i] = cfg
|
||||
if cfg.SeoInfo.Keywords != nil {
|
||||
out.HomeConfigData[i].SeoInfo.Keywords = append([]string(nil), cfg.SeoInfo.Keywords...)
|
||||
}
|
||||
if cfg.List != nil {
|
||||
out.HomeConfigData[i].List = append([]CommonData(nil), cfg.List...)
|
||||
}
|
||||
if cfg.ThemeList != nil {
|
||||
out.HomeConfigData[i].ThemeList = make([]HomeConfigThemeData, len(cfg.ThemeList))
|
||||
for j, theme := range cfg.ThemeList {
|
||||
out.HomeConfigData[i].ThemeList[j] = theme
|
||||
if theme.List != nil {
|
||||
out.HomeConfigData[i].ThemeList[j].List = append([]CommonData(nil), theme.List...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.VideoList != nil {
|
||||
out.HomeConfigData[i].VideoList = append([]VideoItemResp(nil), cfg.VideoList...)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 过滤未启用 banner;无 showOnBanner 标记时保留全部。
|
||||
func ActiveHomeBanners(list []CommonData) []CommonData {
|
||||
if len(list) == 0 {
|
||||
return list
|
||||
}
|
||||
hasFlag := false
|
||||
for _, item := range list {
|
||||
if item.ShowOnBanner {
|
||||
hasFlag = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasFlag {
|
||||
return append([]CommonData(nil), list...)
|
||||
}
|
||||
out := make([]CommonData, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item.ShowOnBanner {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ActiveHomeConfigs 仅保留上架配置,按接口顺序。
|
||||
func ActiveHomeConfigs(list []HomeConfigData) []HomeConfigData {
|
||||
if len(list) == 0 {
|
||||
return list
|
||||
}
|
||||
out := make([]HomeConfigData, 0, len(list))
|
||||
for _, item := range list {
|
||||
if !item.Status {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HomeConfigsByType 上架且 type 匹配的首页配置(保持接口顺序)。
|
||||
func HomeConfigsByType(list []HomeConfigData, typ int) []HomeConfigData {
|
||||
out := make([]HomeConfigData, 0, len(list))
|
||||
for _, item := range ActiveHomeConfigs(list) {
|
||||
if item.Type == typ {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FirstHomeConfigSEOByType 返回首页第一个已上架指定类型配置的 TDK。
|
||||
// 专题更多页没有配置 ID 路径,因此按首页实际展示顺序匹配首个专题配置。
|
||||
func FirstHomeConfigSEOByType(list []HomeConfigData, typ int) (SeoInfo, bool) {
|
||||
for _, item := range ActiveHomeConfigs(list) {
|
||||
if item.Type != typ {
|
||||
continue
|
||||
}
|
||||
seo := item.SeoInfo
|
||||
seo.Normalize()
|
||||
return seo, true
|
||||
}
|
||||
return SeoInfo{}, false
|
||||
}
|
||||
|
||||
// FindHomeConfigByIdentity 按路由携带的 ID 或 slug 精确查找已上架首页配置。
|
||||
func FindHomeConfigByIdentity(list []HomeConfigData, id int64, slug string) (HomeConfigData, bool) {
|
||||
slug = strings.TrimSpace(slug)
|
||||
for _, item := range ActiveHomeConfigs(list) {
|
||||
if id > 0 {
|
||||
if item.ID == id {
|
||||
return item, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slug != "" && strings.EqualFold(strings.TrimSpace(item.Slug), slug) {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return HomeConfigData{}, false
|
||||
}
|
||||
|
||||
// FindHomeConfigByName 按名称查找上架首页配置(精确匹配 trim 后名称)。
|
||||
func FindHomeConfigByName(list []HomeConfigData, name string) (HomeConfigData, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return HomeConfigData{}, false
|
||||
}
|
||||
for _, item := range ActiveHomeConfigs(list) {
|
||||
if strings.TrimSpace(item.Name) == name {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return HomeConfigData{}, false
|
||||
}
|
||||
|
||||
// MoreListURLByHomeConfigName 生成指定首页配置区块的更多页链接。
|
||||
func MoreListURLByHomeConfigName(c *gin.Context, name string) string {
|
||||
cfg, ok := FindHomeConfigByName(FetchHomeOrEmpty(c).HomeConfigData, name)
|
||||
if !ok || cfg.ID <= 0 {
|
||||
return "/more-list"
|
||||
}
|
||||
return MoreListURL(cfg.Slug, cfg.ID, 0, "", 0, 1)
|
||||
}
|
||||
|
||||
var navMegaIcons = []string{
|
||||
"/static/images/nav_trend.svg",
|
||||
"/static/images/nav_zap.svg",
|
||||
"/static/images/nav_star.svg",
|
||||
"/static/images/nav_grid.svg",
|
||||
}
|
||||
|
||||
// NavMegaIcon 快捷入口图标,按序号循环。
|
||||
func NavMegaIcon(index int) string {
|
||||
if len(navMegaIcons) == 0 {
|
||||
return "/static/images/nav_star.svg"
|
||||
}
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
return navMegaIcons[index%len(navMegaIcons)]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"videoweb/app/service/configser"
|
||||
)
|
||||
|
||||
const homeAdsPerBlock = 2
|
||||
|
||||
// AdSlice 从 detailbannerList 按偏移取 n 条;用尽后从头循环。仅做切片,不改字段名。
|
||||
func AdSlice(list []configser.Advertisement, offset, n int) []configser.Advertisement {
|
||||
ads := usableHomeAds(list)
|
||||
if len(ads) == 0 || n <= 0 {
|
||||
return nil
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
out := make([]configser.Advertisement, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, ads[(offset+i)%len(ads)])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func usableHomeAds(list []configser.Advertisement) []configser.Advertisement {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]configser.Advertisement, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item.Images == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 部猜你喜欢短剧。
|
||||
// 文档:GET /api/app/playlet/hot-like → proto.PlayletHotLikeResp
|
||||
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
|
||||
}
|
||||
|
||||
// FetchHotLikeOrEmpty 失败时返回空热搜与猜你喜欢。
|
||||
func FetchHotLikeOrEmpty(c *gin.Context) *PlayletHotLikeResp {
|
||||
data, err := FetchHotLike(c)
|
||||
if err != nil || data == nil {
|
||||
return &PlayletHotLikeResp{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
hotRankPath = "/api/app/playlet/hot/rank/list"
|
||||
|
||||
defaultHotRankPageSize = 24
|
||||
|
||||
// hot/rank/list rankType:1-热播榜,2-新剧榜,3-追剧榜
|
||||
hotRankHot = 1
|
||||
hotRankNew = 2
|
||||
hotRankBinge = 3
|
||||
)
|
||||
|
||||
// HotRankQuery 热榜查询参数。
|
||||
type HotRankQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
RankType int
|
||||
}
|
||||
|
||||
// Normalize 校正分页与榜单类型。
|
||||
func (q *HotRankQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultHotRankPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
switch q.RankType {
|
||||
case hotRankNew, hotRankBinge:
|
||||
default:
|
||||
q.RankType = hotRankHot
|
||||
}
|
||||
}
|
||||
|
||||
// FetchHotRankList 分页查询短剧热榜。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_hot_rank_list
|
||||
func FetchHotRankList(c *gin.Context, q HotRankQuery) (*PlayletHotRankListResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("rankType", strconv.Itoa(q.RankType))
|
||||
|
||||
var env APIEnvelope[PlayletHotRankListResp]
|
||||
if err := upstream.GetPublicJSON(c, hotRankPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet hot rank list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchHotRankListOrEmpty 失败时返回空榜单。
|
||||
func FetchHotRankListOrEmpty(c *gin.Context, q HotRankQuery) *PlayletHotRankListResp {
|
||||
data, err := FetchHotRankList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet hot rank list failed", log.E(err))
|
||||
return &PlayletHotRankListResp{PageNum: q.PageNum, PageSize: q.PageSize, RankType: q.RankType}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletHotRankListResp{PageNum: q.PageNum, PageSize: q.PageSize, RankType: q.RankType}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
if data.RankType == 0 {
|
||||
data.RankType = q.RankType
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func hotRankPageURL(rankType, pageNum int) string {
|
||||
switch rankType {
|
||||
case hotRankNew, hotRankBinge:
|
||||
default:
|
||||
rankType = hotRankHot
|
||||
}
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/popular-recommend"
|
||||
if rankType != hotRankHot {
|
||||
path += "/" + strconv.Itoa(rankType)
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// HotRankPageURL 生成热榜页链接(供模板使用)。
|
||||
func HotRankPageURL(rankType, pageNum int) string {
|
||||
return hotRankPageURL(rankType, pageNum)
|
||||
}
|
||||
|
||||
// BuildHotRankTabs 热榜类型 Tab。
|
||||
func BuildHotRankTabs(q HotRankQuery) []FilterChipVM {
|
||||
q.Normalize()
|
||||
return []FilterChipVM{
|
||||
{Label: "热播榜", URL: hotRankPageURL(hotRankHot, 1), Active: q.RankType == hotRankHot},
|
||||
{Label: "新剧榜", URL: hotRankPageURL(hotRankNew, 1), Active: q.RankType == hotRankNew},
|
||||
{Label: "追剧榜", URL: hotRankPageURL(hotRankBinge, 1), Active: q.RankType == hotRankBinge},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildHotRankPagination 热榜分页。
|
||||
func BuildHotRankPagination(q HotRankQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return hotRankPageURL(q.RankType, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// HotRankCanonicalPath 返回热榜筛选和分页对应的唯一规范路径。
|
||||
func HotRankCanonicalPath(q HotRankQuery) string {
|
||||
q.Normalize()
|
||||
return hotRankPageURL(q.RankType, q.PageNum)
|
||||
}
|
||||
|
||||
// ParseHotRankQuery 从路径参数解析热榜筛选。
|
||||
// 路由:/popular-recommend/ 、/popular-recommend/page/:page/ 、/popular-recommend/:rankType/ 、/popular-recommend/:rankType/page/:page/
|
||||
func ParseHotRankQuery(c *gin.Context) HotRankQuery {
|
||||
q := HotRankQuery{PageSize: defaultHotRankPageSize}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("rankType"))); err == nil {
|
||||
q.RankType = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("page"))); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// HotRankTabTitle 当前热榜标题。
|
||||
func HotRankTabTitle(rankType int) string {
|
||||
switch rankType {
|
||||
case hotRankNew:
|
||||
return "新剧榜"
|
||||
case hotRankBinge:
|
||||
return "追剧榜"
|
||||
default:
|
||||
return "热播榜"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const likeSetPath = "/api/app/playlet/like/set"
|
||||
|
||||
// PlayletLikeSetReq 对应 swagger proto.PlayletLikeSetReq
|
||||
type PlayletLikeSetReq struct {
|
||||
ID int64 `json:"id"`
|
||||
IsLike bool `json:"isLike"`
|
||||
}
|
||||
|
||||
// PlayletLikeSetResp 对应 swagger proto.PlayletLikeSetResp
|
||||
type PlayletLikeSetResp struct {
|
||||
ID int64 `json:"id"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsLike int `json:"isLike"`
|
||||
}
|
||||
|
||||
// LikeSet 设置短剧点赞/取消点赞。
|
||||
func LikeSet(c *gin.Context, req PlayletLikeSetReq) (*APIEnvelope[PlayletLikeSetResp], error) {
|
||||
if req.ID <= 0 {
|
||||
return nil, fmt.Errorf("playlet like invalid id")
|
||||
}
|
||||
var env APIEnvelope[PlayletLikeSetResp]
|
||||
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
listPath = "/api/app/playlet/list"
|
||||
allListPath = "/api/app/playlet/all/list"
|
||||
themePath = "/api/app/theme/list"
|
||||
|
||||
defaultListPageSize = 24
|
||||
|
||||
// all/list sortType:1最新 2最热 3收藏最多
|
||||
allSortNewest = 1
|
||||
allSortHeat = 2
|
||||
allSortCollect = 3
|
||||
|
||||
// all/list updateStatus:0全部 1连载中 2已完结
|
||||
allUpdateAll = 0
|
||||
allUpdateOngoing = 1
|
||||
allUpdateDone = 2
|
||||
|
||||
sortHeat = 1 // 热度优先(旧 playlet/list)
|
||||
sortTodayUpdate = 2 // 今日更新(旧 playlet/list)
|
||||
// 站点统一类型:1-非成人 2-成人(与 themeType 一致)。
|
||||
contentTypeNonAdult = 1
|
||||
contentTypeAdult = 2
|
||||
// playlet/list 上游 type 与站点定义相反:1-成人 2-非成人。
|
||||
upstreamListTypeAdult = 1
|
||||
upstreamListTypeNonAdult = 2
|
||||
)
|
||||
|
||||
// ListQuery 短剧列表查询参数(旧 playlet/list)。
|
||||
type ListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortOrder int
|
||||
ThemeID int64
|
||||
ThemeSlug string
|
||||
Type int
|
||||
}
|
||||
|
||||
// AllListQuery 对应 GET /api/app/playlet/all/list 查询参数。
|
||||
type AllListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortType int // 1最新 2最热 3收藏最多
|
||||
ThemeID int64
|
||||
ThemeSlug string
|
||||
UpdateStatus int // 0全部 1连载中 2已完结
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Normalize 校正 all/list 查询参数。
|
||||
func (q *AllListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultListPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
switch q.SortType {
|
||||
case allSortHeat, allSortCollect:
|
||||
default:
|
||||
q.SortType = allSortNewest
|
||||
}
|
||||
switch q.UpdateStatus {
|
||||
case allUpdateOngoing, allUpdateDone:
|
||||
default:
|
||||
q.UpdateStatus = allUpdateAll
|
||||
}
|
||||
if q.ThemeID < 0 {
|
||||
q.ThemeID = 0
|
||||
}
|
||||
q.ThemeSlug = strings.TrimSpace(q.ThemeSlug)
|
||||
}
|
||||
|
||||
// ParseAllListQuery 从路径参数解析 all/list 筛选。
|
||||
// 路由:/list/ 、/list/:theme/ 、/list/:theme/:updateStatus/:sortType/ 、
|
||||
//
|
||||
// /list/:theme/:updateStatus/:sortType/page/:page/
|
||||
func ParseAllListQuery(c *gin.Context) AllListQuery {
|
||||
q := AllListQuery{PageSize: defaultListPageSize}
|
||||
theme := strings.TrimSpace(c.Param("theme"))
|
||||
if theme != "" {
|
||||
if decoded, err := url.PathUnescape(theme); err == nil {
|
||||
theme = strings.TrimSpace(decoded)
|
||||
}
|
||||
}
|
||||
if theme != "" && !strings.EqualFold(theme, "all") {
|
||||
if id, err := strconv.ParseInt(theme, 10, 64); err == nil && id > 0 {
|
||||
q.ThemeID = id
|
||||
} else {
|
||||
q.ThemeSlug = theme
|
||||
}
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("updateStatus"))); err == nil {
|
||||
q.UpdateStatus = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("sortType"))); err == nil {
|
||||
q.SortType = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("page"))); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// AllListURL 生成列表页筛选链接(纯路径,无 query)。
|
||||
func AllListURL(themeSlug string, themeID int64, updateStatus, sortType, pageNum int) string {
|
||||
q := AllListQuery{
|
||||
PageNum: pageNum,
|
||||
PageSize: defaultListPageSize,
|
||||
SortType: sortType,
|
||||
ThemeID: themeID,
|
||||
ThemeSlug: themeSlug,
|
||||
UpdateStatus: updateStatus,
|
||||
}
|
||||
q.Normalize()
|
||||
return q.URL()
|
||||
}
|
||||
|
||||
// URL 输出路径模式:/list/{theme}/{updateStatus}/{sortType}/[page/{n}/]
|
||||
// theme 优先 slug,无 slug 时才用 id;都没有则为 all。
|
||||
func (q AllListQuery) URL() string {
|
||||
q.Normalize()
|
||||
slug := strings.TrimSpace(q.ThemeSlug)
|
||||
id := q.ThemeID
|
||||
if slug != "" {
|
||||
id = 0
|
||||
}
|
||||
segment := pathSegment(slug, id)
|
||||
if segment == "" {
|
||||
segment = "all"
|
||||
}
|
||||
defaults := q.UpdateStatus == allUpdateAll && q.SortType == allSortNewest
|
||||
if defaults && q.PageNum <= 1 {
|
||||
if segment == "all" {
|
||||
return "/list"
|
||||
}
|
||||
return "/list/" + segment
|
||||
}
|
||||
path := "/list/" + segment + "/" + strconv.Itoa(q.UpdateStatus) + "/" + strconv.Itoa(q.SortType)
|
||||
if q.PageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(q.PageNum)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// ResolveAllListTheme 用题材列表补全 slug / id:路径与跳转优先 slug。
|
||||
func ResolveAllListTheme(q *AllListQuery, themes []PlayletThemeItem) {
|
||||
if q == nil {
|
||||
return
|
||||
}
|
||||
q.Normalize()
|
||||
slug := strings.TrimSpace(q.ThemeSlug)
|
||||
if slug != "" {
|
||||
for _, t := range themes {
|
||||
if strings.TrimSpace(t.Slug) == slug {
|
||||
q.ThemeID = t.ID
|
||||
q.ThemeSlug = strings.TrimSpace(t.Slug)
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if q.ThemeID > 0 {
|
||||
if found := FindThemeSlugByID(themes, q.ThemeID); found != "" {
|
||||
q.ThemeSlug = found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BuildAllListPagination 列表分页。
|
||||
func BuildAllListPagination(q AllListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
nq := q
|
||||
nq.PageNum = p
|
||||
return nq.URL()
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// FetchAllList 拉取 GET /api/app/playlet/all/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_all_list
|
||||
func FetchAllList(c *gin.Context, q AllListQuery) (*PlayletAllListResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortType", strconv.Itoa(q.SortType))
|
||||
params.Set("updateStatus", strconv.Itoa(q.UpdateStatus))
|
||||
if q.ThemeSlug != "" {
|
||||
params.Set("themeSlug", q.ThemeSlug)
|
||||
} else if q.ThemeID > 0 {
|
||||
params.Set("themeId", strconv.FormatInt(q.ThemeID, 10))
|
||||
}
|
||||
|
||||
var env APIEnvelope[PlayletAllListResp]
|
||||
if err := upstream.GetPublicJSON(c, allListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet all list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchAllListOrEmpty 失败时返回空列表。
|
||||
func FetchAllListOrEmpty(c *gin.Context, q AllListQuery) *PlayletAllListResp {
|
||||
data, err := FetchAllList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet all list failed", log.E(err))
|
||||
return &PlayletAllListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletAllListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
|
||||
// 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 ActiveThemes(list)
|
||||
}
|
||||
|
||||
// ActiveThemes 仅保留上架题材,按 rank 降序(越大越靠前)。
|
||||
func ActiveThemes(list []PlayletThemeItem) []PlayletThemeItem {
|
||||
out := make([]PlayletThemeItem, 0, len(list))
|
||||
for _, item := range list {
|
||||
if !item.Status {
|
||||
continue
|
||||
}
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
item.Slug = strings.TrimSpace(item.Slug)
|
||||
if item.Name == "" || item.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Rank != out[j].Rank {
|
||||
return out[i].Rank > out[j].Rank
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PaginationVM 分页条数据(页面 UI,非接口字段映射)。
|
||||
type PaginationVM struct {
|
||||
Visible bool
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevURL string
|
||||
NextURL string
|
||||
Page int
|
||||
TotalPages int
|
||||
TotalText string
|
||||
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)
|
||||
id := themeID
|
||||
if cleanSlug != "" {
|
||||
id = 0
|
||||
}
|
||||
defaultSort := sortHeat
|
||||
if cleanSlug == "ai-short-play" || cleanSlug == "ai-series" {
|
||||
defaultSort = sortTodayUpdate
|
||||
}
|
||||
if id <= 0 && cleanSlug == "" && sortOrder == sortHeat && pageNum == 1 {
|
||||
return "/list"
|
||||
}
|
||||
path := "/list"
|
||||
segment := pathSegment(cleanSlug, id)
|
||||
if segment == "" {
|
||||
segment = "all"
|
||||
}
|
||||
path += "/" + segment + "/" + strconv.Itoa(sortOrder)
|
||||
if id <= 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(int) string { return "#" }
|
||||
}
|
||||
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,
|
||||
TotalText: "共 " + strconv.FormatInt(totalCount, 10) + " 部",
|
||||
}
|
||||
}
|
||||
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,
|
||||
TotalText: "共 " + strconv.FormatInt(totalCount, 10) + " 部",
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
homeConfigDetailListPath = "/api/app/playlet/home/config/detail/list"
|
||||
defaultMoreListPageSize = 24
|
||||
|
||||
// timeRange:0综合 1本周 2本月
|
||||
moreTimeAll = 0
|
||||
moreTimeWeek = 1
|
||||
moreTimeMonth = 2
|
||||
|
||||
// operateType type=1:0全部 1短剧 2漫剧
|
||||
moreOperateAll = 0
|
||||
moreOperateShort = 1
|
||||
moreOperateComic = 2
|
||||
)
|
||||
|
||||
// MoreListQuery 首页配置详情列表查询参数。
|
||||
type MoreListQuery struct {
|
||||
HomeConfigID int64
|
||||
HomeConfigSlug string
|
||||
PageNum int
|
||||
PageSize int
|
||||
TimeRange int
|
||||
ThemeID int64
|
||||
ThemeSlug string
|
||||
OperateType int
|
||||
}
|
||||
|
||||
// Normalize 校正分页与筛选默认值。
|
||||
func (q *MoreListQuery) Normalize() {
|
||||
if q.HomeConfigID < 0 {
|
||||
q.HomeConfigID = 0
|
||||
}
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultMoreListPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
switch q.TimeRange {
|
||||
case moreTimeWeek, moreTimeMonth:
|
||||
default:
|
||||
q.TimeRange = moreTimeAll
|
||||
}
|
||||
switch q.OperateType {
|
||||
case moreOperateShort, moreOperateComic:
|
||||
default:
|
||||
q.OperateType = moreOperateAll
|
||||
}
|
||||
if q.ThemeID < 0 {
|
||||
q.ThemeID = 0
|
||||
}
|
||||
}
|
||||
|
||||
// FetchHomeConfigDetailList 分页查询首页配置详情列表。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home_config_detail_list
|
||||
func FetchHomeConfigDetailList(c *gin.Context, q MoreListQuery) (*HomeConfigDetailListResp, error) {
|
||||
q.Normalize()
|
||||
slug := strings.TrimSpace(q.HomeConfigSlug)
|
||||
if slug == "" && q.HomeConfigID <= 0 {
|
||||
return nil, fmt.Errorf("playlet home config detail list missing homeConfigSlug")
|
||||
}
|
||||
params := url.Values{}
|
||||
if slug != "" {
|
||||
params.Set("homeConfigSlug", slug)
|
||||
} else {
|
||||
params.Set("homeConfigId", strconv.FormatInt(q.HomeConfigID, 10))
|
||||
}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("timeRange", strconv.Itoa(q.TimeRange))
|
||||
params.Set("operateType", strconv.Itoa(q.OperateType))
|
||||
if slug := strings.TrimSpace(q.ThemeSlug); slug != "" && slug != "all" {
|
||||
params.Set("themeSlug", slug)
|
||||
} else if q.ThemeID > 0 {
|
||||
params.Set("themeId", strconv.FormatInt(q.ThemeID, 10))
|
||||
}
|
||||
|
||||
var env APIEnvelope[HomeConfigDetailListResp]
|
||||
if err := upstream.GetPublicJSON(c, homeConfigDetailListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet home config detail list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchHomeConfigDetailListOrEmpty 失败时返回空列表。
|
||||
func FetchHomeConfigDetailListOrEmpty(c *gin.Context, q MoreListQuery) *HomeConfigDetailListResp {
|
||||
data, err := FetchHomeConfigDetailList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet home config detail list failed", log.E(err))
|
||||
return &HomeConfigDetailListResp{
|
||||
HomeConfigID: q.HomeConfigID,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
if data == nil {
|
||||
return &HomeConfigDetailListResp{
|
||||
HomeConfigID: q.HomeConfigID,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
if data.HomeConfigID == 0 {
|
||||
data.HomeConfigID = q.HomeConfigID
|
||||
}
|
||||
if strings.TrimSpace(data.HomeConfigSlug) == "" {
|
||||
data.HomeConfigSlug = q.HomeConfigSlug
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
|
||||
// ApplyHomeConfigDetailIdentity 把接口回填的 ID/slug 写回查询参数。
|
||||
func ApplyHomeConfigDetailIdentity(q *MoreListQuery, raw *HomeConfigDetailListResp) {
|
||||
if q == nil || raw == nil {
|
||||
return
|
||||
}
|
||||
if raw.HomeConfigID > 0 {
|
||||
q.HomeConfigID = raw.HomeConfigID
|
||||
}
|
||||
if slug := strings.TrimSpace(raw.HomeConfigSlug); slug != "" {
|
||||
q.HomeConfigSlug = slug
|
||||
}
|
||||
}
|
||||
|
||||
// MoreListEntityValid 判断更多页对应的首页配置是否存在。
|
||||
// slug 路由下 URL 没有数字 ID,必须先回填再判定,否则会把有效配置打成 404。
|
||||
func MoreListEntityValid(q MoreListQuery, name string, total int64) bool {
|
||||
if q.HomeConfigID <= 0 && strings.TrimSpace(q.HomeConfigSlug) == "" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(name) != "" || total > 0
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func moreListPageURL(configSlug string, configID int64, timeRange int, themeSlug string, themeID int64, pageNum int) string {
|
||||
return moreConfigListPageURL("/more-list/", configSlug, configID, timeRange, themeSlug, themeID, pageNum)
|
||||
}
|
||||
|
||||
func moreSeriesListPageURL(configSlug string, configID int64, timeRange int, themeSlug string, themeID int64, pageNum int) string {
|
||||
return moreConfigListPageURL("/more-series-list/", configSlug, configID, timeRange, themeSlug, themeID, pageNum)
|
||||
}
|
||||
|
||||
func moreConfigListPageURL(prefix, configSlug string, configID int64, timeRange int, themeSlug string, themeID int64, pageNum int) string {
|
||||
configSeg := pathSegment(configSlug, configID)
|
||||
if configSeg == "" {
|
||||
return strings.TrimRight(prefix, "/")
|
||||
}
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
switch timeRange {
|
||||
case moreTimeWeek, moreTimeMonth:
|
||||
default:
|
||||
timeRange = moreTimeAll
|
||||
}
|
||||
slug := strings.TrimSpace(themeSlug)
|
||||
if slug == "all" {
|
||||
slug = ""
|
||||
themeID = 0
|
||||
}
|
||||
themeSeg := pathSegment(slug, themeID)
|
||||
if themeSeg == "" {
|
||||
themeSeg = "all"
|
||||
}
|
||||
|
||||
path := strings.TrimRight(prefix, "/") + "/" + configSeg
|
||||
defaults := timeRange == moreTimeAll && themeSeg == "all"
|
||||
if defaults && pageNum <= 1 {
|
||||
return path
|
||||
}
|
||||
if defaults {
|
||||
return path + "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
path += "/" + strconv.Itoa(timeRange) + "/" + themeSeg
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// MoreListURL 生成首页配置更多页链接(供模板使用),优先 slug。
|
||||
func MoreListURL(configSlug string, configID int64, timeRange int, themeSlug string, themeID int64, pageNum int) string {
|
||||
return moreListPageURL(configSlug, configID, timeRange, themeSlug, themeID, pageNum)
|
||||
}
|
||||
|
||||
// MoreSeriesListURL 生成首页影集配置更多页链接(type=2),优先 slug。
|
||||
func MoreSeriesListURL(configSlug string, configID int64, timeRange int, themeSlug string, themeID int64, pageNum int) string {
|
||||
return moreSeriesListPageURL(configSlug, configID, timeRange, themeSlug, themeID, pageNum)
|
||||
}
|
||||
|
||||
// MoreListCanonicalPath 返回更多页规范路径。
|
||||
func MoreListCanonicalPath(q MoreListQuery) string {
|
||||
q.Normalize()
|
||||
return moreListPageURL(q.HomeConfigSlug, q.HomeConfigID, q.TimeRange, q.ThemeSlug, q.ThemeID, q.PageNum)
|
||||
}
|
||||
|
||||
// MoreSeriesListCanonicalPath 返回影集更多页规范路径。
|
||||
func MoreSeriesListCanonicalPath(q MoreListQuery) string {
|
||||
q.Normalize()
|
||||
return moreSeriesListPageURL(q.HomeConfigSlug, q.HomeConfigID, q.TimeRange, q.ThemeSlug, q.ThemeID, q.PageNum)
|
||||
}
|
||||
|
||||
// BuildMoreListPagination 更多页分页。
|
||||
func BuildMoreListPagination(q MoreListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return moreListPageURL(q.HomeConfigSlug, q.HomeConfigID, q.TimeRange, q.ThemeSlug, q.ThemeID, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// BuildMoreSeriesListPagination 影集更多页分页。
|
||||
func BuildMoreSeriesListPagination(q MoreListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return moreSeriesListPageURL(q.HomeConfigSlug, q.HomeConfigID, q.TimeRange, q.ThemeSlug, q.ThemeID, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// BuildMoreTimeTabs 时间范围筛选。
|
||||
func BuildMoreTimeTabs(q MoreListQuery) []FilterChipVM {
|
||||
q.Normalize()
|
||||
return []FilterChipVM{
|
||||
{Label: "综合", URL: moreListPageURL(q.HomeConfigSlug, q.HomeConfigID, moreTimeAll, q.ThemeSlug, q.ThemeID, 1), Active: q.TimeRange == moreTimeAll},
|
||||
{Label: "本周", URL: moreListPageURL(q.HomeConfigSlug, q.HomeConfigID, moreTimeWeek, q.ThemeSlug, q.ThemeID, 1), Active: q.TimeRange == moreTimeWeek},
|
||||
{Label: "本月", URL: moreListPageURL(q.HomeConfigSlug, q.HomeConfigID, moreTimeMonth, q.ThemeSlug, q.ThemeID, 1), Active: q.TimeRange == moreTimeMonth},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildMoreSeriesTimeTabs 影集更多页时间范围筛选。
|
||||
func BuildMoreSeriesTimeTabs(q MoreListQuery) []FilterChipVM {
|
||||
q.Normalize()
|
||||
return []FilterChipVM{
|
||||
{Label: "综合", URL: moreSeriesListPageURL(q.HomeConfigSlug, q.HomeConfigID, moreTimeAll, q.ThemeSlug, q.ThemeID, 1), Active: q.TimeRange == moreTimeAll},
|
||||
{Label: "本周", URL: moreSeriesListPageURL(q.HomeConfigSlug, q.HomeConfigID, moreTimeWeek, q.ThemeSlug, q.ThemeID, 1), Active: q.TimeRange == moreTimeWeek},
|
||||
{Label: "本月", URL: moreSeriesListPageURL(q.HomeConfigSlug, q.HomeConfigID, moreTimeMonth, q.ThemeSlug, q.ThemeID, 1), Active: q.TimeRange == moreTimeMonth},
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveMoreListTheme 根据题材列表解析 slug/id。
|
||||
func ResolveMoreListTheme(q *MoreListQuery, themes []PlayletThemeItem) {
|
||||
if q == nil {
|
||||
return
|
||||
}
|
||||
slug := strings.TrimSpace(q.ThemeSlug)
|
||||
if slug == "" || slug == "all" {
|
||||
q.ThemeSlug = ""
|
||||
if q.ThemeID <= 0 {
|
||||
return
|
||||
}
|
||||
if found := FindThemeSlugByID(themes, q.ThemeID); found != "" {
|
||||
q.ThemeSlug = found
|
||||
q.ThemeID = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, theme := range themes {
|
||||
if strings.EqualFold(strings.TrimSpace(theme.Slug), slug) {
|
||||
q.ThemeSlug = theme.Slug
|
||||
q.ThemeID = 0
|
||||
return
|
||||
}
|
||||
}
|
||||
if n, err := strconv.ParseInt(slug, 10, 64); err == nil && n > 0 {
|
||||
q.ThemeID = n
|
||||
q.ThemeSlug = FindThemeSlugByID(themes, n)
|
||||
if q.ThemeSlug != "" {
|
||||
q.ThemeID = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseMoreListQuery 从路径参数解析更多页筛选。
|
||||
// 路由:/more-list/:configId/ 、/more-list/:configId/page/:page/ 、
|
||||
//
|
||||
// /more-list/:configId/:timeRange/:theme/ 、/more-list/:configId/:timeRange/:theme/page/:page/
|
||||
//
|
||||
// configId 优先按 slug 解析,纯数字时回退为首页配置 ID。
|
||||
func ParseMoreListQuery(c *gin.Context) MoreListQuery {
|
||||
q := MoreListQuery{PageSize: defaultMoreListPageSize}
|
||||
if raw := strings.TrimSpace(c.Param("configId")); raw != "" {
|
||||
if n, err := strconv.ParseInt(raw, 10, 64); err == nil && n > 0 {
|
||||
q.HomeConfigID = n
|
||||
} else {
|
||||
q.HomeConfigSlug = raw
|
||||
}
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("timeRange"))); err == nil {
|
||||
q.TimeRange = n
|
||||
}
|
||||
if theme := strings.TrimSpace(c.Param("theme")); theme != "" && theme != "all" {
|
||||
if n, err := strconv.ParseInt(theme, 10, 64); err == nil && n > 0 {
|
||||
q.ThemeID = n
|
||||
} else {
|
||||
q.ThemeSlug = theme
|
||||
}
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("page"))); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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 = 48
|
||||
|
||||
// 排行榜 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 分页查询短剧排行榜。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_rank
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
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 从路径参数解析排行榜筛选。
|
||||
// 路由:/rank/ 、/rank/page/:page/ 、/rank/:sortOrder/ 、/rank/:sortOrder/page/:page/
|
||||
func ParseRankQuery(c *gin.Context) RankQuery {
|
||||
q := RankQuery{PageSize: defaultRankPageSize}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("sortOrder"))); err == nil {
|
||||
q.SortOrder = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("page"))); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// RankTabTitle 当前榜单标题。
|
||||
func RankTabTitle(sortOrder int) string {
|
||||
switch sortOrder {
|
||||
case rankSortNew:
|
||||
return "新剧榜"
|
||||
case rankSortBinge:
|
||||
return "追剧榜"
|
||||
default:
|
||||
return "热播榜"
|
||||
}
|
||||
}
|
||||
|
||||
// RankNo 排名展示:01、02…
|
||||
func RankNo(n int) string {
|
||||
if n <= 0 {
|
||||
return "00"
|
||||
}
|
||||
return fmt.Sprintf("%02d", n)
|
||||
}
|
||||
|
||||
// IsRankBinge 是否追剧榜(热度展示用 countCollect)。
|
||||
func IsRankBinge(sortOrder int) bool {
|
||||
return sortOrder == rankSortBinge
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-detail"
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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"
|
||||
searchSuggestPath = "/api/app/playlet/search/suggest"
|
||||
defaultSearchPageSize = 25
|
||||
)
|
||||
|
||||
// SearchQuery 搜索分页参数(上游 query:keyword / pageNum / pageSize)。
|
||||
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 分页搜索短剧。
|
||||
// 文档:GET /api/app/playlet/search → proto.PlayletSearchResp
|
||||
func FetchSearch(c *gin.Context, q SearchQuery) (*PlayletSearchResp, 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[PlayletSearchResp], 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[PlayletSearchResp]
|
||||
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) *PlayletSearchResp {
|
||||
data, err := FetchSearch(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet search failed", log.E(err))
|
||||
return &PlayletSearchResp{Keyword: q.Keyword, PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletSearchResp{Keyword: q.Keyword, PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchSearchSuggest 搜索框下拉联想。
|
||||
// 文档:GET /api/app/playlet/search/suggest → proto.PlayletSearchSuggestResp
|
||||
// http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_search_suggest
|
||||
func FetchSearchSuggest(c *gin.Context, keyword string) (*PlayletSearchSuggestResp, error) {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return nil, fmt.Errorf("playlet search suggest keyword is empty")
|
||||
}
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
env, err := fetchSearchSuggestOnce(c, keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
env, err = fetchSearchSuggestOnce(c, keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet search suggest code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
func fetchSearchSuggestOnce(c *gin.Context, keyword string) (*APIEnvelope[PlayletSearchSuggestResp], error) {
|
||||
params := url.Values{}
|
||||
params.Set("keyword", keyword)
|
||||
var env APIEnvelope[PlayletSearchSuggestResp]
|
||||
if err := upstream.GetJSON(c, searchSuggestPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchSearchSuggestOrEmpty 失败时返回空列表。
|
||||
func FetchSearchSuggestOrEmpty(c *gin.Context, keyword string) *PlayletSearchSuggestResp {
|
||||
data, err := FetchSearchSuggest(c, keyword)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet search suggest failed", log.E(err))
|
||||
return &PlayletSearchSuggestResp{Keyword: strings.TrimSpace(keyword)}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletSearchSuggestResp{Keyword: strings.TrimSpace(keyword)}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"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)
|
||||
}
|
||||
marker := fmt.Sprintf("第%d页", pageNum)
|
||||
if base.Description != "" {
|
||||
if !strings.Contains(base.Description, marker) {
|
||||
base.Description = strings.TrimRight(base.Description, "。;; ") + " - " + marker
|
||||
}
|
||||
}
|
||||
keywordFound := false
|
||||
for _, keyword := range base.Keywords {
|
||||
if strings.EqualFold(strings.TrimSpace(keyword), marker) {
|
||||
keywordFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !keywordFound {
|
||||
base.Keywords = append(base.Keywords, 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
|
||||
}
|
||||
|
||||
titleValues := make([]string, 0, len(normalized))
|
||||
descriptionValues := make([]string, 0, len(normalized))
|
||||
keywords := append([]string(nil), base.Keywords...)
|
||||
for _, selection := range normalized {
|
||||
if !strings.Contains(base.Title, selection.Value) {
|
||||
titleValues = append(titleValues, selection.Value)
|
||||
}
|
||||
if !strings.Contains(base.Description, selection.Value) {
|
||||
descriptionValues = append(descriptionValues, selection.Value)
|
||||
}
|
||||
keywords = append(keywords, selection.Value)
|
||||
}
|
||||
base.Title = joinSEOParts(base.Title, titleValues)
|
||||
base.Description = joinSEOParts(base.Description, descriptionValues)
|
||||
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 优先,短剧详情作字段级回退。
|
||||
// description 追加剧名与集数,避免同一短剧的多个播放页生成重复描述。
|
||||
func ResolvePlayPageSEO(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) SeoInfo {
|
||||
var chapterSEO, playletSEO SeoInfo
|
||||
if chapter != nil {
|
||||
chapterSEO = chapter.SeoInfo
|
||||
}
|
||||
if playlet != nil {
|
||||
playletSEO = playlet.SeoInfo
|
||||
}
|
||||
seo := MergeSeoInfo(chapterSEO, playletSEO)
|
||||
seo.Description = resolvePlayPageDescription(seo.Description, chapter, playlet)
|
||||
return seo
|
||||
}
|
||||
|
||||
func resolvePlayPageDescription(description string, chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) string {
|
||||
description = strings.TrimSpace(description)
|
||||
if description == "" && playlet != nil {
|
||||
description = firstNonEmpty(playlet.Desc, playlet.Description)
|
||||
}
|
||||
if chapter == nil || chapter.CurrentEpisode <= 0 {
|
||||
return description
|
||||
}
|
||||
|
||||
episode := fmt.Sprintf("第%d集", chapter.CurrentEpisode)
|
||||
if strings.Contains(description, episode) {
|
||||
return description
|
||||
}
|
||||
|
||||
title := "本剧"
|
||||
if playlet != nil && strings.TrimSpace(playlet.Title) != "" {
|
||||
title = fmt.Sprintf("《%s》", strings.TrimSpace(playlet.Title))
|
||||
}
|
||||
suffix := fmt.Sprintf("%s%s高清在线观看。", title, episode)
|
||||
if description == "" {
|
||||
return "正在播放" + suffix
|
||||
}
|
||||
return strings.TrimRight(description, "。;;,, -") + ";" + suffix
|
||||
}
|
||||
|
||||
// ResolvePlayPageTitle 仅保留包含真实剧名的 SEO 标题,防止站点通用标题覆盖不同短剧。
|
||||
func ResolvePlayPageTitle(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) string {
|
||||
var chapterTitle, playletTitle, name string
|
||||
if chapter != nil {
|
||||
chapterTitle = strings.TrimSpace(chapter.SeoInfo.Title)
|
||||
}
|
||||
if playlet != nil {
|
||||
name = strings.TrimSpace(playlet.Title)
|
||||
playletTitle = strings.TrimSpace(playlet.SeoInfo.Title)
|
||||
}
|
||||
title := firstNonEmpty(name, "在线播放")
|
||||
for _, candidate := range []string{chapterTitle, playletTitle} {
|
||||
if candidate != "" && (name == "" || strings.Contains(candidate, name)) {
|
||||
title = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if chapter != nil && chapter.CurrentEpisode > 0 {
|
||||
// 剧名中的数字属于作品名称,只在 SEO 的额外文字中识别已有集数。
|
||||
if title != name {
|
||||
episodeText := strings.TrimSpace(strings.Replace(title, name, "", 1))
|
||||
for _, match := range playTitleEpisodePattern.FindAllStringSubmatch(episodeText, -1) {
|
||||
// 末尾独立数字仅在章节 SEO 中视为集数,避免误判短剧 SEO 中的年份。
|
||||
if match[3] != "" && title != chapterTitle {
|
||||
continue
|
||||
}
|
||||
n, _ := strconv.Atoi(firstNonEmpty(match[1], match[2], match[3]))
|
||||
if n == chapter.CurrentEpisode {
|
||||
return title
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s - 第%d集", title, chapter.CurrentEpisode)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
var playTitleEpisodePattern = regexp.MustCompile(`(?i)(?:第\s*([0-9]+)\s*集|\bEP\s*([0-9]+)\b|(?:^|\s)([0-9]+)$)`)
|
||||
@@ -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,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,155 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
tagListPath = "/api/app/playlet/tag/list"
|
||||
tagOptionsPath = "/api/app/playlet/tag/options"
|
||||
defaultTagSize = 12
|
||||
tagOptionsTTL = 60 * time.Second
|
||||
|
||||
// tag/list sortType:1最新 2最热 3收藏最多
|
||||
tagSortNewest = 1
|
||||
tagSortHeat = 2
|
||||
tagSortCollect = 3
|
||||
)
|
||||
|
||||
var (
|
||||
tagOptionsMu sync.Mutex
|
||||
tagOptionsCache []PlayletTagItem
|
||||
tagOptionsAt time.Time
|
||||
)
|
||||
|
||||
type TagListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
TagID int64
|
||||
TagSlug string
|
||||
SortType int // 1最新 2最热 3收藏最多
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
q.TagSlug = strings.TrimSpace(q.TagSlug)
|
||||
switch q.SortType {
|
||||
case tagSortHeat, tagSortCollect:
|
||||
default:
|
||||
q.SortType = tagSortNewest
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
params.Set("sortType", strconv.Itoa(q.SortType))
|
||||
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}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchTagOptions 拉取 GET /api/app/playlet/tag/options(已上架标签,不分页)。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧标签相关接口/get_api_app_playlet_tag_options
|
||||
func FetchTagOptions(c *gin.Context) (*PlayletTagListResponse, error) {
|
||||
var env APIEnvelope[PlayletTagListResponse]
|
||||
if err := upstream.GetPublicJSON(c, tagOptionsPath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet tag options code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchTagOptionsOrEmpty 返回上架标签(按 rank 降序);失败时返回空切片。
|
||||
func FetchTagOptionsOrEmpty(c *gin.Context) []PlayletTagItem {
|
||||
tagOptionsMu.Lock()
|
||||
defer tagOptionsMu.Unlock()
|
||||
if tagOptionsCache != nil && time.Since(tagOptionsAt) < tagOptionsTTL {
|
||||
return append([]PlayletTagItem(nil), tagOptionsCache...)
|
||||
}
|
||||
|
||||
resp, err := FetchTagOptions(c)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet tag options failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
list := ActiveTagOptions(nil)
|
||||
if resp != nil {
|
||||
list = ActiveTagOptions(resp.List)
|
||||
}
|
||||
tagOptionsCache = list
|
||||
tagOptionsAt = time.Now()
|
||||
return append([]PlayletTagItem(nil), list...)
|
||||
}
|
||||
|
||||
// ActiveTagOptions 仅保留上架标签,按 rank 降序(越大越靠前)。
|
||||
func ActiveTagOptions(list []PlayletTagItem) []PlayletTagItem {
|
||||
out := make([]PlayletTagItem, 0, len(list))
|
||||
for _, item := range list {
|
||||
if !item.Status {
|
||||
continue
|
||||
}
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
item.Slug = strings.TrimSpace(item.Slug)
|
||||
if item.Name == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Rank != out[j].Rank {
|
||||
return out[i].Rank > out[j].Rank
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func tagListPageURL(tagID int64, tagSlug string, sortType, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
switch sortType {
|
||||
case tagSortHeat, tagSortCollect:
|
||||
default:
|
||||
sortType = tagSortNewest
|
||||
}
|
||||
path := "/tags"
|
||||
if segment := pathSegment(tagSlug, tagID); segment != "" {
|
||||
path += "/" + segment
|
||||
}
|
||||
if sortType == tagSortNewest {
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path
|
||||
}
|
||||
path += "/sort/" + strconv.Itoa(sortType)
|
||||
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 "/tags/" + segment
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func themeListURLByKey(slug string, id int64) string {
|
||||
if segment := pathSegment(slug, id); segment != "" {
|
||||
return "/list/" + segment
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TagListURL 生成标签页筛选链接(纯路径)。
|
||||
func TagListURL(slug string, id int64, sortType, pageNum int) string {
|
||||
q := TagListQuery{
|
||||
TagID: id,
|
||||
TagSlug: slug,
|
||||
SortType: sortType,
|
||||
PageNum: pageNum,
|
||||
PageSize: defaultTagSize,
|
||||
}
|
||||
q.Normalize()
|
||||
return tagListPageURL(q.TagID, q.TagSlug, q.SortType, q.PageNum)
|
||||
}
|
||||
|
||||
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}
|
||||
id := int64(0)
|
||||
slug := ""
|
||||
if i < len(ids) {
|
||||
id = ids[i]
|
||||
}
|
||||
if i < len(slugs) {
|
||||
slug = slugs[i]
|
||||
}
|
||||
item.URL = themeListURLByKey(slug, id)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TagListCanonicalPath(q TagListQuery) string {
|
||||
q.Normalize()
|
||||
return tagListPageURL(q.TagID, q.TagSlug, q.SortType, q.PageNum)
|
||||
}
|
||||
|
||||
func BuildTagListPagination(q TagListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return tagListPageURL(q.TagID, q.TagSlug, q.SortType, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// ParseTagListQuery 从路径参数解析标签列表筛选。
|
||||
// 路由:/tags/:slug/ 、/tags/:slug/page/:page/ 、
|
||||
//
|
||||
// /tags/:slug/sort/:sortType/ 、/tags/:slug/sort/:sortType/page/:page/
|
||||
func ParseTagListQuery(c *gin.Context) TagListQuery {
|
||||
q := TagListQuery{PageSize: defaultTagSize}
|
||||
ident := strings.TrimSpace(c.Param("slug"))
|
||||
if ident != "" {
|
||||
if decoded, err := url.PathUnescape(ident); err == nil {
|
||||
ident = strings.TrimSpace(decoded)
|
||||
}
|
||||
}
|
||||
if id, err := strconv.ParseInt(ident, 10, 64); err == nil && id > 0 {
|
||||
q.TagID = id
|
||||
} else {
|
||||
q.TagSlug = ident
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("sortType"))); err == nil {
|
||||
q.SortType = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("page"))); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// ResolveTagListIdentity 用标签选项补全 slug / id / 名称。
|
||||
func ResolveTagListIdentity(q *TagListQuery, tags []PlayletTagItem) (name string) {
|
||||
if q == nil {
|
||||
return ""
|
||||
}
|
||||
q.Normalize()
|
||||
slug := strings.TrimSpace(q.TagSlug)
|
||||
if slug != "" {
|
||||
for _, t := range tags {
|
||||
if strings.EqualFold(strings.TrimSpace(t.Slug), slug) {
|
||||
q.TagID = t.ID
|
||||
q.TagSlug = strings.TrimSpace(t.Slug)
|
||||
return strings.TrimSpace(t.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if q.TagID > 0 {
|
||||
for _, t := range tags {
|
||||
if t.ID == q.TagID {
|
||||
q.TagSlug = strings.TrimSpace(t.Slug)
|
||||
return strings.TrimSpace(t.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RelatedTagOptions 相关标签(排除当前)。
|
||||
func RelatedTagOptions(tags []PlayletTagItem, currentID int64, currentSlug string) []PlayletTagItem {
|
||||
currentSlug = strings.TrimSpace(currentSlug)
|
||||
out := make([]PlayletTagItem, 0, len(tags))
|
||||
for _, t := range tags {
|
||||
if currentID > 0 && t.ID == currentID {
|
||||
continue
|
||||
}
|
||||
if currentSlug != "" && strings.EqualFold(strings.TrimSpace(t.Slug), currentSlug) {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 + PageTitleSuffix(pageNum)
|
||||
}
|
||||
base := "标签短剧"
|
||||
if tagName != "" {
|
||||
base = tagName + "短剧"
|
||||
}
|
||||
return base + PageTitleSuffix(pageNum)
|
||||
}
|
||||
@@ -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,67 @@
|
||||
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 分页查询今日更新短剧。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_today_update
|
||||
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 "/recently-added"
|
||||
}
|
||||
return "/recently-added/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
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
topicDetailPath = "/api/app/playlet/topic/detail"
|
||||
|
||||
defaultTopicDetailPageSize = 24
|
||||
|
||||
// topic/detail sortType:1编辑排序 2最新 3最热
|
||||
topicDetailSortEditor = 1
|
||||
topicDetailSortNewest = 2
|
||||
topicDetailSortHeat = 3
|
||||
)
|
||||
|
||||
// TopicDetailQuery 对应 GET /api/app/playlet/topic/detail 查询参数。
|
||||
// 上游必填 id;路径优先 slug,服务端解析后再请求。
|
||||
type TopicDetailQuery struct {
|
||||
ID int64
|
||||
Slug string
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortType int // 1编辑排序 2最新 3最热
|
||||
}
|
||||
|
||||
// Normalize 校正分页与排序默认值。
|
||||
func (q *TopicDetailQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultTopicDetailPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
switch q.SortType {
|
||||
case topicDetailSortNewest, topicDetailSortHeat:
|
||||
default:
|
||||
q.SortType = topicDetailSortEditor
|
||||
}
|
||||
if q.ID < 0 {
|
||||
q.ID = 0
|
||||
}
|
||||
q.Slug = strings.TrimSpace(q.Slug)
|
||||
}
|
||||
|
||||
// ParseTopicDetailQuery 从路径参数解析专题详情筛选。
|
||||
// 路由:/topic-detail/:slug/ 、/topic-detail/:slug/page/:page/ 、
|
||||
//
|
||||
// /topic-detail/:slug/sort/:sortType/ 、/topic-detail/:slug/sort/:sortType/page/:page/
|
||||
func ParseTopicDetailQuery(c *gin.Context) TopicDetailQuery {
|
||||
q := TopicDetailQuery{PageSize: defaultTopicDetailPageSize}
|
||||
ident := strings.TrimSpace(c.Param("slug"))
|
||||
if ident != "" {
|
||||
if decoded, err := url.PathUnescape(ident); err == nil {
|
||||
ident = strings.TrimSpace(decoded)
|
||||
}
|
||||
}
|
||||
if id, err := strconv.ParseInt(ident, 10, 64); err == nil && id > 0 {
|
||||
q.ID = id
|
||||
} else {
|
||||
q.Slug = ident
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("sortType"))); err == nil {
|
||||
q.SortType = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("page"))); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// TopicDetailURL 生成专题详情筛选链接(纯路径,无 query)。
|
||||
func TopicDetailURL(slug string, id int64, sortType, pageNum int) string {
|
||||
q := TopicDetailQuery{
|
||||
ID: id,
|
||||
Slug: slug,
|
||||
PageNum: pageNum,
|
||||
PageSize: defaultTopicDetailPageSize,
|
||||
SortType: sortType,
|
||||
}
|
||||
q.Normalize()
|
||||
return q.URL()
|
||||
}
|
||||
|
||||
// URL 输出:/topic-detail/{slug|id}/[sort/{s}/][page/{n}/]
|
||||
func (q TopicDetailQuery) URL() string {
|
||||
q.Normalize()
|
||||
segment := pathSegment(q.Slug, q.ID)
|
||||
if segment == "" {
|
||||
return "/topic"
|
||||
}
|
||||
base := "/topic-detail/" + segment
|
||||
if q.SortType == topicDetailSortEditor {
|
||||
if q.PageNum <= 1 {
|
||||
return base
|
||||
}
|
||||
return base + "/page/" + strconv.Itoa(q.PageNum)
|
||||
}
|
||||
path := base + "/sort/" + strconv.Itoa(q.SortType)
|
||||
if q.PageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(q.PageNum)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// BuildTopicDetailPagination 专题详情作品分页。
|
||||
func BuildTopicDetailPagination(q TopicDetailQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
nq := q
|
||||
nq.PageNum = p
|
||||
return nq.URL()
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// ResolveTopicDetailID 路径为 slug 时,用专题列表解析上游必填 id。
|
||||
func ResolveTopicDetailID(c *gin.Context, q *TopicDetailQuery) {
|
||||
if q == nil || q.ID > 0 {
|
||||
return
|
||||
}
|
||||
q.Normalize()
|
||||
slug := q.Slug
|
||||
if slug == "" {
|
||||
return
|
||||
}
|
||||
for page := 1; page <= 5; page++ {
|
||||
raw := FetchTopicListOrEmpty(c, TopicListQuery{
|
||||
PageNum: page,
|
||||
PageSize: 50,
|
||||
SortType: topicSortNewest,
|
||||
})
|
||||
for _, item := range raw.List {
|
||||
if strings.EqualFold(strings.TrimSpace(item.Slug), slug) {
|
||||
q.ID = item.ID
|
||||
q.Slug = strings.TrimSpace(item.Slug)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(raw.List) == 0 || int64(page*50) >= raw.TotalCount {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FetchTopicDetail 拉取 GET /api/app/playlet/topic/detail。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_topic_detail
|
||||
func FetchTopicDetail(c *gin.Context, q TopicDetailQuery) (*PlayletTopicDetailResp, error) {
|
||||
q.Normalize()
|
||||
if q.ID <= 0 {
|
||||
return nil, fmt.Errorf("playlet topic detail missing id")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("id", strconv.FormatInt(q.ID, 10))
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortType", strconv.Itoa(q.SortType))
|
||||
|
||||
var env APIEnvelope[PlayletTopicDetailResp]
|
||||
if err := upstream.GetPublicJSON(c, topicDetailPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet topic detail code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchTopicDetailOrEmpty 失败时返回空详情。
|
||||
func FetchTopicDetailOrEmpty(c *gin.Context, q TopicDetailQuery) *PlayletTopicDetailResp {
|
||||
data, err := FetchTopicDetail(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet topic detail failed", log.E(err))
|
||||
return &PlayletTopicDetailResp{ID: q.ID, Slug: q.Slug, PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletTopicDetailResp{ID: q.ID, Slug: q.Slug, PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
|
||||
// TopicDetailCoverURLs 专题详情顶部封面,最多 3 张。
|
||||
func TopicDetailCoverURLs(detail PlayletTopicDetailResp) []string {
|
||||
return TopicCoverURLs(PlayletTopicPageItem{Covers: detail.Covers, List: detail.List})
|
||||
}
|
||||
|
||||
// TopicDetailHeroCover 头图背景用第一张封面。
|
||||
func TopicDetailHeroCover(detail PlayletTopicDetailResp) string {
|
||||
covers := TopicDetailCoverURLs(detail)
|
||||
if len(covers) == 0 {
|
||||
return ""
|
||||
}
|
||||
return covers[0]
|
||||
}
|
||||
|
||||
// TopicDetailSortLabel 排序文案。
|
||||
func TopicDetailSortLabel(sortType int) string {
|
||||
switch sortType {
|
||||
case topicDetailSortNewest:
|
||||
return "最新"
|
||||
case topicDetailSortHeat:
|
||||
return "最热"
|
||||
default:
|
||||
return "编辑排序"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
topicListPath = "/api/app/playlet/topic/list"
|
||||
|
||||
defaultTopicListPageSize = 12
|
||||
|
||||
// topic/list sortType:1最新 2最热
|
||||
topicSortNewest = 1
|
||||
topicSortHeat = 2
|
||||
)
|
||||
|
||||
// TopicListQuery 对应 GET /api/app/playlet/topic/list 查询参数。
|
||||
type TopicListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortType int // 1最新 2最热
|
||||
}
|
||||
|
||||
// Normalize 校正分页与排序默认值。
|
||||
func (q *TopicListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultTopicListPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.SortType != topicSortHeat {
|
||||
q.SortType = topicSortNewest
|
||||
}
|
||||
}
|
||||
|
||||
// ParseTopicListQuery 从路径参数解析专题剧单筛选。
|
||||
// 路由:/topic/ 、/topic/page/:page/ 、/topic/sort/:sortType/ 、
|
||||
//
|
||||
// /topic/sort/:sortType/page/:page/
|
||||
func ParseTopicListQuery(c *gin.Context) TopicListQuery {
|
||||
q := TopicListQuery{PageSize: defaultTopicListPageSize}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("sortType"))); err == nil {
|
||||
q.SortType = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("page"))); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// TopicListURL 生成专题剧单页筛选链接(纯路径,无 query)。
|
||||
func TopicListURL(sortType, pageNum int) string {
|
||||
q := TopicListQuery{
|
||||
PageNum: pageNum,
|
||||
PageSize: defaultTopicListPageSize,
|
||||
SortType: sortType,
|
||||
}
|
||||
q.Normalize()
|
||||
return q.URL()
|
||||
}
|
||||
|
||||
// URL 输出路径:/topic/ 、/topic/page/{n}/ 、/topic/sort/{s}/ 、/topic/sort/{s}/page/{n}/
|
||||
func (q TopicListQuery) URL() string {
|
||||
q.Normalize()
|
||||
if q.SortType == topicSortNewest {
|
||||
if q.PageNum <= 1 {
|
||||
return "/topic"
|
||||
}
|
||||
return "/topic/page/" + strconv.Itoa(q.PageNum)
|
||||
}
|
||||
path := "/topic/sort/" + strconv.Itoa(q.SortType)
|
||||
if q.PageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(q.PageNum)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// BuildTopicListPagination 专题剧单分页。
|
||||
func BuildTopicListPagination(q TopicListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
nq := q
|
||||
nq.PageNum = p
|
||||
return nq.URL()
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// FetchTopicList 拉取 GET /api/app/playlet/topic/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_topic_list
|
||||
func FetchTopicList(c *gin.Context, q TopicListQuery) (*PlayletTopicPageListResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortType", strconv.Itoa(q.SortType))
|
||||
|
||||
var env APIEnvelope[PlayletTopicPageListResp]
|
||||
if err := upstream.GetPublicJSON(c, topicListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet topic list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchTopicListOrEmpty 失败时返回空列表。
|
||||
func FetchTopicListOrEmpty(c *gin.Context, q TopicListQuery) *PlayletTopicPageListResp {
|
||||
data, err := FetchTopicList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet topic list failed", log.E(err))
|
||||
return &PlayletTopicPageListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletTopicPageListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
|
||||
// TopicCoverURLs 取专题展示封面,优先 covers,不足时用 list.cover 补齐,最多 3 张。
|
||||
func TopicCoverURLs(item PlayletTopicPageItem) []string {
|
||||
out := make([]string, 0, 3)
|
||||
for _, cover := range item.Covers {
|
||||
cover = strings.TrimSpace(cover)
|
||||
if cover == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, cover)
|
||||
if len(out) >= 3 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
for _, playlet := range item.List {
|
||||
cover := strings.TrimSpace(playlet.Cover)
|
||||
if cover == "" {
|
||||
continue
|
||||
}
|
||||
dup := false
|
||||
for _, existing := range out {
|
||||
if existing == cover {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dup {
|
||||
continue
|
||||
}
|
||||
out = append(out, cover)
|
||||
if len(out) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/configser"
|
||||
)
|
||||
|
||||
// TemplateFuncs 模板辅助:仅做展示格式化 / URL,不把接口字段映射成另一套名字。
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"detailURL": detailURL,
|
||||
"playURL": playURL,
|
||||
"chapterPlayURL": chapterPlayURL,
|
||||
"categoryURL": categoryURL,
|
||||
"categoryDetailURL": categoryDetailURL,
|
||||
"themeListURL": themeListURLByKey,
|
||||
"tagOptionsURL": tagListURLByKey,
|
||||
"allListURL": AllListURL,
|
||||
"moreListURL": MoreListURL,
|
||||
"moreSeriesListURL": MoreSeriesListURL,
|
||||
"navMegaIcon": NavMegaIcon,
|
||||
"topicListURL": TopicListURL,
|
||||
"topicDetailURL": TopicDetailURL,
|
||||
"topicCoverURLs": TopicCoverURLs,
|
||||
"tagPageURL": TagListURL,
|
||||
"hotRankPageURL": HotRankPageURL,
|
||||
"searchResultsURL": SearchResultsURL,
|
||||
"coverURL": coverURL,
|
||||
"formatHeat": formatHeat,
|
||||
"formatCollect": formatCollectHeat,
|
||||
"formatLikeCount": formatLikeCount,
|
||||
"formatMediaTime": formatMediaTime,
|
||||
"playletDisplayName": playletDisplayName,
|
||||
"rankNo": RankNo,
|
||||
"episodeLabel": episodeText,
|
||||
"displayTag": displayTag,
|
||||
"firstTagName": firstTagName,
|
||||
"themeTagText": themeTagText,
|
||||
"tagListURL": tagListURL,
|
||||
"cardTag": cardTag,
|
||||
"cardTags": cardTags,
|
||||
"namedTags": namedTags,
|
||||
"namedThemeTags": namedThemeTags,
|
||||
"firstName": firstName,
|
||||
"rankClass": rankClass,
|
||||
"add": addInt,
|
||||
"mod": modInt,
|
||||
"contains": strings.Contains,
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
"footerLinkURL": footerLinkURL,
|
||||
"footerLinkIsExternal": footerLinkIsExternal,
|
||||
"externalLinkAttrs": externalLinkAttrs,
|
||||
"footerContactURL": footerContactURL,
|
||||
"footerContactName": footerContactName,
|
||||
"footerContactValue": footerContactValue,
|
||||
"navDisplayURL": navDisplayURL,
|
||||
"urlquery": url.QueryEscape,
|
||||
"pathescape": url.PathEscape,
|
||||
"metaLine": metaLine,
|
||||
"adSlice": AdSlice,
|
||||
"cssCover": cssCover,
|
||||
"cssCoverPlaceholder": cssCoverPlaceholder,
|
||||
"updateStatusText": updateStatusText,
|
||||
"browseText": browseText,
|
||||
"usableAnchor": usableAnchor,
|
||||
"playletYearText": playletYearText,
|
||||
"playletDateText": playletDateText,
|
||||
"isPlayletAllFree": isPlayletAllFree,
|
||||
"collectProgressText": collectProgressText,
|
||||
"collectTagText": collectTagText,
|
||||
"chapterCollectEpisodeLabel": chapterCollectEpisodeLabel,
|
||||
"chapterCollectDesc": chapterCollectDesc,
|
||||
"chapterCollectMeta": chapterCollectMeta,
|
||||
"historyProgressText": historyProgressText,
|
||||
"historyWatchLine": historyWatchLine,
|
||||
"historyTagText": historyTagText,
|
||||
"historyPlayURL": historyPlayURL,
|
||||
"historyContinueMeta": historyContinueMeta,
|
||||
"historyProgressPct": historyProgressPct,
|
||||
"historyStatus": historyStatus,
|
||||
"formatHistoryAt": formatHistoryAt,
|
||||
"formatShortDateTime": formatShortDateTime,
|
||||
"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(name, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "/" || raw == "#" {
|
||||
return ""
|
||||
}
|
||||
if footerLinkIsExternal(raw) {
|
||||
return raw
|
||||
}
|
||||
if href := footerRichtextHref(name, raw); href != "" {
|
||||
return href
|
||||
}
|
||||
return normalizeFooterInternalURL(raw)
|
||||
}
|
||||
|
||||
func footerLinkIsExternal(raw string) bool {
|
||||
return isExternalWebURL(raw)
|
||||
}
|
||||
|
||||
// externalLinkAttrs 为运行时配置的站外网页链接补齐统一的打开方式和 SEO 关系。
|
||||
// 返回固定属性而不是拼接用户输入,避免配置值进入 HTML 属性上下文。
|
||||
func externalLinkAttrs(raw string) template.HTMLAttr {
|
||||
if !isExternalWebURL(raw) {
|
||||
return ""
|
||||
}
|
||||
return template.HTMLAttr(`target="_blank" rel="sponsored nofollow noopener"`)
|
||||
}
|
||||
|
||||
func isExternalWebURL(raw string) bool {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
return strings.HasPrefix(value, "http://") ||
|
||||
strings.HasPrefix(value, "https://") ||
|
||||
strings.HasPrefix(value, "//")
|
||||
}
|
||||
|
||||
func footerRichtextHref(name, 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/") {
|
||||
title := strings.TrimPrefix(path, "/richtext/")
|
||||
if decoded, err := url.PathUnescape(title); err == nil {
|
||||
title = decoded
|
||||
}
|
||||
if semanticPath, ok := configser.RichTextSemanticHref(name); ok {
|
||||
return semanticPath
|
||||
}
|
||||
return configser.RichTextHref(title)
|
||||
}
|
||||
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, "/") {
|
||||
parsed.Path = strings.TrimRight(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
|
||||
}
|
||||
|
||||
// ImgPlaceholder 默认/失败占位图(与 imgDecypt.js 保持一致)。
|
||||
const ImgPlaceholder = "/static/images/placeholder.png?v=2"
|
||||
|
||||
// coverURL 选出封面地址。返回 template.URL,避免 html/template 把 data-src
|
||||
// 当 URL 上下文时把已百分号编码的中文路径再次转成 %25xx(导致封面 404)。
|
||||
// 无有效地址时回退占位图。
|
||||
func coverURL(values ...string) template.URL {
|
||||
u := firstNonEmpty(values...)
|
||||
if u == "" {
|
||||
return template.URL(ImgPlaceholder)
|
||||
}
|
||||
return template.URL(u)
|
||||
}
|
||||
|
||||
// cssCover 生成卡片封面 CSS 变量,供 style="{{cssCover ...}}" 使用。
|
||||
// 无有效地址时使用占位图。
|
||||
func cssCover(values ...string) template.CSS {
|
||||
u := firstNonEmpty(values...)
|
||||
if u == "" {
|
||||
u = ImgPlaceholder
|
||||
}
|
||||
return template.CSS("--cover:url(" + u + ")")
|
||||
}
|
||||
|
||||
// cssCoverPlaceholder 仅占位,真实图放 data-cover-src 由前端解密后写入 --cover。
|
||||
func cssCoverPlaceholder() template.CSS {
|
||||
return template.CSS("--cover:url(" + ImgPlaceholder + ")")
|
||||
}
|
||||
|
||||
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 modInt(a, b int) int {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return a % b
|
||||
}
|
||||
|
||||
func absInt64Tpl(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func formatShortDateTime(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "0001") {
|
||||
return ""
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, "T", " ")
|
||||
if i := strings.Index(raw, "."); i > 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
if i := strings.IndexAny(raw, "Z+"); i > 10 {
|
||||
raw = strings.TrimSpace(raw[:i])
|
||||
}
|
||||
if len(raw) >= 16 {
|
||||
raw = raw[:16]
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, "-", "/")
|
||||
return raw
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package playletser
|
||||
|
||||
import "videoweb/app/service/configser"
|
||||
|
||||
// HomePlayletResponse 对应 swagger proto.HomePlayletResponse
|
||||
// GET /api/app/playlet/home
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/app短剧相关接口/get_api_app_playlet_home
|
||||
type HomePlayletResponse struct {
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
Announcements []AnnouncementItem `json:"announcements"`
|
||||
HomeBannerData []CommonData `json:"homeBannerData"`
|
||||
HomeConfigData []HomeConfigData `json:"homeConfigData"`
|
||||
Advitise configser.AdvitiseData `json:"advitise"`
|
||||
// 以下为历史/扩展字段:上游若仍返回则透传,swagger 当前未声明。
|
||||
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"`
|
||||
}
|
||||
|
||||
// HomeConfigData 对应 swagger proto.HomeConfigData(首页配置区块)。
|
||||
type HomeConfigData struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Desc string `json:"desc"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
Rank int `json:"rank"`
|
||||
SortType int `json:"sortType"`
|
||||
Status bool `json:"status"`
|
||||
Type int `json:"type"` // 1短剧 2视频 3题材
|
||||
List []CommonData `json:"list"`
|
||||
ThemeList []HomeConfigThemeData `json:"themeList"`
|
||||
VideoList []VideoItemResp `json:"videoList"` // type=2
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// VideoItemResp 对应 swagger proto.VideoItemResp(homeConfigData.type=2)。
|
||||
type VideoItemResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
CoverImg string `json:"coverImg"`
|
||||
CreatorID int64 `json:"creatorId"`
|
||||
CreatorName string `json:"creatorName"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Tags []int64 `json:"tags"`
|
||||
VideoTime int `json:"videoTime"`
|
||||
WatchTimes int64 `json:"watchTimes"`
|
||||
AddedTime string `json:"addedTime"`
|
||||
}
|
||||
|
||||
// HomeConfigThemeData 对应 swagger proto.HomeConfigThemeData。
|
||||
type HomeConfigThemeData 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"`
|
||||
PlayletCount int64 `json:"playletCount"`
|
||||
List []CommonData `json:"list"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// HomeConfigDetailListResp 对应 swagger proto.HomeConfigDetailListResp。
|
||||
// GET /api/app/playlet/home/config/detail/list
|
||||
type HomeConfigDetailListResp struct {
|
||||
HomeConfigID int64 `json:"homeConfigId"`
|
||||
HomeConfigSlug string `json:"homeConfigSlug"`
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
Type int `json:"type"`
|
||||
SortType int `json:"sortType"`
|
||||
List []CommonData `json:"list"`
|
||||
VideoList []VideoItemResp `json:"videoList"`
|
||||
ThemeList []HomeConfigThemeData `json:"themeList"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
Advitise configser.AdvitiseData `json:"advitise"`
|
||||
}
|
||||
|
||||
// HomeCategoryData 对应历史字段 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"`
|
||||
HorizontalCover string `json:"horizontalCover"`
|
||||
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 / proto.PlayletRankResp。
|
||||
// GET /api/app/playlet/list、GET /api/app/playlet/rank
|
||||
type PlayletVideoResp struct {
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
List []CommonData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// PlayletSearchResp 对应 swagger proto.PlayletSearchResp。
|
||||
// GET /api/app/playlet/search
|
||||
type PlayletSearchResp struct {
|
||||
Keyword string `json:"keyword"`
|
||||
List []CommonData `json:"list"`
|
||||
EveryoneWatching []CommonData `json:"everyoneWatching"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// PlayletSearchSuggestResp 对应 swagger proto.PlayletSearchSuggestResp。
|
||||
// GET /api/app/playlet/search/suggest
|
||||
type PlayletSearchSuggestResp struct {
|
||||
Keyword string `json:"keyword"`
|
||||
List []CommonData `json:"list"`
|
||||
}
|
||||
|
||||
// PlayletHotRankListResp 对应 swagger proto.PlayletHotRankListResp。
|
||||
// GET /api/app/playlet/hot/rank/list
|
||||
type PlayletHotRankListResp struct {
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
List []CommonData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
RankType int `json:"rankType"` // 1热播榜 2新剧榜 3追剧榜
|
||||
}
|
||||
|
||||
// PlayletAllListResp 对应 swagger proto.PlayletAllListResp
|
||||
// GET /api/app/playlet/all/list
|
||||
type PlayletAllListResp struct {
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
List []CommonData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
Advitise configser.AdvitiseData `json:"advitise"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// PlayletTopicDetailResp 对应 swagger proto.PlayletTopicDetailResp。
|
||||
// GET /api/app/playlet/topic/detail
|
||||
type PlayletTopicDetailResp 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"`
|
||||
PlayletCount int64 `json:"playletCount"`
|
||||
Covers []string `json:"covers"`
|
||||
List []CommonData `json:"list"`
|
||||
MoreTopics []PlayletTopicPageItem `json:"moreTopics"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// PlayletTopicPageItem 对应 swagger proto.PlayletTopicPageItem。
|
||||
// GET /api/app/playlet/topic/list 列表项。
|
||||
type PlayletTopicPageItem 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"`
|
||||
PlayletCount int64 `json:"playletCount"`
|
||||
Covers []string `json:"covers"`
|
||||
List []CommonData `json:"list"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// PlayletTopicPageListResp 对应 swagger proto.PlayletTopicPageListResp。
|
||||
// GET /api/app/playlet/topic/list
|
||||
type PlayletTopicPageListResp struct {
|
||||
List []PlayletTopicPageItem `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// PlayletThemeItem 对应 swagger proto.PlayletThemeItem
|
||||
type PlayletThemeItem 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"`
|
||||
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"`
|
||||
HorizontalCover string `json:"horizontalCover"`
|
||||
Preview string `json:"preview"`
|
||||
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"`
|
||||
Advitise configser.AdvitiseData `json:"advitise"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// PlayletTagListResponse 对应 swagger proto.PlayletTagListResponse
|
||||
// GET /api/app/playlet/tag/options
|
||||
type PlayletTagListResponse struct {
|
||||
List []PlayletTagItem `json:"list"`
|
||||
}
|
||||
|
||||
// PlayletTagItem 对应 swagger proto.PlayletTagItem
|
||||
type PlayletTagItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Rank int `json:"rank"`
|
||||
Status bool `json:"status"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pathSegment 生成路径段:优先 slug,没有 slug 时才用 id。
|
||||
func pathSegment(slug string, id int64) string {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug != "" {
|
||||
return url.PathEscape(slug)
|
||||
}
|
||||
if id > 0 {
|
||||
return strconv.FormatInt(id, 10)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SearchResultsURL 搜索结果页路径:/search/{q}/(对应 search.html)
|
||||
func SearchResultsURL(q string) string {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
return "/search"
|
||||
}
|
||||
return "/search/" + url.PathEscape(q)
|
||||
}
|
||||
|
||||
func detailURL(slug string, id int64) string {
|
||||
if segment := pathSegment(slug, id); segment != "" {
|
||||
return "/play/" + segment
|
||||
}
|
||||
return "#"
|
||||
}
|
||||
|
||||
func playURL(slug string, playletID int64) string {
|
||||
return chapterPlayURL(slug, playletID, 0)
|
||||
}
|
||||
|
||||
func chapterPlayURL(slug string, playletID, chapterID int64) string {
|
||||
segment := pathSegment(slug, playletID)
|
||||
if segment == "" {
|
||||
return "#"
|
||||
}
|
||||
if chapterID <= 0 {
|
||||
return "/play/" + segment
|
||||
}
|
||||
return fmt.Sprintf("/play/%s/%d", segment, chapterID)
|
||||
}
|
||||
|
||||
func categoryURL(slug string, id int64) string {
|
||||
if segment := pathSegment(slug, id); segment != "" {
|
||||
return "/topic-detail/" + 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)
|
||||
}
|
||||
|
||||
func formatMediaTime(sec int) string {
|
||||
if sec <= 0 {
|
||||
return "00:00"
|
||||
}
|
||||
h := sec / 3600
|
||||
m := (sec % 3600) / 60
|
||||
s := sec % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
||||
}
|
||||
return fmt.Sprintf("%02d:%02d", m, s)
|
||||
}
|
||||
|
||||
// playletDisplayName 优先 playName,其次 title。
|
||||
func playletDisplayName(playName, title string) string {
|
||||
return firstNonEmpty(playName, title)
|
||||
}
|
||||
|
||||
// PlayletDisplayName 对外导出的剧名展示。
|
||||
func PlayletDisplayName(playName, title string) string {
|
||||
return playletDisplayName(playName, title)
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package rechargeser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
packageListPath = "/api/app/recharge/package/list"
|
||||
coinBuyPath = "/api/app/coin/buy"
|
||||
vipPayPath = "/api/app/vip/pay"
|
||||
vipOrdersPath = "/api/app/vip/orders"
|
||||
coinOrdersPath = "/api/app/coin/orders"
|
||||
coinTransPath = "/api/app/coin/trans/list"
|
||||
coinTransExportPath = "/api/app/coin/trans/export"
|
||||
)
|
||||
|
||||
// FetchPackageList 拉取购买套餐列表(会员 + 金币)。
|
||||
func FetchPackageList(c *gin.Context) (*RechargePackageListResp, error) {
|
||||
var env APIEnvelope[RechargePackageListResp]
|
||||
if err := upstream.GetJSON(c, packageListPath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("recharge package list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchPackageListOrEmpty 失败时返回空列表,保证 SSR 可渲染。
|
||||
func FetchPackageListOrEmpty(c *gin.Context) *RechargePackageListResp {
|
||||
data, err := FetchPackageList(c)
|
||||
if err != nil {
|
||||
log.Error("fetch recharge package list failed", log.E(err))
|
||||
return &RechargePackageListResp{}
|
||||
}
|
||||
if data == nil {
|
||||
return &RechargePackageListResp{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// BuyCoin 购买金币套餐:POST /api/app/coin/buy
|
||||
func BuyCoin(c *gin.Context, req BuyPayReq) (*APIEnvelope[PayOrderResp], error) {
|
||||
var env APIEnvelope[PayOrderResp]
|
||||
if _, err := upstream.PostJSON(c, coinBuyPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// VipPay 购买会员套餐:POST /api/app/vip/pay
|
||||
func VipPay(c *gin.Context, req BuyPayReq) (*APIEnvelope[PayOrderResp], error) {
|
||||
var env APIEnvelope[PayOrderResp]
|
||||
if _, err := upstream.PostJSON(c, vipPayPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// NormalizePayMode 规范化支付方式。
|
||||
func NormalizePayMode(raw string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch v {
|
||||
case "wx", "weixin":
|
||||
return "wechat"
|
||||
case "unionpay":
|
||||
return "union"
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// FetchVipOrders 查询 VIP 充值记录。
|
||||
func FetchVipOrders(c *gin.Context, page, pageSize int) ([]VipOrder, error) {
|
||||
return fetchOrders(c, vipOrdersPath, page, pageSize)
|
||||
}
|
||||
|
||||
// FetchCoinOrders 查询金币购买记录。
|
||||
func FetchCoinOrders(c *gin.Context, page, pageSize int) ([]VipOrder, error) {
|
||||
return fetchOrders(c, coinOrdersPath, page, pageSize)
|
||||
}
|
||||
|
||||
// FetchRecentOrdersOrEmpty 合并 VIP + 金币订单,失败返回空。
|
||||
func FetchRecentOrdersOrEmpty(c *gin.Context, limit int) []VipOrder {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
pageSize := limit
|
||||
vipOrders, err1 := FetchVipOrders(c, 1, pageSize)
|
||||
coinOrders, err2 := FetchCoinOrders(c, 1, pageSize)
|
||||
if err1 != nil {
|
||||
log.Error("fetch vip orders failed", log.E(err1))
|
||||
}
|
||||
if err2 != nil {
|
||||
log.Error("fetch coin orders failed", log.E(err2))
|
||||
}
|
||||
out := make([]VipOrder, 0, len(vipOrders)+len(coinOrders))
|
||||
out = append(out, vipOrders...)
|
||||
out = append(out, coinOrders...)
|
||||
return out
|
||||
}
|
||||
|
||||
// FetchCoinTransList 查询金币余额明细:GET /api/app/coin/trans/list
|
||||
// 第二个返回值为上游业务 code(网络/解码失败时为 0)。
|
||||
func FetchCoinTransList(c *gin.Context, page, pageSize int) ([]CoinTransListItem, int, error) {
|
||||
data, code, err := FetchCoinTransPage(c, page, pageSize, 0)
|
||||
if data == nil {
|
||||
return nil, code, err
|
||||
}
|
||||
return data.List, code, err
|
||||
}
|
||||
|
||||
// FetchCoinTransPage 查询金币余额明细并保留分页信息。
|
||||
// typ: 0全部、1充值、2消费、3奖励。
|
||||
func FetchCoinTransPage(c *gin.Context, page, pageSize, typ int) (*CoinTransPage, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
if typ < 0 || typ > 3 {
|
||||
typ = 0
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("page", strconv.Itoa(page))
|
||||
query.Set("pageSize", strconv.Itoa(pageSize))
|
||||
query.Set("type", strconv.Itoa(typ))
|
||||
full := coinTransPath + "?" + query.Encode()
|
||||
var env APIEnvelopeRaw
|
||||
if err := upstream.GetJSON(c, full, &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("%s code=%d msg=%s", coinTransPath, env.Code, env.Msg)
|
||||
}
|
||||
return decodeCoinTransPage(env.Data, page, pageSize), env.Code, nil
|
||||
}
|
||||
|
||||
// FetchCoinTransExport 导出金币流水 CSV:GET /api/app/coin/trans/export?type=
|
||||
// typ: 0全部、1充值、2消费、3奖励。
|
||||
func FetchCoinTransExport(c *gin.Context, typ int) (*upstream.BinaryResult, error) {
|
||||
if typ < 0 || typ > 3 {
|
||||
typ = 0
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("type", strconv.Itoa(typ))
|
||||
full := coinTransExportPath + "?" + query.Encode()
|
||||
result, err := upstream.GetBinary(c, full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
return nil, fmt.Errorf("%s empty response", coinTransExportPath)
|
||||
}
|
||||
if result.StatusCode < 200 || result.StatusCode >= 300 {
|
||||
return result, fmt.Errorf("%s status=%d body=%s", coinTransExportPath, result.StatusCode, truncateBytes(result.Body, 200))
|
||||
}
|
||||
// 上游偶发返回 JSON 错误包装
|
||||
ct := strings.ToLower(result.ContentType)
|
||||
if strings.Contains(ct, "json") {
|
||||
var env APIEnvelopeRaw
|
||||
if json.Unmarshal(result.Body, &env) == nil && env.Code != 0 && env.Code != 200 {
|
||||
return result, fmt.Errorf("%s code=%d msg=%s", coinTransExportPath, env.Code, env.Msg)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func truncateBytes(b []byte, n int) string {
|
||||
s := string(b)
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// FetchCoinTransListOrEmpty 失败返回空列表;游客会话失效时会尝试重登后重试一次。
|
||||
func FetchCoinTransListOrEmpty(c *gin.Context, page, pageSize int) []CoinTransListItem {
|
||||
list, code, err := FetchCoinTransList(c, page, pageSize)
|
||||
if err == nil {
|
||||
return list
|
||||
}
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
list, _, err = FetchCoinTransList(c, page, pageSize)
|
||||
if err == nil {
|
||||
return list
|
||||
}
|
||||
}
|
||||
log.Error("fetch coin trans list failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeCoinTransList(raw json.RawMessage) []CoinTransListItem {
|
||||
return decodeCoinTransPage(raw, 1, 10).List
|
||||
}
|
||||
|
||||
func decodeCoinTransPage(raw json.RawMessage, page, pageSize int) *CoinTransPage {
|
||||
empty := &CoinTransPage{PageNum: page, PageSize: pageSize}
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return empty
|
||||
}
|
||||
var arr []CoinTransListItem
|
||||
if err := json.Unmarshal(raw, &arr); err == nil {
|
||||
empty.List = arr
|
||||
empty.TotalCount = int64(len(arr))
|
||||
return empty
|
||||
}
|
||||
var obj CoinTransPage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return empty
|
||||
}
|
||||
if obj.PageNum <= 0 {
|
||||
obj.PageNum = page
|
||||
}
|
||||
if obj.PageSize <= 0 {
|
||||
obj.PageSize = pageSize
|
||||
}
|
||||
return &obj
|
||||
}
|
||||
|
||||
func fetchOrders(c *gin.Context, path string, page, pageSize int) ([]VipOrder, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
full := fmt.Sprintf("%s?page=%d&pageSize=%d", path, page, pageSize)
|
||||
var env APIEnvelopeRaw
|
||||
if err := upstream.GetJSON(c, full, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("%s code=%d msg=%s", path, env.Code, env.Msg)
|
||||
}
|
||||
return decodeOrderList(env.Data), nil
|
||||
}
|
||||
|
||||
func decodeOrderList(raw json.RawMessage) []VipOrder {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
var arr []VipOrder
|
||||
if err := json.Unmarshal(raw, &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
var obj struct {
|
||||
List []VipOrder `json:"list"`
|
||||
Orders []VipOrder `json:"orders"`
|
||||
Records []VipOrder `json:"records"`
|
||||
Items []VipOrder `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case len(obj.List) > 0:
|
||||
return obj.List
|
||||
case len(obj.Orders) > 0:
|
||||
return obj.Orders
|
||||
case len(obj.Records) > 0:
|
||||
return obj.Records
|
||||
case len(obj.Items) > 0:
|
||||
return obj.Items
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package rechargeser
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// PackageType 对应 rechargepackagemod.PackageType
|
||||
const (
|
||||
PackageTypeVip = 1 // 会员畅享
|
||||
PackageTypeCoin = 2 // 金币充值
|
||||
)
|
||||
|
||||
// PayMethodInfo 对应 swagger proto.PayMethodInfo
|
||||
type PayMethodInfo struct {
|
||||
Type string `json:"type"` // alipay / wechat / union
|
||||
TypeName string `json:"typeName"` // 支付宝 / 微信 / 银联
|
||||
}
|
||||
|
||||
// RechargePackageItem 对应 swagger proto.RechargePackageItem
|
||||
type RechargePackageItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SubTitle string `json:"subTitle"`
|
||||
PackageType int `json:"packageType"`
|
||||
Price int64 `json:"price"` // 售价,分
|
||||
OriginalPrice int64 `json:"originalPrice"` // 原价,分
|
||||
BaseCoin int64 `json:"baseCoin"`
|
||||
GiftCoin int64 `json:"giftCoin"`
|
||||
TotalCoin int64 `json:"totalCoin"`
|
||||
DurationDays int `json:"durationDays"`
|
||||
DurationHours int `json:"durationHours"`
|
||||
Sort int `json:"sort"`
|
||||
PayMethods []PayMethodInfo `json:"payMethods"`
|
||||
}
|
||||
|
||||
// RechargePackageListResp 对应 swagger proto.RechargePackageListResp
|
||||
// GET /api/app/recharge/package/list
|
||||
type RechargePackageListResp struct {
|
||||
VipList []RechargePackageItem `json:"vipList"`
|
||||
CoinList []RechargePackageItem `json:"coinList"`
|
||||
}
|
||||
|
||||
// BuyPayReq 对应 coinapi.buyCoinReq / rechargeapi.submitOrderReq
|
||||
type BuyPayReq struct {
|
||||
PackageID int64 `json:"packageId"`
|
||||
PayMode string `json:"payMode"` // alipay / wechat / union ...
|
||||
}
|
||||
|
||||
// PayOrderResp 对应 coinser.BuyCoinResp / rechargeser.SubmitOrderResp
|
||||
type PayOrderResp struct {
|
||||
IsOpenNewBrowser bool `json:"isOpenNewBrowser"`
|
||||
PayURL string `json:"payUrl"`
|
||||
PayURLMode string `json:"payUrlMode"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
}
|
||||
|
||||
// VipOrder 对应 swagger vipordermod.VipOrder(会员/金币订单共用结构)
|
||||
type VipOrder struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
OrderType int `json:"orderType"` // 1会员 2金币
|
||||
Status int `json:"status"` // 1待支付 2成功 3失败
|
||||
Amount int64 `json:"amount"` // 分
|
||||
Coin int64 `json:"coin"`
|
||||
CardID int64 `json:"cardId"`
|
||||
CardName string `json:"cardName"`
|
||||
CoinCfgID int64 `json:"coinCfgId"`
|
||||
PayMode string `json:"payMode"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
FinishedAt string `json:"finishedAt"`
|
||||
}
|
||||
|
||||
// CoinTransListItem 对应 swagger proto.CoinTransListItemResp
|
||||
// GET /api/app/coin/trans/list
|
||||
type CoinTransListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Remark string `json:"remark"`
|
||||
TranType int `json:"tranType"`
|
||||
TranTypeName string `json:"tranTypeName"`
|
||||
FlowDir int `json:"flowDir"` // 1收入 -1支出
|
||||
FlowDirName string `json:"flowDirName"`
|
||||
Amount int64 `json:"amount"`
|
||||
ChangeAmount int64 `json:"changeAmount"`
|
||||
BalanceBefore int64 `json:"balanceBefore"`
|
||||
BalanceAfter int64 `json:"balanceAfter"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
RelatedID int64 `json:"relatedId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// CoinTransPage 对应 response.BasePageResp 与金币流水列表组合。
|
||||
type CoinTransPage struct {
|
||||
List []CoinTransListItem `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
const (
|
||||
OrderTypeVip = 1
|
||||
OrderTypeCoin = 2
|
||||
|
||||
OrderStatusPending = 1
|
||||
OrderStatusSuccess = 2
|
||||
OrderStatusFailed = 3
|
||||
|
||||
FlowDirIn = 1
|
||||
FlowDirOut = -1
|
||||
)
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
// APIEnvelopeRaw Data 形态不固定时使用。
|
||||
type APIEnvelopeRaw struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package rechargeser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PayMethodVM 支付方式展示。
|
||||
type PayMethodVM struct {
|
||||
Type string
|
||||
TypeName string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// PackageCardVM 充值页套餐卡片(字段来自 package/list)。
|
||||
type PackageCardVM struct {
|
||||
ID int64
|
||||
Name string
|
||||
Hint string // 优先 subTitle
|
||||
BaseCoinText string
|
||||
GiftCoinText string
|
||||
TotalCoinText string
|
||||
PriceText string // 售价(元),不含 ¥
|
||||
OriginalPriceText string // 原价(元),仅当原价>售价时有值
|
||||
PriceFen int64
|
||||
Active bool
|
||||
PayMethods []PayMethodVM
|
||||
PayMethodsJSON string // 供 data-pay-methods 使用
|
||||
}
|
||||
|
||||
// DepositPageVM 充值页模板数据。
|
||||
type DepositPageVM struct {
|
||||
VipList []PackageCardVM
|
||||
CoinList []PackageCardVM
|
||||
PayMethods []PayMethodVM // 当前选中套餐的支付方式(接口 payMethods)
|
||||
// TxList 交易明细:直接透传 /api/app/coin/trans/list 返回的字段
|
||||
//(不再做 Name/TimeText/AmountText 的自定义字段映射)。
|
||||
TxList []CoinTransListItem
|
||||
}
|
||||
|
||||
// TxItemVM 交易明细行(历史遗留:旧逻辑会把 CoinTransListItem 映射到此结构)。
|
||||
// 当前“交易明细调试”需求:DepositPageVM 直接透传 CoinTransListItem,
|
||||
// 但旧函数可能仍被其它页面复用,因此保留结构体定义。
|
||||
type TxItemVM struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
TimeText string `json:"time"`
|
||||
MobileTime string `json:"mobileTime"`
|
||||
AmountText string `json:"amount"`
|
||||
AmountClass string `json:"amountClass"` // deposit-tx-amount--plus / --minus
|
||||
Credit bool `json:"credit"`
|
||||
StatusText string `json:"status"`
|
||||
}
|
||||
|
||||
var defaultPayMethods = []PayMethodInfo{
|
||||
{Type: "alipay", TypeName: "支付宝"},
|
||||
{Type: "wechat", TypeName: "微信"},
|
||||
{Type: "union", TypeName: "银联"},
|
||||
}
|
||||
|
||||
// BuildDepositPageVM 按接口 vipList / coinList 组装展示数据(支付方式不造假)。
|
||||
// preferTab: "coin" 优先选中金币套餐,其它/空则优先会员。
|
||||
func BuildDepositPageVM(raw *RechargePackageListResp, preferTab string) DepositPageVM {
|
||||
if raw == nil {
|
||||
return DepositPageVM{}
|
||||
}
|
||||
vip := buildCards(raw.VipList, true)
|
||||
coin := buildCards(raw.CoinList, false)
|
||||
activatePreferredPackage(vip, coin, preferTab)
|
||||
|
||||
var activePays []PayMethodVM
|
||||
for _, item := range coin {
|
||||
if item.Active {
|
||||
activePays = item.PayMethods
|
||||
break
|
||||
}
|
||||
}
|
||||
if activePays == nil {
|
||||
for _, item := range vip {
|
||||
if item.Active {
|
||||
activePays = item.PayMethods
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(activePays) > 0 {
|
||||
activePays[0].Active = true
|
||||
}
|
||||
return DepositPageVM{
|
||||
VipList: vip,
|
||||
CoinList: coin,
|
||||
PayMethods: activePays,
|
||||
}
|
||||
}
|
||||
|
||||
// activatePreferredPackage 默认选中:优先有支付方式的套餐,否则选第一项。
|
||||
// preferTab=coin 时优先金币,否则优先会员。
|
||||
func activatePreferredPackage(vip, coin []PackageCardVM, preferTab string) {
|
||||
pick := func(list []PackageCardVM) bool {
|
||||
for i := range list {
|
||||
if len(list[i].PayMethods) > 0 {
|
||||
list[i].Active = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(list) > 0 {
|
||||
list[0].Active = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
preferCoin := strings.EqualFold(strings.TrimSpace(preferTab), "coin")
|
||||
if preferCoin {
|
||||
if pick(coin) {
|
||||
return
|
||||
}
|
||||
pick(vip)
|
||||
return
|
||||
}
|
||||
if pick(vip) {
|
||||
return
|
||||
}
|
||||
pick(coin)
|
||||
}
|
||||
|
||||
// AttachTxList 附加交易明细(金币流水)。
|
||||
func (vm *DepositPageVM) AttachTxList(list []CoinTransListItem) {
|
||||
if vm == nil {
|
||||
return
|
||||
}
|
||||
vm.TxList = list
|
||||
}
|
||||
|
||||
// BuildTxListFromCoinTrans 将金币流水转为展示列表。
|
||||
func BuildTxListFromCoinTrans(list []CoinTransListItem, limit int) []TxItemVM {
|
||||
items := append([]CoinTransListItem(nil), list...)
|
||||
if limit > 0 && len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
out := make([]TxItemVM, 0, len(items))
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.Title)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(item.TranTypeName)
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(item.Remark)
|
||||
}
|
||||
if name == "" {
|
||||
name = "金币变动"
|
||||
}
|
||||
change := item.ChangeAmount
|
||||
if change == 0 {
|
||||
change = item.Amount
|
||||
if item.FlowDir == FlowDirOut {
|
||||
change = -change
|
||||
}
|
||||
}
|
||||
amountClass := "deposit-tx-amount--plus"
|
||||
amountText := "+" + formatCount(absInt64(change)) + " 金币"
|
||||
if change < 0 || item.FlowDir == FlowDirOut {
|
||||
amountClass = "deposit-tx-amount--minus"
|
||||
amountText = "-" + formatCount(absInt64(change)) + " 金币"
|
||||
}
|
||||
out = append(out, TxItemVM{
|
||||
Name: name,
|
||||
Kind: transactionKind(item),
|
||||
Description: name,
|
||||
Category: transactionCategory(item),
|
||||
TimeText: formatTransTime(item.CreatedAt),
|
||||
MobileTime: formatMobileTransTime(item.CreatedAt),
|
||||
AmountText: amountText,
|
||||
AmountClass: amountClass,
|
||||
Credit: change >= 0 && item.FlowDir != FlowDirOut,
|
||||
StatusText: strings.TrimSpace(item.FlowDirName),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func transactionKind(item CoinTransListItem) string {
|
||||
if name := strings.TrimSpace(item.TranTypeName); name != "" {
|
||||
return name
|
||||
}
|
||||
if item.FlowDir == FlowDirOut {
|
||||
return "消费"
|
||||
}
|
||||
return "收入"
|
||||
}
|
||||
|
||||
func transactionCategory(item CoinTransListItem) string {
|
||||
switch item.TranType {
|
||||
case 1:
|
||||
return "topup"
|
||||
case 2:
|
||||
return "reward"
|
||||
case 4:
|
||||
return "spend"
|
||||
default:
|
||||
if item.FlowDir == FlowDirOut {
|
||||
return "spend"
|
||||
}
|
||||
return "reward"
|
||||
}
|
||||
}
|
||||
|
||||
func formatMobileTransTime(raw string) string {
|
||||
formatted := formatTransTime(raw)
|
||||
if len(formatted) >= 16 {
|
||||
return formatted[5:16]
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
func absInt64(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func formatTransTime(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "0001") {
|
||||
return ""
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, "T", " ")
|
||||
if i := strings.Index(raw, "."); i > 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
if i := strings.IndexAny(raw, "Z+"); i > 10 {
|
||||
raw = strings.TrimSpace(raw[:i])
|
||||
}
|
||||
if len(raw) > 19 {
|
||||
raw = raw[:19]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// BuildTxList 将订单转为展示列表(按时间倒序)。保留供订单场景复用。
|
||||
func BuildTxList(orders []VipOrder, limit int) []TxItemVM {
|
||||
items := append([]VipOrder(nil), orders...)
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return orderTimeKey(items[i]) > orderTimeKey(items[j])
|
||||
})
|
||||
if limit > 0 && len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
out := make([]TxItemVM, 0, len(items))
|
||||
for _, o := range items {
|
||||
name := strings.TrimSpace(o.CardName)
|
||||
if name == "" {
|
||||
if o.OrderType == OrderTypeCoin || o.CoinCfgID > 0 {
|
||||
name = "金币充值"
|
||||
} else {
|
||||
name = "会员充值"
|
||||
}
|
||||
}
|
||||
status := orderStatusText(o.Status)
|
||||
if status != "" {
|
||||
name = name + " · " + status
|
||||
}
|
||||
amountClass := "deposit-tx-amount--minus"
|
||||
amountText := "-¥" + FormatYuan(o.Amount)
|
||||
if o.OrderType == OrderTypeCoin || o.Coin > 0 {
|
||||
if o.Status == OrderStatusSuccess && o.Coin > 0 {
|
||||
amountClass = "deposit-tx-amount--plus"
|
||||
amountText = "+" + formatCount(o.Coin) + " 金币"
|
||||
}
|
||||
}
|
||||
out = append(out, TxItemVM{
|
||||
Name: name,
|
||||
TimeText: formatOrderTime(o),
|
||||
AmountText: amountText,
|
||||
AmountClass: amountClass,
|
||||
StatusText: status,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orderTimeKey(o VipOrder) string {
|
||||
if t := strings.TrimSpace(o.FinishedAt); t != "" && !strings.HasPrefix(t, "0001") {
|
||||
return t
|
||||
}
|
||||
return strings.TrimSpace(o.CreatedAt)
|
||||
}
|
||||
|
||||
func formatOrderTime(o VipOrder) string {
|
||||
raw := orderTimeKey(o)
|
||||
if raw == "" || strings.HasPrefix(raw, "0001") {
|
||||
return ""
|
||||
}
|
||||
// 常见 RFC3339 / 带 T 的时间,截成可读
|
||||
raw = strings.ReplaceAll(raw, "T", " ")
|
||||
if i := strings.Index(raw, "."); i > 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
if i := strings.IndexAny(raw, "Z+"); i > 10 {
|
||||
raw = strings.TrimSpace(raw[:i])
|
||||
}
|
||||
if len(raw) > 19 {
|
||||
raw = raw[:19]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func orderStatusText(status int) string {
|
||||
switch status {
|
||||
case OrderStatusPending:
|
||||
return "待支付"
|
||||
case OrderStatusSuccess:
|
||||
return "已完成"
|
||||
case OrderStatusFailed:
|
||||
return "失败"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func buildCards(list []RechargePackageItem, isVIP bool) []PackageCardVM {
|
||||
items := append([]RechargePackageItem(nil), list...)
|
||||
// 仅按接口 sort 排序;sort 相同保持接口原顺序
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return items[i].Sort < items[j].Sort
|
||||
})
|
||||
out := make([]PackageCardVM, 0, len(items))
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
if isVIP {
|
||||
name = "会员套餐"
|
||||
} else {
|
||||
name = "金币套餐"
|
||||
}
|
||||
}
|
||||
pays := buildPayMethodVMs(item.PayMethods, false)
|
||||
card := PackageCardVM{
|
||||
ID: item.ID,
|
||||
Name: name,
|
||||
Hint: packageHint(item, isVIP),
|
||||
BaseCoinText: formatCount(item.BaseCoin),
|
||||
GiftCoinText: formatCount(item.GiftCoin),
|
||||
TotalCoinText: formatCount(packageTotalCoin(item)),
|
||||
PriceText: FormatYuan(item.Price),
|
||||
PriceFen: item.Price,
|
||||
PayMethods: pays,
|
||||
PayMethodsJSON: marshalPayMethodsJSON(pays),
|
||||
}
|
||||
if item.OriginalPrice > item.Price && item.OriginalPrice > 0 {
|
||||
card.OriginalPriceText = FormatYuan(item.OriginalPrice)
|
||||
}
|
||||
out = append(out, card)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func packageTotalCoin(item RechargePackageItem) int64 {
|
||||
if item.TotalCoin > 0 {
|
||||
return item.TotalCoin
|
||||
}
|
||||
return item.BaseCoin + item.GiftCoin
|
||||
}
|
||||
|
||||
func buildPayMethodVMs(list []PayMethodInfo, withFallback bool) []PayMethodVM {
|
||||
src := list
|
||||
if len(src) == 0 && withFallback {
|
||||
src = defaultPayMethods
|
||||
}
|
||||
out := make([]PayMethodVM, 0, len(src))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range src {
|
||||
typ := strings.ToLower(strings.TrimSpace(item.Type))
|
||||
name := strings.TrimSpace(item.TypeName)
|
||||
if typ == "" && name == "" {
|
||||
continue
|
||||
}
|
||||
if typ == "" {
|
||||
typ = strings.ToLower(name)
|
||||
}
|
||||
if name == "" {
|
||||
name = defaultPayTypeName(typ)
|
||||
}
|
||||
if _, ok := seen[typ]; ok {
|
||||
continue
|
||||
}
|
||||
seen[typ] = struct{}{}
|
||||
out = append(out, PayMethodVM{Type: typ, TypeName: name})
|
||||
}
|
||||
if len(out) == 0 && withFallback {
|
||||
return buildPayMethodVMs(defaultPayMethods, false)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func defaultPayTypeName(typ string) string {
|
||||
switch typ {
|
||||
case "alipay":
|
||||
return "支付宝"
|
||||
case "wechat", "wx", "weixin":
|
||||
return "微信"
|
||||
case "union", "unionpay":
|
||||
return "银联"
|
||||
case "dcep", "ecny":
|
||||
return "数字人民币"
|
||||
case "usdt":
|
||||
return "USDT"
|
||||
default:
|
||||
if typ == "" {
|
||||
return "支付"
|
||||
}
|
||||
return typ
|
||||
}
|
||||
}
|
||||
|
||||
func marshalPayMethodsJSON(list []PayMethodVM) string {
|
||||
type row struct {
|
||||
Type string `json:"type"`
|
||||
TypeName string `json:"typeName"`
|
||||
}
|
||||
rows := make([]row, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, row{Type: item.Type, TypeName: item.TypeName})
|
||||
}
|
||||
b, err := json.Marshal(rows)
|
||||
if err != nil || len(b) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func packageHint(item RechargePackageItem, isVIP bool) string {
|
||||
// 优先接口 subTitle
|
||||
if s := strings.TrimSpace(item.SubTitle); s != "" {
|
||||
return s
|
||||
}
|
||||
if isVIP {
|
||||
if item.DurationDays > 0 {
|
||||
return fmt.Sprintf("%d天会员", item.DurationDays)
|
||||
}
|
||||
if item.DurationHours > 0 {
|
||||
return fmt.Sprintf("%d小时会员", item.DurationHours)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
base := item.BaseCoin
|
||||
gift := item.GiftCoin
|
||||
total := item.TotalCoin
|
||||
if total <= 0 {
|
||||
total = base + gift
|
||||
}
|
||||
if gift > 0 && base > 0 {
|
||||
return fmt.Sprintf("基础 %s + 赠送 %s", formatCount(base), formatCount(gift))
|
||||
}
|
||||
if total > 0 {
|
||||
return formatCount(total) + " 金币"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FormatYuan 分转元展示(整数不带小数)。
|
||||
func FormatYuan(fen int64) string {
|
||||
if fen < 0 {
|
||||
fen = 0
|
||||
}
|
||||
if fen%100 == 0 {
|
||||
return strconv.FormatInt(fen/100, 10)
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", float64(fen)/100), "0"), ".")
|
||||
}
|
||||
|
||||
func formatCount(n int64) string {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
s := strconv.FormatInt(n, 10)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
rem := len(s) % 3
|
||||
if rem == 0 {
|
||||
rem = 3
|
||||
}
|
||||
b.WriteString(s[:rem])
|
||||
for i := rem; i < len(s); i += 3 {
|
||||
b.WriteByte(',')
|
||||
b.WriteString(s[i : i+3])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsIndexablePath 是否为需要收录的公开页。
|
||||
// 采用白名单策略:新增页面只有明确归类后才允许进入索引,避免搜索、账户、
|
||||
// 排序变体和工具页因遗漏配置被搜索引擎收录。
|
||||
func IsIndexablePath(raw string) bool {
|
||||
path := normalizedPath(raw)
|
||||
if path == "/" {
|
||||
return true
|
||||
}
|
||||
switch path {
|
||||
case "/about", "/help", "/terms", "/privacy", "/2257", "/dmca":
|
||||
return true
|
||||
}
|
||||
|
||||
segments := splitPath(path)
|
||||
if len(segments) == 0 {
|
||||
return false
|
||||
}
|
||||
switch segments[0] {
|
||||
case "list":
|
||||
return isIndexablePlayletList(segments)
|
||||
case "series":
|
||||
return isIndexableSeriesList(segments)
|
||||
case "topic":
|
||||
return isIndexableSimplePagination(segments, "topic")
|
||||
case "topic-detail", "tags":
|
||||
return isIndexableNamedCollection(segments)
|
||||
case "rank", "popular-recommend":
|
||||
return isIndexableRanking(segments)
|
||||
case "more-list", "more-series-list":
|
||||
return isIndexableCuratedCollection(segments)
|
||||
case "recently-added":
|
||||
return isIndexableSimplePagination(segments, "recently-added")
|
||||
case "play":
|
||||
return isIndexablePlayletPlayback(segments)
|
||||
case "series-play":
|
||||
return len(segments) == 2 && segments[1] != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsPrivatePath 标记不应跟踪链接的账户、认证和交易页面。
|
||||
func IsPrivatePath(raw string) bool {
|
||||
path := normalizedPath(raw)
|
||||
for _, prefix := range []string{
|
||||
"/login", "/register", "/profile", "/history", "/favorites",
|
||||
"/purchased", "/setting", "/settings", "/recharge", "/feedback",
|
||||
} {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizedPath(raw string) string {
|
||||
path := strings.TrimSpace(raw)
|
||||
if u, err := url.Parse(path); err == nil && u.Path != "" {
|
||||
path = u.Path
|
||||
}
|
||||
path = "/" + strings.Trim(strings.TrimSpace(path), "/")
|
||||
if path == "/" {
|
||||
return path
|
||||
}
|
||||
return strings.TrimSuffix(path, "/")
|
||||
}
|
||||
|
||||
func splitPath(path string) []string {
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(path, "/")
|
||||
}
|
||||
|
||||
func positiveInt(raw string) bool {
|
||||
n, err := strconv.Atoi(raw)
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
func pageNumber(raw string) bool {
|
||||
n, err := strconv.Atoi(raw)
|
||||
return err == nil && n > 1
|
||||
}
|
||||
|
||||
func isIndexableSimplePagination(segments []string, root string) bool {
|
||||
if len(segments) == 1 && segments[0] == root {
|
||||
return true
|
||||
}
|
||||
return len(segments) == 3 && segments[0] == root && segments[1] == "page" && pageNumber(segments[2])
|
||||
}
|
||||
|
||||
func isIndexablePlayletList(segments []string) bool {
|
||||
if len(segments) == 1 {
|
||||
return true
|
||||
}
|
||||
if len(segments) == 2 {
|
||||
return segments[1] != "" && segments[1] != "all"
|
||||
}
|
||||
base, hasPage := withoutPageSuffix(segments)
|
||||
if len(base) != 4 || base[1] == "" {
|
||||
return false
|
||||
}
|
||||
updateStatus, updateErr := strconv.Atoi(base[2])
|
||||
sortType, sortErr := strconv.Atoi(base[3])
|
||||
if updateErr != nil || sortErr != nil || updateStatus < 0 || updateStatus > 2 || sortType != 1 {
|
||||
return false
|
||||
}
|
||||
// 默认第一页已有更短规范地址 /list 或 /list/:theme。
|
||||
return hasPage || updateStatus != 0
|
||||
}
|
||||
|
||||
func isIndexableSeriesList(segments []string) bool {
|
||||
if len(segments) == 1 {
|
||||
return true
|
||||
}
|
||||
base, hasPage := withoutPageSuffix(segments)
|
||||
if len(base) != 4 {
|
||||
return false
|
||||
}
|
||||
media, topic, sortType := base[1], base[2], base[3]
|
||||
if media != "all" && media != "film" && media != "anime" {
|
||||
return false
|
||||
}
|
||||
if topic != "all" && !positiveInt(topic) {
|
||||
return false
|
||||
}
|
||||
if sortType != "1" {
|
||||
return false
|
||||
}
|
||||
// 默认第一页已有更短规范地址 /series。
|
||||
return hasPage || media != "all" || topic != "all"
|
||||
}
|
||||
|
||||
func isIndexableNamedCollection(segments []string) bool {
|
||||
if len(segments) == 2 && segments[1] != "" {
|
||||
return true
|
||||
}
|
||||
return len(segments) == 4 && segments[1] != "" && segments[2] == "page" && pageNumber(segments[3])
|
||||
}
|
||||
|
||||
func isIndexableRanking(segments []string) bool {
|
||||
if len(segments) == 1 {
|
||||
return true
|
||||
}
|
||||
if len(segments) == 2 {
|
||||
return segments[1] == "2" || segments[1] == "3"
|
||||
}
|
||||
if len(segments) == 3 {
|
||||
return segments[1] == "page" && pageNumber(segments[2])
|
||||
}
|
||||
return len(segments) == 4 && (segments[1] == "2" || segments[1] == "3") && segments[2] == "page" && pageNumber(segments[3])
|
||||
}
|
||||
|
||||
func isIndexableCuratedCollection(segments []string) bool {
|
||||
if len(segments) == 2 {
|
||||
return segments[1] != ""
|
||||
}
|
||||
return len(segments) == 4 && segments[1] != "" && segments[2] == "page" && pageNumber(segments[3])
|
||||
}
|
||||
|
||||
func isIndexablePlayletPlayback(segments []string) bool {
|
||||
// 规范入口是 /play/{slug};各集 /play/{slug}/{ep} 近重复且多为薄播放页,不收录。
|
||||
return len(segments) == 2 && segments[1] != ""
|
||||
}
|
||||
|
||||
func withoutPageSuffix(segments []string) ([]string, bool) {
|
||||
if len(segments) < 3 || segments[len(segments)-2] != "page" || !pageNumber(segments[len(segments)-1]) {
|
||||
return segments, false
|
||||
}
|
||||
return segments[:len(segments)-2], true
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
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"
|
||||
"videoweb/app/service/videoser"
|
||||
)
|
||||
|
||||
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 = normalizePageURL(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 if video, ok := asVideo(data["video"]); ok {
|
||||
graph = append(graph, seriesPlayNodes(siteURL, pageURL, video)...)
|
||||
} 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 {
|
||||
// /static/favicon.png is embedded and served by this application. Keep the
|
||||
// declared dimensions aligned with the real asset so validators can fetch and
|
||||
// verify the Organization logo instead of following a non-existent root path.
|
||||
logo := AbsURL(siteURL, "/static/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": 132,
|
||||
"height": 132,
|
||||
},
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func websiteNode(siteURL string, cfg configser.AppConfigVM) ldThing {
|
||||
name := siteName(cfg)
|
||||
desc := strings.TrimSpace(cfg.SeoInfo.Description)
|
||||
if desc == "" {
|
||||
desc = name + "汇集热门短剧、今日更新与精选推荐,免费在线观看高清短剧。"
|
||||
}
|
||||
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("/play", 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("/play", "", 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 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 {
|
||||
// 集数和所属剧集是 Episode 的属性;视频媒体通过 associatedMedia 与本集关联。
|
||||
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 seriesPlayNodes(siteURL, pageURL string, video *videoser.VideoInfoResp) []any {
|
||||
name := firstNonEmpty(video.SeoInfo.Title, video.Title, "在线播放")
|
||||
desc := firstNonEmpty(video.SeoInfo.Description, fmt.Sprintf("在线观看《%s》。", name))
|
||||
cover := absMedia(siteURL, firstNonEmpty(video.CoverImg, video.Video.Cover))
|
||||
published := isoDateTime(firstNonEmpty(video.AddedTime, video.CreatedAt))
|
||||
contentURL := videoContentURL(siteURL, video.Video.URL)
|
||||
page := contentWebPageNode(siteURL, pageURL, name, desc, "WebPage")
|
||||
crumbs := [][2]string{
|
||||
{"首页", AbsURL(siteURL, "/")},
|
||||
{"剧集", AbsURL(siteURL, "/series")},
|
||||
{name, pageURL},
|
||||
}
|
||||
page["breadcrumb"] = ldThing{"@id": pageURL + "#breadcrumb"}
|
||||
nodes := []any{page, breadcrumb(siteURL, crumbs)}
|
||||
if cover == "" || published == "" {
|
||||
return nodes
|
||||
}
|
||||
videoID := pageURL + "#video"
|
||||
item := ldThing{
|
||||
"@type": "VideoObject",
|
||||
"@id": videoID,
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"url": pageURL,
|
||||
"mainEntityOfPage": ldThing{"@id": pageURL + "#webpage"},
|
||||
"inLanguage": "zh-CN",
|
||||
"isFamilyFriendly": false,
|
||||
"contentRating": "adult",
|
||||
"isPartOf": ldThing{"@id": siteURL + "/#website"},
|
||||
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
||||
"thumbnailUrl": []string{cover},
|
||||
"image": []string{cover},
|
||||
"uploadDate": published,
|
||||
"potentialAction": ldThing{
|
||||
"@type": "WatchAction",
|
||||
"target": ldThing{
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": pageURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
if contentURL != "" {
|
||||
item["contentUrl"] = contentURL
|
||||
}
|
||||
if video.Video.VideoTime > 0 {
|
||||
item["duration"] = fmt.Sprintf("PT%dS", video.Video.VideoTime)
|
||||
}
|
||||
if video.Video.Width > 0 {
|
||||
item["width"] = video.Video.Width
|
||||
}
|
||||
if video.Video.Height > 0 {
|
||||
item["height"] = video.Video.Height
|
||||
}
|
||||
if video.WatchTimes > 0 {
|
||||
item["interactionStatistic"] = ldThing{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": ldThing{"@type": "WatchAction"},
|
||||
"userInteractionCount": video.WatchTimes,
|
||||
}
|
||||
}
|
||||
page["mainEntity"] = ldThing{"@id": videoID}
|
||||
return append(nodes, item)
|
||||
}
|
||||
|
||||
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 = siteName(cfg)
|
||||
}
|
||||
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", "/popular-recommend", "/recently-added", "/topic", "/topic-detail", "/tags", "/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.PlayPath(slug, id)
|
||||
if path != "#" {
|
||||
urls = append(urls, AbsURL(siteURL, normalizePageURL(path)))
|
||||
}
|
||||
}
|
||||
|
||||
if list, ok := data["list"].([]playletser.CommonData); ok {
|
||||
for _, item := range list {
|
||||
appendPlaylet(item.Slug, item.ID)
|
||||
}
|
||||
}
|
||||
if list, ok := data["list"].([]videoser.VideoAllListItemResp); ok {
|
||||
for _, item := range list {
|
||||
if path := videoser.VideoPlayURL(item.Slug, item.ID); path != "/series" {
|
||||
urls = append(urls, AbsURL(siteURL, path))
|
||||
}
|
||||
}
|
||||
}
|
||||
if list, ok := data["list"].([]playletser.VideoItemResp); ok {
|
||||
for _, item := range list {
|
||||
if path := videoser.VideoPlayURL(item.Slug, item.ID); path != "/series" {
|
||||
urls = append(urls, AbsURL(siteURL, path))
|
||||
}
|
||||
}
|
||||
}
|
||||
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.CommonData); ok {
|
||||
for _, item := range banners {
|
||||
appendPlaylet(item.Slug, item.ID)
|
||||
}
|
||||
}
|
||||
if themes, ok := data["list"].([]playletser.PlayletThemeItem); ok {
|
||||
for _, item := range themes {
|
||||
path := playletser.RecommendCanonicalPath(item.ID, item.Slug)
|
||||
urls = append(urls, AbsURL(siteURL, normalizePageURL(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-detail/") {
|
||||
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", "/popular-recommend", "/recently-added", "/topic", "/topic-detail", "/tags", "/category", "/richtext", "/application"} {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
switch path {
|
||||
case "/home-road", "/about", "/terms", "/privacy", "/help", "/2257", "/dmca":
|
||||
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 {
|
||||
name := siteName(cfg)
|
||||
if page, ok := asRichText(data["richtext"]); ok && page.Found {
|
||||
return firstNonEmpty(page.Subtitle, page.Title+" - "+name+"官方内容。")
|
||||
}
|
||||
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), name+"汇集热门短剧、今日更新与精选推荐,免费在线观看高清短剧。")
|
||||
}
|
||||
if path == "/nav" {
|
||||
return firstNonEmpty(strings.TrimSpace(cfg.SeoNavInfo.DhDescription), name+"官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。")
|
||||
}
|
||||
for prefix, desc := range map[string]string{
|
||||
"/list": "浏览" + name + "全部短剧列表,按分类、标签筛选海量高清短剧,发现更多精彩剧集。",
|
||||
"/series": name + "精选AI成人剧集,持续更新热门漫剧、独家原创剧集,支持免费在线观看。",
|
||||
"/rank": name + "排行榜实时更新热播短剧,查看热门榜、飙升榜。",
|
||||
"/popular-recommend": name + "热播榜根据播放、收藏与解锁综合排序,查看热播、新剧与追剧榜。",
|
||||
"/recently-added": name + "今日更新,第一时间追更最新上线短剧与连载剧集。",
|
||||
"/topic": name + "精选短剧专题与专题作品,方便按主题发现内容。",
|
||||
"/topic-detail": name + "精选短剧专题与专题作品,方便按主题发现内容。",
|
||||
"/tags": name + "标签内容页,按题材标签浏览相关短剧。",
|
||||
"/category": name + "分类内容页,按内容分类浏览最新上架短剧。",
|
||||
"/richtext": name + "官方说明与帮助内容。",
|
||||
} {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
||||
return desc
|
||||
}
|
||||
}
|
||||
switch path {
|
||||
case "/application":
|
||||
return name + "福利应用中心,精选实用应用与福利入口。"
|
||||
case "/download-app":
|
||||
return "下载" + name + "APP,随时随地追看热门短剧。"
|
||||
case "/home-road":
|
||||
return name + "官方最新地址发布页,实时同步国内可用线路、永久入口与防失联联系方式。"
|
||||
case "/about-us", "/aboutUs":
|
||||
return "了解" + name + "的品牌、产品与服务。"
|
||||
case "/user-agreement", "/userAgreement":
|
||||
return name + "用户协议,说明使用平台服务的权利与义务。"
|
||||
case "/privacy-policy", "/privacyPolicy":
|
||||
return name + "隐私政策,说明个人信息的收集、使用与保护方式。"
|
||||
case "/help-center", "/helpCenter":
|
||||
return name + "帮助中心,提供账号、观影、充值与常见问题解答。"
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"videoweb/app/service/configser"
|
||||
)
|
||||
|
||||
const fallbackLLMSSiteName = "黄果短剧"
|
||||
|
||||
// BuildLLMSTXT 生成面向 AI 抓取工具的简洁站点说明与主要规范链接。
|
||||
func BuildLLMSTXT(siteURL string, cfg configser.AppConfigVM) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(siteURL), "/")
|
||||
name := oneLine(cfg.SeoInfo.WebSiteName)
|
||||
if name == "" {
|
||||
name = fallbackLLMSSiteName
|
||||
}
|
||||
description := oneLine(cfg.SeoInfo.Description)
|
||||
if description == "" {
|
||||
description = "汇集热门短剧、今日更新与精选推荐,提供高清短剧在线观看。"
|
||||
}
|
||||
|
||||
links := []struct {
|
||||
label string
|
||||
path string
|
||||
}{
|
||||
{label: "首页", path: "/"},
|
||||
{label: "短剧列表", path: "/list"},
|
||||
{label: "剧集", path: "/series"},
|
||||
{label: "热门推荐", path: "/popular-recommend"},
|
||||
{label: "今日更新", path: "/recently-added"},
|
||||
{label: "专题", path: "/topic"},
|
||||
}
|
||||
|
||||
var body strings.Builder
|
||||
body.WriteString("# ")
|
||||
body.WriteString(name)
|
||||
body.WriteString("\n\n> ")
|
||||
body.WriteString(description)
|
||||
body.WriteString("\n\n## 核心页面\n\n")
|
||||
for _, link := range links {
|
||||
body.WriteString("- [")
|
||||
body.WriteString(link.label)
|
||||
body.WriteString("](")
|
||||
body.WriteString(AbsURL(base, link.path))
|
||||
body.WriteString(")\n")
|
||||
}
|
||||
body.WriteString("\n## 站点发现\n\n")
|
||||
body.WriteString("- [Sitemap](")
|
||||
body.WriteString(AbsURL(base, "/sitemap.xml"))
|
||||
body.WriteString(")\n- [Robots](")
|
||||
body.WriteString(AbsURL(base, "/robots.txt"))
|
||||
body.WriteString(")\n")
|
||||
return body.String()
|
||||
}
|
||||
|
||||
func oneLine(value string) string {
|
||||
return strings.Join(strings.Fields(value), " ")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
templateassets "videoweb/app/template"
|
||||
)
|
||||
|
||||
// BuildRobotsTXT 动态 robots.txt:优先读取当前项目模板文件,并把 Sitemap 写成绝对地址。
|
||||
func BuildRobotsTXT(siteURL string) string {
|
||||
sitemap := AbsURL(siteURL, "/sitemap.xml")
|
||||
body := defaultRobotsTXT
|
||||
loadedFromDisk := false
|
||||
for _, path := range []string{
|
||||
"template/pages/robots.txt",
|
||||
"templates/pages/robots.txt",
|
||||
"app/template/pages/robots.txt",
|
||||
"app/templates/pages/robots.txt",
|
||||
} {
|
||||
if raw, err := os.ReadFile(path); err == nil {
|
||||
if t := strings.TrimSpace(string(raw)); t != "" {
|
||||
body = t
|
||||
loadedFromDisk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !loadedFromDisk {
|
||||
if raw, err := templateassets.FS.ReadFile("pages/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: /cdn-cgi/
|
||||
Disallow: /h5/
|
||||
Disallow: /enkey
|
||||
Disallow: /newenkey
|
||||
|
||||
# 登录、搜索和账户 HTML 页面允许抓取,以便爬虫读取页面内 noindex。
|
||||
|
||||
Sitemap: /sitemap.xml
|
||||
`
|
||||
@@ -0,0 +1,226 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/videoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ResolveSiteURL 解析对外站点根地址(无尾斜杠)。
|
||||
// 优先有效正式域名;跳过占位域名后回退到当前请求 Host。
|
||||
func ResolveSiteURL(c *gin.Context, cfg configser.AppConfigVM) string {
|
||||
if siteURL := PrimarySiteURL(cfg); siteURL != "" {
|
||||
return siteURL
|
||||
}
|
||||
if c != nil && c.Request != nil {
|
||||
return requestOrigin(c.Request)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PrimarySiteURL 返回配置接口动态下发的永久地址,供 canonical、OG 等站点元数据使用。
|
||||
// 页面是否允许索引由页面类型和内容状态决定,与请求 Host 是否匹配此地址无关。
|
||||
func PrimarySiteURL(cfg configser.AppConfigVM) string {
|
||||
return usableSiteURL(cfg.ForeverURLAddr)
|
||||
}
|
||||
|
||||
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 根路径;其他页使用当前 router 的无尾斜杠规范路径。
|
||||
// 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 normalizePageURL(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 路由。
|
||||
// 短剧规范入口是 /play/{slug},与 sitemap-playletdetail 一致;各集页 noindex,不能标成 canonical。
|
||||
func ContentCanonicalURL(siteURL, fallback string, data map[string]any) string {
|
||||
if detail, ok := asPlaylet(data["detail"]); ok {
|
||||
if path := playletPath("/play", detail.Slug, detail.ID); path != "" {
|
||||
return AbsURL(siteURL, path)
|
||||
}
|
||||
}
|
||||
if playlet, ok := asPlaylet(data["playlet"]); ok {
|
||||
if path := playletPath("/play", playlet.Slug, playlet.ID); path != "" {
|
||||
return AbsURL(siteURL, path)
|
||||
}
|
||||
}
|
||||
if video, ok := asVideo(data["video"]); ok {
|
||||
if path := videoser.VideoPlayURL(video.Slug, video.ID); path != "/series" {
|
||||
return AbsURL(siteURL, path)
|
||||
}
|
||||
}
|
||||
if chapter, ok := asChapter(data["chapter"]); ok {
|
||||
if path := playletPath("/play", "", chapter.PlayletID); path != "" {
|
||||
return AbsURL(siteURL, path)
|
||||
}
|
||||
}
|
||||
return normalizePageURL(fallback)
|
||||
}
|
||||
|
||||
func asVideo(v any) (*videoser.VideoInfoResp, bool) {
|
||||
switch value := v.(type) {
|
||||
case *videoser.VideoInfoResp:
|
||||
if value != nil {
|
||||
return value, true
|
||||
}
|
||||
case videoser.VideoInfoResp:
|
||||
return &value, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// normalizePageURL 去掉非首页 URL 的尾斜杠,并保留查询参数。
|
||||
func normalizePageURL(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 != "/" {
|
||||
u.Path = strings.TrimRight(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, "/")
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
"videoweb/app/appg"
|
||||
)
|
||||
|
||||
// DefaultIndexNowKey 与 hgdj_server / 其它站共用的 IndexNow 密钥。
|
||||
// 对应站点根路径 /{key}.txt,内容必须等于 key 本身。
|
||||
const DefaultIndexNowKey = "6d41643810d540ce924edb982c3d3171"
|
||||
|
||||
// VerifyCodes 站长平台所有权验证(meta + 根路径文件)。
|
||||
type VerifyCodes struct {
|
||||
Baidu string
|
||||
BaiduHTML string
|
||||
IndexNowKey string
|
||||
}
|
||||
|
||||
// VerifyFromConfig 读取 yaml 中的验证码与验证文件名。
|
||||
func VerifyFromConfig() VerifyCodes {
|
||||
seo := appg.Conf.SEO
|
||||
key := sanitizeIndexNowKey(seo.IndexNowKey)
|
||||
if key == "" {
|
||||
key = DefaultIndexNowKey
|
||||
}
|
||||
return VerifyCodes{
|
||||
Baidu: strings.TrimSpace(seo.BaiduSiteVerification),
|
||||
BaiduHTML: sanitizeVerifyFile(seo.BaiduHTMLFile),
|
||||
IndexNowKey: key,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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 []TranslationCategory
|
||||
navAt time.Time
|
||||
)
|
||||
|
||||
// FetchCategoryList 拉取导航分类及每个分类对应的 seoInfo。
|
||||
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
|
||||
}
|
||||
|
||||
// FetchHeaderCategoryListOrDefault 返回接口 categoryList(带短缓存);失败时兜底。
|
||||
func FetchHeaderCategoryListOrDefault(c *gin.Context) []TranslationCategory {
|
||||
navMu.Lock()
|
||||
defer navMu.Unlock()
|
||||
if navCache != nil && time.Since(navAt) < categoryListCacheTTL {
|
||||
return append([]TranslationCategory(nil), navCache...)
|
||||
}
|
||||
|
||||
info, err := FetchCategoryList(c)
|
||||
if err != nil {
|
||||
log.Error("fetch translation category list failed", log.E(err))
|
||||
return DefaultCategoryList()
|
||||
}
|
||||
list := filterHeaderCategories(info)
|
||||
if len(list) == 0 {
|
||||
return DefaultCategoryList()
|
||||
}
|
||||
navCache = list
|
||||
navAt = time.Now()
|
||||
return append([]TranslationCategory(nil), list...)
|
||||
}
|
||||
|
||||
func filterHeaderCategories(info *AppTranslationInfo) []TranslationCategory {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]TranslationCategory, 0, len(info.CategoryList))
|
||||
for _, cat := range info.CategoryList {
|
||||
if cat.CategoryType == TranslationTypeBottom {
|
||||
continue
|
||||
}
|
||||
if cat.Name == "" && cat.URLValue == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, cat)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DefaultCategoryList 接口失败时的兜底导航(字段与接口一致)。
|
||||
func DefaultCategoryList() []TranslationCategory {
|
||||
return []TranslationCategory{
|
||||
{Name: "首页", URLValue: "/", CategoryType: TranslationTypeURL},
|
||||
{Name: "短剧", URLValue: "/list", CategoryType: TranslationTypeURL},
|
||||
{Name: "剧集", URLValue: "/series", CategoryType: TranslationTypeURL},
|
||||
{Name: "专题", URLValue: "/topic", CategoryType: TranslationTypeURL},
|
||||
{Name: "排行榜", URLValue: "/rank", CategoryType: TranslationTypeURL},
|
||||
}
|
||||
}
|
||||
|
||||
// FetchHeaderNavOrDefault 兼容旧调用:转为 HeaderNavItemVM。
|
||||
func FetchHeaderNavOrDefault(c *gin.Context) []HeaderNavItemVM {
|
||||
list := FetchHeaderCategoryListOrDefault(c)
|
||||
return BuildHeaderNav(&AppTranslationInfo{CategoryList: list})
|
||||
}
|
||||
@@ -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 // 底部导航
|
||||
)
|
||||
@@ -0,0 +1,465 @@
|
||||
package translationser
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"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 == "/tags" || strings.HasPrefix(path, "/tags/") || path == "/tag" || strings.HasPrefix(path, "/tag/") || path == "/tag-list" || strings.HasPrefix(path, "/tag-list/"):
|
||||
return "tagList"
|
||||
case path == "/topic" || strings.HasPrefix(path, "/topic/") || path == "/topic-detail" || strings.HasPrefix(path, "/topic-detail/"):
|
||||
return "topic"
|
||||
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
|
||||
return "rank"
|
||||
case path == "/recently-added" || strings.HasPrefix(path, "/recently-added/") || 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-list" || strings.HasPrefix(path, "/more-list/") || path == "/more" || strings.HasPrefix(path, "/more/") || path == "/search" || strings.HasPrefix(path, "/search/") || path == "/search-mobile" || strings.HasPrefix(path, "/search-mobile/") || path == "/popular-recommend" || strings.HasPrefix(path, "/popular-recommend/"):
|
||||
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: "/recently-added", 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
|
||||
}
|
||||
|
||||
// FindHeaderNavSEOByPath 按当前请求匹配最长导航路径。
|
||||
// 命中表示当前页面属于该导航分类,可直接使用分类接口返回的 TDK。
|
||||
func FindHeaderNavSEOByPath(items []HeaderNavItemVM, 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
|
||||
}
|
||||
return SeoValue{}, false
|
||||
}
|
||||
|
||||
// FindHeaderNavSEO 优先匹配当前请求的最长导航路径,navActive 用于无实际 URL 的导航兜底。
|
||||
func FindHeaderNavSEO(items []HeaderNavItemVM, activeKey, requestPath string) (SeoValue, bool) {
|
||||
if seo, found := FindHeaderNavSEOByPath(items, requestPath); found {
|
||||
return seo, 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
|
||||
}
|
||||
parsed.Path = rewriteNavPath(parsed.Path)
|
||||
if parsed.Path != "/" {
|
||||
// Gin redirects the trailing-slash variants of these routes to their
|
||||
// canonical forms. Emit the final URL directly so every shared header,
|
||||
// drawer and footer link does not create an avoidable permanent redirect.
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
}
|
||||
return parsed.String(), false, false
|
||||
}
|
||||
|
||||
// rewriteNavPath 将上游导航 path 对齐到本站路由(不改接口字段名,仅生成可跳转 href)。
|
||||
func rewriteNavPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
trimmed := strings.TrimRight(path, "/")
|
||||
if trimmed == "" {
|
||||
trimmed = "/"
|
||||
}
|
||||
switch trimmed {
|
||||
case "/home":
|
||||
return "/"
|
||||
case "/playlet":
|
||||
return "/list"
|
||||
case "/theme":
|
||||
return "/topic"
|
||||
case "/video":
|
||||
return "/series"
|
||||
case "/today-update":
|
||||
return "/recently-added"
|
||||
case "/popular":
|
||||
return "/popular-recommend"
|
||||
case "/tag", "/tag-list":
|
||||
return "/tags"
|
||||
case "/user-center":
|
||||
return "/profile"
|
||||
case "/purchased-series":
|
||||
return "/purchased"
|
||||
case "/search-results":
|
||||
return "/search"
|
||||
case "/more":
|
||||
return "/more-list"
|
||||
default:
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/today-update/"):
|
||||
return "/recently-added/" + strings.TrimPrefix(path, "/today-update/")
|
||||
case strings.HasPrefix(path, "/popular/"):
|
||||
return "/popular-recommend/" + strings.TrimPrefix(path, "/popular/")
|
||||
case strings.HasPrefix(path, "/tag/"):
|
||||
return "/tags/" + strings.TrimPrefix(path, "/tag/")
|
||||
case strings.HasPrefix(path, "/tag-list/"):
|
||||
return "/tags/" + strings.TrimPrefix(path, "/tag-list/")
|
||||
case strings.HasPrefix(path, "/search-results/"):
|
||||
return "/search/" + strings.TrimPrefix(path, "/search-results/")
|
||||
case strings.HasPrefix(path, "/more/"):
|
||||
return "/more-list/" + strings.TrimPrefix(path, "/more/")
|
||||
case strings.HasPrefix(path, "/topic/") && !strings.HasPrefix(path, "/topic/page/") && !strings.HasPrefix(path, "/topic/sort/"):
|
||||
// 旧专题详情嵌在 /topic/:slug,对齐到 /topic-detail/:slug
|
||||
rest := strings.TrimPrefix(path, "/topic/")
|
||||
if rest != "" && rest != "/" {
|
||||
return "/topic-detail/" + rest
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// NavHref 模板用:由 urlValue 得到本站可跳转地址。
|
||||
func NavHref(urlValue string) string {
|
||||
href, _, _ := normalizeNavURL(urlValue, "")
|
||||
if href == "" {
|
||||
return "#"
|
||||
}
|
||||
return href
|
||||
}
|
||||
|
||||
// NavIsDownload 判断后台导航项是否为 App 下载入口。
|
||||
func NavIsDownload(urlValue, name string) bool {
|
||||
if normalizeNavPath(NavHref(urlValue)) == "/download-app" {
|
||||
return true
|
||||
}
|
||||
name = strings.ToLower(strings.Join(strings.Fields(name), ""))
|
||||
return name == "下载app" || name == "downloadapp"
|
||||
}
|
||||
|
||||
// NavDrawerIcon 抽屉导航图标:按 urlValue 映射本地静态资源(不用接口 icon)。
|
||||
func NavDrawerIcon(urlValue string) string {
|
||||
path := normalizeNavPath(NavHref(urlValue))
|
||||
switch path {
|
||||
case "/":
|
||||
return "/static/images/menu_sy.svg"
|
||||
case "/list":
|
||||
return "/static/images/menu_dj.svg"
|
||||
case "/series":
|
||||
return "/static/images/menu_jj.svg"
|
||||
case "/topic":
|
||||
return "/static/images/menu_zt.svg"
|
||||
case "/rank":
|
||||
return "/static/images/menu_phb.svg"
|
||||
default:
|
||||
return "/static/images/menu_sy.svg"
|
||||
}
|
||||
}
|
||||
|
||||
// NavHasMegaMenu PC 顶栏是否展示短剧下拉(题材 / 状态 / 快捷入口)。
|
||||
func NavHasMegaMenu(urlValue string) bool {
|
||||
return normalizeNavPath(NavHref(urlValue)) == "/list"
|
||||
}
|
||||
|
||||
// ListThemeSegmentFromPath 从列表页路径解析题材段(slug 或 id);无题材时返回空。
|
||||
func ListThemeSegmentFromPath(requestPath string) string {
|
||||
parts := listPathParts(requestPath)
|
||||
if len(parts) < 2 || parts[0] != "list" {
|
||||
return ""
|
||||
}
|
||||
seg := strings.TrimSpace(parts[1])
|
||||
if seg == "" || seg == "all" || seg == "page" {
|
||||
return ""
|
||||
}
|
||||
return seg
|
||||
}
|
||||
|
||||
// ListUpdateStatusFromPath 从列表页路径解析 updateStatus;非列表筛选时返回 0(全部)。
|
||||
func ListUpdateStatusFromPath(requestPath string) int {
|
||||
parts := listPathParts(requestPath)
|
||||
// /list/{theme}/{updateStatus}/{sortType}
|
||||
if len(parts) >= 4 && parts[0] == "list" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(parts[2])); err == nil {
|
||||
switch n {
|
||||
case 1, 2:
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func listPathParts(requestPath string) []string {
|
||||
path := normalizeNavPath(requestPath)
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(path, "/")
|
||||
}
|
||||
|
||||
// NavIsActive 模板用:当前请求路径是否命中该导航 urlValue。
|
||||
func NavIsActive(urlValue, requestPath string) bool {
|
||||
href := NavHref(urlValue)
|
||||
if href == "" || href == "#" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(href)
|
||||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "javascript:") {
|
||||
return false
|
||||
}
|
||||
navPath := normalizeNavPath(href)
|
||||
cur := normalizeNavPath(requestPath)
|
||||
if navPath == cur {
|
||||
return true
|
||||
}
|
||||
// 路径前缀:/list 命中 /list/dushi/...,首页 / 不参与前缀匹配
|
||||
if navPath != "/" && strings.HasPrefix(cur, navPath+"/") {
|
||||
return true
|
||||
}
|
||||
return 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 "/recently-added":
|
||||
return "todayUpdate"
|
||||
case "/application":
|
||||
return "application"
|
||||
case "/tags":
|
||||
return "tagList"
|
||||
case "/topic":
|
||||
return "topic"
|
||||
case "/series":
|
||||
return "aiSeries"
|
||||
case "/home-road":
|
||||
return "homeRoad"
|
||||
case "/profile":
|
||||
return "userCenter"
|
||||
case "/download-app":
|
||||
return "downloadApp"
|
||||
default:
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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
|
||||
}
|
||||
|
||||
// BinaryResult 上游非 JSON 响应(如 CSV 导出)。
|
||||
type BinaryResult struct {
|
||||
StatusCode int
|
||||
ContentType string
|
||||
ContentDisposition string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
const maxBinaryBody = 16 << 20 // 16MB
|
||||
|
||||
// GetBinary 拉取上游二进制/文本文件响应(不强制 JSON Accept)。
|
||||
func GetBinary(c *gin.Context, path string) (*BinaryResult, error) {
|
||||
base := BaseURL()
|
||||
if base == "" {
|
||||
return nil, fmt.Errorf("upstream serverAddr is empty")
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, base+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyCommonHeaders(c, req)
|
||||
req.Header.Set("Accept", "text/csv,application/octet-stream,*/*")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBinaryBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &BinaryResult{
|
||||
StatusCode: resp.StatusCode,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentDisposition: resp.Header.Get("Content-Disposition"),
|
||||
Body: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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] + "..."
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package usercenterser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const centerDataPath = "/api/app/user/center/data"
|
||||
|
||||
// Data 对应 Swagger proto.UserCenterDataResp。
|
||||
type Data struct {
|
||||
WatchCount int64 `json:"watchCount"`
|
||||
CollectCount int64 `json:"collectCount"`
|
||||
ChasingCount int64 `json:"chasingCount"`
|
||||
BoughtChapterCount int64 `json:"boughtChapterCount"`
|
||||
}
|
||||
|
||||
// Fetch 获取当前用户的个人中心统计。code 为上游业务码,供会话失效重试。
|
||||
func Fetch(c *gin.Context) (*Data, int, error) {
|
||||
var env struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := upstream.GetJSON(c, centerDataPath, &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("user center data code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
var data Data
|
||||
if len(env.Data) == 0 || string(env.Data) == "null" {
|
||||
return nil, env.Code, fmt.Errorf("user center data is empty")
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &data); err != nil {
|
||||
return nil, env.Code, fmt.Errorf("decode user center data: %w", err)
|
||||
}
|
||||
return &data, env.Code, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
collectSetPath = "/api/app/video/collect/set"
|
||||
collectListPath = "/api/app/video/collect/list"
|
||||
defaultCollectPageSize = 40
|
||||
)
|
||||
|
||||
// VideoCollectSetReq 对应 swagger proto.VideoCollectSetReq。
|
||||
// POST /api/app/video/collect/set
|
||||
type VideoCollectSetReq struct {
|
||||
ID int64 `json:"id"`
|
||||
IsCollect bool `json:"isCollect"`
|
||||
}
|
||||
|
||||
// VideoCollectSetResp 对应 swagger proto.VideoCollectSetResp。
|
||||
type VideoCollectSetResp struct {
|
||||
ID int64 `json:"id"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
IsCollect int `json:"isCollect"` // 1-已收藏 2-未收藏
|
||||
}
|
||||
|
||||
// VideoCollectData 对应 swagger proto.VideoCollectData。
|
||||
// GET /api/app/video/collect/list
|
||||
type VideoCollectData struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
CoverImg string `json:"coverImg"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Tags []int64 `json:"tags"`
|
||||
VideoTime int `json:"videoTime"`
|
||||
WatchTimes int64 `json:"watchTimes"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountComment int64 `json:"countComment"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
CollectedAt string `json:"collectedAt"`
|
||||
}
|
||||
|
||||
// VideoCollectListResp 对应 swagger proto.VideoCollectListResp。
|
||||
type VideoCollectListResp struct {
|
||||
List []VideoCollectData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// CollectListQuery 视频收藏列表分页。
|
||||
type CollectListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *CollectListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultCollectPageSize
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
}
|
||||
|
||||
// CollectSet 设置视频收藏/取消收藏。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_collect_set
|
||||
func CollectSet(c *gin.Context, req VideoCollectSetReq) (*APIEnvelope[VideoCollectSetResp], error) {
|
||||
if req.ID <= 0 {
|
||||
return nil, fmt.Errorf("video collect invalid id")
|
||||
}
|
||||
var env APIEnvelope[VideoCollectSetResp]
|
||||
if _, err := upstream.PostJSON(c, collectSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchCollectList 拉取视频收藏列表:GET /api/app/video/collect/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_collect_list
|
||||
func FetchCollectList(c *gin.Context, q CollectListQuery) (*VideoCollectListResp, int, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[VideoCollectListResp]
|
||||
if err := upstream.GetJSON(c, collectListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("video collect list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
|
||||
// FetchCollectListOrEmpty 失败返回空列表;游客会话失效时尝试重登后重试一次。
|
||||
func FetchCollectListOrEmpty(c *gin.Context, q CollectListQuery) *VideoCollectListResp {
|
||||
data, code, err := FetchCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &VideoCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
data, _, err = FetchCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &VideoCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
log.Error("fetch video collect list failed", log.E(err))
|
||||
return &VideoCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const videoDetailPath = "/api/app/video/detail"
|
||||
|
||||
// FetchVideoDetail 拉取 GET /api/app/video/detail。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_detail
|
||||
func FetchVideoDetail(c *gin.Context, id int64, slug string) (*VideoInfoResp, error) {
|
||||
params := url.Values{}
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug != "" {
|
||||
params.Set("slug", slug)
|
||||
} else if id > 0 {
|
||||
params.Set("id", strconv.FormatInt(id, 10))
|
||||
} else {
|
||||
return nil, fmt.Errorf("video detail missing id/slug")
|
||||
}
|
||||
|
||||
var env APIEnvelope[VideoInfoResp]
|
||||
if err := upstream.GetJSON(c, videoDetailPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("video detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchVideoDetailByKey 路径段为纯数字时按 id,否则按 slug。
|
||||
func FetchVideoDetailByKey(c *gin.Context, key string) (*VideoInfoResp, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("video detail missing key")
|
||||
}
|
||||
if id, err := strconv.ParseInt(key, 10, 64); err == nil && id > 0 && key == strconv.FormatInt(id, 10) {
|
||||
return FetchVideoDetail(c, id, "")
|
||||
}
|
||||
return FetchVideoDetail(c, 0, key)
|
||||
}
|
||||
|
||||
// FetchVideoDetailOrNilByKey 失败时返回 nil。
|
||||
func FetchVideoDetailOrNilByKey(c *gin.Context, key string) *VideoInfoResp {
|
||||
data, err := FetchVideoDetailByKey(c, key)
|
||||
if err != nil {
|
||||
log.Error("fetch video detail failed", log.E(err), log.Any("key", key))
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ResolveVideoPageTitle 播放页标题。
|
||||
func ResolveVideoPageTitle(v *VideoInfoResp) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
if t := strings.TrimSpace(v.SeoInfo.Title); t != "" {
|
||||
return t
|
||||
}
|
||||
return strings.TrimSpace(v.Title)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
videoHistoryListPath = "/api/app/video/history/list"
|
||||
videoHistoryClearPath = "/api/app/video/history/clear"
|
||||
videoHistoryDeletePath = "/api/app/video/history/delete"
|
||||
defaultHistoryPageSize = 40
|
||||
)
|
||||
|
||||
// VideoHistoryData 对应 swagger proto.VideoHistoryData。
|
||||
// GET /api/app/video/history/list
|
||||
type VideoHistoryData struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
CoverImg string `json:"coverImg"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Tags []int64 `json:"tags"`
|
||||
VideoTime int `json:"videoTime"`
|
||||
WatchTimes int64 `json:"watchTimes"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountComment int64 `json:"countComment"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
HistoryAt string `json:"historyAt"`
|
||||
}
|
||||
|
||||
// VideoHistoryListResp 对应 swagger proto.VideoHistoryListResp。
|
||||
type VideoHistoryListResp struct {
|
||||
List []VideoHistoryData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// VideoHistoryClearResp 对应 swagger proto.VideoHistoryClearResp。
|
||||
type VideoHistoryClearResp struct {
|
||||
DeletedCount int `json:"deletedCount"`
|
||||
}
|
||||
|
||||
// VideoHistoryDeleteReq 对应 swagger proto.VideoHistoryDeleteReq。
|
||||
type VideoHistoryDeleteReq struct {
|
||||
VideoID int64 `json:"videoId"`
|
||||
}
|
||||
|
||||
// VideoHistoryDeleteResp 对应 swagger proto.VideoHistoryDeleteResp。
|
||||
type VideoHistoryDeleteResp struct {
|
||||
DeletedCount int `json:"deletedCount"`
|
||||
}
|
||||
|
||||
// HistoryListQuery 视频观看历史分页。
|
||||
type HistoryListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *HistoryListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultHistoryPageSize
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
}
|
||||
|
||||
// FetchHistoryList 分页查询视频观看历史:GET /api/app/video/history/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_history_list
|
||||
func FetchHistoryList(c *gin.Context, q HistoryListQuery) (*VideoHistoryListResp, int, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[VideoHistoryListResp]
|
||||
if err := upstream.GetJSON(c, videoHistoryListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("video history list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
|
||||
// FetchHistoryListOrEmpty 失败返回空列表;游客会话失效时尝试重登后重试一次。
|
||||
func FetchHistoryListOrEmpty(c *gin.Context, q HistoryListQuery) *VideoHistoryListResp {
|
||||
data, code, err := FetchHistoryList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &VideoHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
data, _, err = FetchHistoryList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &VideoHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
log.Error("fetch video history list failed", log.E(err))
|
||||
return &VideoHistoryListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
|
||||
// HistoryClear 清空视频观看历史:POST /api/app/video/history/clear。
|
||||
func HistoryClear(c *gin.Context) (*APIEnvelope[VideoHistoryClearResp], error) {
|
||||
var env APIEnvelope[VideoHistoryClearResp]
|
||||
if _, err := upstream.PostJSON(c, videoHistoryClearPath, nil, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// HistoryDelete 删除单条视频观看历史:POST /api/app/video/history/delete。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_history_delete
|
||||
func HistoryDelete(c *gin.Context, req VideoHistoryDeleteReq) (*APIEnvelope[VideoHistoryDeleteResp], error) {
|
||||
if req.VideoID <= 0 {
|
||||
return nil, fmt.Errorf("video history delete invalid videoId")
|
||||
}
|
||||
var env APIEnvelope[VideoHistoryDeleteResp]
|
||||
if _, err := upstream.PostJSON(c, videoHistoryDeletePath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const likeSetPath = "/api/app/video/like/set"
|
||||
|
||||
// VideoLikeSetReq 对应 swagger proto.VideoLikeSetReq。
|
||||
// POST /api/app/video/like/set
|
||||
type VideoLikeSetReq struct {
|
||||
ID int64 `json:"id"`
|
||||
IsLike bool `json:"isLike"`
|
||||
}
|
||||
|
||||
// VideoLikeSetResp 对应 swagger proto.VideoLikeSetResp。
|
||||
type VideoLikeSetResp struct {
|
||||
ID int64 `json:"id"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsLike int `json:"isLike"` // 1-已点赞 2-未点赞
|
||||
}
|
||||
|
||||
// LikeSet 设置视频点赞/取消点赞。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/post_api_app_video_like_set
|
||||
func LikeSet(c *gin.Context, req VideoLikeSetReq) (*APIEnvelope[VideoLikeSetResp], error) {
|
||||
if req.ID <= 0 {
|
||||
return nil, fmt.Errorf("video like invalid id")
|
||||
}
|
||||
var env APIEnvelope[VideoLikeSetResp]
|
||||
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const videoAllListPath = "/api/app/video/all/list"
|
||||
|
||||
// FetchVideoAllList 拉取 GET /api/app/video/all/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_all_list
|
||||
func FetchVideoAllList(c *gin.Context, q SeriesListQuery) (*VideoAllListResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("mediaType", strconv.Itoa(q.MediaType))
|
||||
params.Set("sortType", strconv.Itoa(q.SortType))
|
||||
if q.TopicID > 0 {
|
||||
params.Set("topicId", strconv.FormatInt(q.TopicID, 10))
|
||||
}
|
||||
|
||||
var env APIEnvelope[VideoAllListResp]
|
||||
if err := upstream.GetPublicJSON(c, videoAllListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("video all list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchVideoAllListOrEmpty 失败时返回空列表。
|
||||
func FetchVideoAllListOrEmpty(c *gin.Context, q SeriesListQuery) *VideoAllListResp {
|
||||
data, err := FetchVideoAllList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch video all list failed", log.E(err))
|
||||
return &VideoAllListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &VideoAllListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// BuildSeriesListPagination 剧集列表分页。
|
||||
func BuildSeriesListPagination(q SeriesListQuery, totalCount int64) playletser.PaginationVM {
|
||||
q.Normalize()
|
||||
return playletser.BuildPagination(func(p int) string {
|
||||
nq := q
|
||||
nq.PageNum = p
|
||||
return nq.URL()
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
videoTopicListPath = "/api/app/video/topic/list"
|
||||
|
||||
// mediaType:0全部 1影集 2动画
|
||||
MediaAll = 0
|
||||
MediaFilm = 1
|
||||
MediaAnime = 2
|
||||
|
||||
// sortType:1最新 2最热 3收藏最多
|
||||
SortNewest = 1
|
||||
SortHeat = 2
|
||||
SortCollect = 3
|
||||
|
||||
defaultSeriesPageSize = 16
|
||||
)
|
||||
|
||||
// SeriesListQuery 剧集页筛选:对应 video/all/list + topic/list 的 mediaType。
|
||||
type SeriesListQuery struct {
|
||||
MediaType int
|
||||
TopicID int64
|
||||
SortType int
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// Normalize 校正默认值。
|
||||
func (q *SeriesListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultSeriesPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
switch q.MediaType {
|
||||
case MediaFilm, MediaAnime:
|
||||
default:
|
||||
q.MediaType = MediaAll
|
||||
}
|
||||
switch q.SortType {
|
||||
case SortHeat, SortCollect:
|
||||
default:
|
||||
q.SortType = SortNewest
|
||||
}
|
||||
if q.TopicID < 0 {
|
||||
q.TopicID = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ParseSeriesListQuery 从路径解析剧集筛选。
|
||||
// 路由:/series/ 、/series/:media/:topic/:sortType/ 、
|
||||
//
|
||||
// /series/:media/:topic/:sortType/page/:page/
|
||||
func ParseSeriesListQuery(c *gin.Context) SeriesListQuery {
|
||||
q := SeriesListQuery{PageSize: defaultSeriesPageSize}
|
||||
q.MediaType = parseMediaSegment(c.Param("media"))
|
||||
topic := strings.TrimSpace(c.Param("topic"))
|
||||
if topic != "" && !strings.EqualFold(topic, "all") {
|
||||
if id, err := strconv.ParseInt(topic, 10, 64); err == nil && id > 0 {
|
||||
q.TopicID = id
|
||||
}
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("sortType"))); err == nil {
|
||||
q.SortType = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(c.Param("page"))); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
func parseMediaSegment(raw string) int {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
switch raw {
|
||||
case "", "all", "0":
|
||||
return MediaAll
|
||||
case "film", "1", "yingji":
|
||||
return MediaFilm
|
||||
case "anime", "2", "donghua":
|
||||
return MediaAnime
|
||||
default:
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
return n
|
||||
}
|
||||
return MediaAll
|
||||
}
|
||||
}
|
||||
|
||||
func mediaSegment(mediaType int) string {
|
||||
switch mediaType {
|
||||
case MediaFilm:
|
||||
return "film"
|
||||
case MediaAnime:
|
||||
return "anime"
|
||||
default:
|
||||
return "all"
|
||||
}
|
||||
}
|
||||
|
||||
func topicSegment(topicID int64) string {
|
||||
if topicID > 0 {
|
||||
return strconv.FormatInt(topicID, 10)
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
// SeriesListURL 生成剧集页筛选链接(纯路径)。
|
||||
func SeriesListURL(mediaType int, topicID int64, sortType, pageNum int) string {
|
||||
q := SeriesListQuery{
|
||||
MediaType: mediaType,
|
||||
TopicID: topicID,
|
||||
SortType: sortType,
|
||||
PageNum: pageNum,
|
||||
PageSize: defaultSeriesPageSize,
|
||||
}
|
||||
q.Normalize()
|
||||
return q.URL()
|
||||
}
|
||||
|
||||
// URL 输出:/series/ 或 /series/{media}/{topic}/{sort}/[page/{n}/]
|
||||
func (q SeriesListQuery) URL() string {
|
||||
q.Normalize()
|
||||
defaults := q.MediaType == MediaAll && q.TopicID == 0 && q.SortType == SortNewest
|
||||
if defaults && q.PageNum <= 1 {
|
||||
return "/series"
|
||||
}
|
||||
path := "/series/" + mediaSegment(q.MediaType) + "/" + topicSegment(q.TopicID) + "/" + strconv.Itoa(q.SortType)
|
||||
if q.PageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(q.PageNum)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// FetchVideoTopicList 拉取 GET /api/app/video/topic/list。
|
||||
// 文档:http://172.104.181.99:25319/swagger/index.html#/视频相关接口/get_api_app_video_topic_list
|
||||
func FetchVideoTopicList(c *gin.Context, mediaType int) ([]VideoTopicInfo, error) {
|
||||
switch mediaType {
|
||||
case MediaFilm, MediaAnime:
|
||||
default:
|
||||
mediaType = MediaAll
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("mediaType", strconv.Itoa(mediaType))
|
||||
|
||||
var env APIEnvelope[VideoTopicListResp]
|
||||
if err := upstream.GetPublicJSON(c, videoTopicListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("video topic list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return env.Data.List, nil
|
||||
}
|
||||
|
||||
// FetchVideoTopicListOrEmpty 失败时返回空题材。
|
||||
func FetchVideoTopicListOrEmpty(c *gin.Context, mediaType int) []VideoTopicInfo {
|
||||
list, err := FetchVideoTopicList(c, mediaType)
|
||||
if err != nil {
|
||||
log.Error("fetch video topic list failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
out := make([]VideoTopicInfo, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item.ID <= 0 || item.DisplayName() == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/configser"
|
||||
)
|
||||
|
||||
// APIEnvelope 上游统一响应壳。
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
// VideoTopicInfo 对应 swagger proto.VideoTopicInfo。
|
||||
type VideoTopicInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Desc string `json:"desc"`
|
||||
Image string `json:"image"`
|
||||
VideoCategoryID int64 `json:"videoCategoryId"`
|
||||
Works int64 `json:"works"`
|
||||
SeoInfo SeoValue `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// SeoValue 对应 swagger pubdef.SeoValue。
|
||||
type SeoValue struct {
|
||||
Title string `json:"title"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// VideoTopicListResp 对应 swagger proto.VideoTopicListResp。
|
||||
// GET /api/app/video/topic/list
|
||||
type VideoTopicListResp struct {
|
||||
List []VideoTopicInfo `json:"list"`
|
||||
}
|
||||
|
||||
// VideoAllListItemResp 对应 swagger proto.VideoAllListItemResp。
|
||||
type VideoAllListItemResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
CoverImg string `json:"coverImg"`
|
||||
CreatorID int64 `json:"creatorId"`
|
||||
CreatorName string `json:"creatorName"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Tags []int64 `json:"tags"`
|
||||
MediaType int `json:"mediaType"` // 1影集 2动画
|
||||
TopicIDs []int64 `json:"topicIds"`
|
||||
TopicNames []string `json:"topicNames"`
|
||||
Topics []VideoTopicInfo `json:"topics"`
|
||||
VideoCount int `json:"videoCount"`
|
||||
VideoTime int `json:"videoTime"`
|
||||
WatchTimes int64 `json:"watchTimes"`
|
||||
AddedTime string `json:"addedTime"`
|
||||
}
|
||||
|
||||
// VideoAllListResp 对应 swagger proto.VideoAllListResp。
|
||||
// GET /api/app/video/all/list
|
||||
type VideoAllListResp struct {
|
||||
List []VideoAllListItemResp `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
Advitise configser.AdvitiseData `json:"advitise"`
|
||||
}
|
||||
|
||||
// VideoItemResp 对应 swagger proto.VideoItemResp(推荐列表等)。
|
||||
type VideoItemResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
CoverImg string `json:"coverImg"`
|
||||
CreatorID int64 `json:"creatorId"`
|
||||
CreatorName string `json:"creatorName"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Tags []int64 `json:"tags"`
|
||||
VideoTime int `json:"videoTime"`
|
||||
WatchTimes int64 `json:"watchTimes"`
|
||||
AddedTime string `json:"addedTime"`
|
||||
}
|
||||
|
||||
// VideoData 对应 swagger proto.VideoData。
|
||||
type VideoData struct {
|
||||
TaskID string `json:"taskId"`
|
||||
FileID string `json:"fileId"`
|
||||
URL string `json:"url"`
|
||||
Cover string `json:"cover"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
VideoTime int `json:"videoTime"`
|
||||
PreMp4FileNameFs string `json:"preMp4FileNameFs"`
|
||||
UnencryptedPreMp4 string `json:"unencryptedPreMp4"`
|
||||
}
|
||||
|
||||
// VideoInfoResp 对应 swagger proto.VideoInfoResp(含 recommendVideos)。
|
||||
// GET /api/app/video/detail
|
||||
type VideoInfoResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
CoverImg string `json:"coverImg"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Tags []int64 `json:"tags"`
|
||||
TagsName []string `json:"tagsName"`
|
||||
WatchTimes int64 `json:"watchTimes"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountComment int64 `json:"countComment"`
|
||||
IsLike int `json:"isLike"` // 1-已点赞 2-未点赞
|
||||
IsCollect int `json:"isCollect"` // 1-已收藏 2-未收藏
|
||||
Video VideoData `json:"video"`
|
||||
RecommendVideos []VideoItemResp `json:"recommendVideos"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
Advitise configser.AdvitiseData `json:"advitise"`
|
||||
AddedTime string `json:"addedTime"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// SeoInfo 对应 swagger proto.SeoInfo(视频列表页)。
|
||||
type SeoInfo struct {
|
||||
Title string `json:"title"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Description string `json:"description"`
|
||||
WebSiteName string `json:"webSiteName"`
|
||||
CanonicalURL string `json:"canonicalUrl"`
|
||||
}
|
||||
|
||||
// DisplayName 题材展示名:优先 name,其次 title。
|
||||
func (t VideoTopicInfo) DisplayName() string {
|
||||
if name := strings.TrimSpace(t.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(t.Title)
|
||||
}
|
||||
|
||||
// MediaTypeLabel 影集 / 动画。
|
||||
func MediaTypeLabel(mediaType int) string {
|
||||
if mediaType == MediaAnime {
|
||||
return "动画"
|
||||
}
|
||||
return "影集"
|
||||
}
|
||||
|
||||
// EpisodeLabel 集数或时长展示。
|
||||
func EpisodeLabel(item VideoAllListItemResp) string {
|
||||
if item.VideoCount > 0 {
|
||||
return "全 " + strconv.Itoa(item.VideoCount) + " 集"
|
||||
}
|
||||
if item.VideoTime > 0 {
|
||||
return strconv.Itoa(item.VideoTime) + "分钟"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const defaultRecommendPageSize = 6
|
||||
|
||||
// SliceRecommendPage 猜你喜欢分页切片(详情 recommendVideos,最多 20 条)。
|
||||
func SliceRecommendPage(list []VideoItemResp, page, pageSize int) (out []VideoItemResp, pageNum int) {
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultRecommendPageSize
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
n := len(list)
|
||||
if n == 0 {
|
||||
return nil, 1
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start >= n {
|
||||
page = 1
|
||||
start = 0
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > n {
|
||||
end = n
|
||||
}
|
||||
return list[start:end], page
|
||||
}
|
||||
|
||||
// VideoDetailURL 剧集播放页入口:优先 slug,其次 id → /series-play/{key}/。
|
||||
func VideoDetailURL(id int64) string {
|
||||
return VideoPlayURL("", id)
|
||||
}
|
||||
|
||||
// VideoPlayURL 剧集播放页入口(slug 优先,无 slug 再用 id)。
|
||||
func VideoPlayURL(slug string, id int64) string {
|
||||
seg := strings.TrimSpace(slug)
|
||||
if seg == "" && id > 0 {
|
||||
seg = strconv.FormatInt(id, 10)
|
||||
}
|
||||
if seg == "" {
|
||||
return "/series"
|
||||
}
|
||||
return "/series-play/" + url.PathEscape(seg)
|
||||
}
|
||||
Reference in New Issue
Block a user