Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-09-29 11:47:47 +07:00
co-authored by Claude Opus 5.5
commit 0fbdba5ccf
622 changed files with 72105 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
package applistser
import (
"fmt"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const listPath = "/api/app/applist/list"
// FetchList 拉取应用分类列表。categoryId=0 表示默认。
func FetchList(c *gin.Context, categoryID int64) (*AppListResp, error) {
path := fmt.Sprintf("%s?categoryId=%d", listPath, categoryID)
var env APIEnvelope[AppListResp]
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("applist code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchListOrEmpty 失败时返回空结构,保证 SSR 可渲染。
func FetchListOrEmpty(c *gin.Context, categoryID int64) *AppListResp {
data, err := FetchList(c, categoryID)
if err != nil {
log.Error("fetch applist failed", log.E(err))
return &AppListResp{}
}
if data == nil {
return &AppListResp{}
}
return data
}
+38
View File
@@ -0,0 +1,38 @@
package applistser
// AppListResp 对应 swagger proto.AppListResp 的业务字段(忽略无关 config/advitise)。
type AppListResp struct {
Category AppCategoryInfo `json:"category"`
CategoryList []AppCategoryInfo `json:"categoryList"`
}
// AppCategoryInfo 对应 swagger proto.AppCategoryInfo
type AppCategoryInfo struct {
ID int64 `json:"id"`
Code int `json:"code"`
Title string `json:"title"`
Intro string `json:"intro"`
Sort int `json:"sort"`
AppList []AppInfoData `json:"appList"`
}
// AppInfoData 对应 swagger proto.AppInfoData
type AppInfoData struct {
ID int64 `json:"id"`
CategoryID int64 `json:"categoryId"`
Name string `json:"name"`
Intro string `json:"intro"`
Description string `json:"description"`
Logo string `json:"logo"`
URL string `json:"url"`
Sort int `json:"sort"`
AdvitiseID string `json:"advitiseId"`
Location int `json:"location"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
+83
View File
@@ -0,0 +1,83 @@
package applistser
import (
"fmt"
"strings"
)
// PageVM 福利应用页模板数据
type PageVM struct {
Categories []CategoryVM
}
type CategoryVM struct {
ID int64
SectionID string
Title string
Intro string
Total int
Apps []AppCardVM
IsFirst bool
}
type AppCardVM struct {
ID int64
Name string
Intro string
Logo string
URL string
}
// BuildPageVM 按接口返回的 categoryList / appList 顺序组装页面数据,不做二次排序。
func BuildPageVM(data *AppListResp) PageVM {
vm := PageVM{Categories: []CategoryVM{}}
if data == nil {
return vm
}
list := data.CategoryList
if len(list) == 0 && data.Category.ID > 0 {
list = []AppCategoryInfo{data.Category}
}
if len(list) == 0 {
return vm
}
for i, cat := range list {
cards := make([]AppCardVM, 0, len(cat.AppList))
for _, app := range cat.AppList {
name := strings.TrimSpace(app.Name)
if name == "" {
continue
}
href := strings.TrimSpace(app.URL)
if href == "" {
href = "#"
}
intro := strings.TrimSpace(app.Intro)
if intro == "" {
intro = strings.TrimSpace(app.Description)
}
cards = append(cards, AppCardVM{
ID: app.ID,
Name: name,
Intro: intro,
Logo: strings.TrimSpace(app.Logo),
URL: href,
})
}
title := strings.TrimSpace(cat.Title)
if title == "" {
title = "应用分类"
}
vm.Categories = append(vm.Categories, CategoryVM{
ID: cat.ID,
SectionID: fmt.Sprintf("app-category-%d", cat.ID),
Title: title,
Intro: strings.TrimSpace(cat.Intro),
Total: len(cards),
Apps: cards,
IsFirst: i == 0,
})
}
return vm
}
+290
View File
@@ -0,0 +1,290 @@
package authser
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const (
registerPath = "/api/app/auth/register"
loginPath = "/api/app/auth/login"
resetPath = "/api/app/auth/resetPassword"
guestPath = "/api/app/auth/guest"
getInfoPath = "/api/app/auth/getInfo"
profilePath = "/api/app/auth/profile"
uploadPath = "/api/app/upload/image"
cookieMaxAge = 7 * 24 * 3600
)
// Register 调用上游注册接口。
func Register(c *gin.Context, req RegisterReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, registerPath, req, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// ResetPassword 通过账号和邮箱校验后重置密码。
func ResetPassword(c *gin.Context, req ResetPasswordReq) (*APIEnvelope, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, resetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// Login 调用上游登录接口。
func Login(c *gin.Context, req LoginReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, loginPath, req, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// GuestLogin 调用上游游客登录(按设备 ID 创建或找回游客账号)。
func GuestLogin(c *gin.Context, req GuestLoginReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, guestPath, req, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// GetInfo 通过 token 拉取最新用户信息。
func GetInfo(c *gin.Context) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if err := upstream.GetJSON(c, getInfoPath, &env); err != nil {
return nil, nil, err
}
if env.Code != 200 {
return &env, nil, nil
}
user, err := decodeLoginData(env.Data)
if err != nil {
return &env, nil, err
}
return &env, user, nil
}
// UpdateProfile 修改资料(昵称 / 头像等)。
func UpdateProfile(c *gin.Context, req UpdateProfileReq) (*APIEnvelope, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, profilePath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// UploadImage 上传图片;头像场景不加水印。
func UploadImage(c *gin.Context, fileName string, r io.Reader) (*ImageUploadResp, error) {
var resp ImageUploadResp
_, err := upstream.PostMultipart(c, uploadPath, map[string]string{
"watermark": "0",
}, &upstream.MultipartFile{
FieldName: "file",
FileName: fileName,
Reader: r,
}, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
func decodeLoginData(raw json.RawMessage) (*LoginResp, error) {
if len(raw) == 0 || string(raw) == "null" || string(raw) == `""` {
return nil, fmt.Errorf("empty login data")
}
var user LoginResp
if err := json.Unmarshal(raw, &user); err != nil {
return nil, err
}
return &user, nil
}
// SetAuthCookies 只持久化 SSR 与前端登录态实际依赖的字段。
// 个人简介、邮箱、邀请码、展示卡信息由 getInfo 即时提供,不再随每个页面响应重复写入;
// sid 已存在于请求时也无需原样回写。ClearAuthCookies 仍保留这些历史键用于兼容清理。
func SetAuthCookies(c *gin.Context, user *LoginResp) {
if c == nil || user == nil {
return
}
secure := c.Request != nil && c.Request.TLS != nil
if strings.TrimSpace(user.Token) != "" {
setCookie(c, "token", user.Token, secure)
}
setCookie(c, "userId", strconv.FormatInt(user.UserID, 10), secure)
setCookie(c, "accountNo", user.AccountNo, secure)
setCookie(c, "nickName", user.NickName, secure)
setCookie(c, "avatar", user.Avatar, secure)
setCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10), secure)
setCookie(c, "vipType", strconv.Itoa(user.VipType), secure)
setCookie(c, "currentVipExpire", user.CurrentVipExpire, secure)
setCookie(c, "userType", strconv.Itoa(user.Type), secure)
isReg := "0"
if user.IsRegister || user.Type == 1 {
isReg = "1"
}
setCookie(c, "isRegister", isReg, secure)
// 后续 SSR 服务必须能立即读取本次刚建立/刷新的会话;同时记录为本请求的
// 权威结果,避免 HTML 渲染阶段再次调用 getInfo 并重复写整套 Cookie。
patchAuthCookiesToRequest(c, user)
rememberRequestAuthUser(c, user)
}
func setCookie(c *gin.Context, name, value string, secure bool) {
// Cookie Value 仅允许 ASCII;中文昵称等需 URL 编码。
// 使用 PathEscape(空格→%20),与前端 encodeURIComponent 一致;
// 避免 QueryEscape 的空格→+,否则 JS decodeURIComponent 会留下字面量 "+"。
writeCookie(c, &http.Cookie{
Name: name,
Value: url.PathEscape(value),
Path: "/",
MaxAge: cookieMaxAge,
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(7 * 24 * time.Hour),
})
}
// writeCookie 对同一响应中同名、同 Path、同 Domain 的 Cookie 做 upsert,避免会话恢复的
// 「先清除、再重建」产生两套响应头,同时保留其他作用域下可能存在的同名 Cookie。
func writeCookie(c *gin.Context, cookie *http.Cookie) {
if c == nil || c.Writer == nil || cookie == nil || cookie.Name == "" {
return
}
header := c.Writer.Header()
existing := header.Values("Set-Cookie")
if len(existing) > 0 {
header.Del("Set-Cookie")
for _, raw := range existing {
if sameCookieScope(raw, cookie) {
continue
}
header.Add("Set-Cookie", raw)
}
}
http.SetCookie(c.Writer, cookie)
}
func sameCookieScope(raw string, target *http.Cookie) bool {
response := &http.Response{Header: http.Header{"Set-Cookie": []string{raw}}}
for _, existing := range response.Cookies() {
if existing.Name == target.Name && existing.Path == target.Path && strings.EqualFold(existing.Domain, target.Domain) {
return true
}
}
return false
}
func cookieValue(c *gin.Context, name string) string {
ck, err := c.Request.Cookie(name)
if err != nil || ck == nil {
return ""
}
raw := strings.TrimSpace(ck.Value)
if raw == "" {
return ""
}
// QueryUnescape 同时兼容历史 QueryEscape(+)与 PathEscape(%20)
if decoded, err := url.QueryUnescape(raw); err == nil {
return strings.TrimSpace(decoded)
}
return raw
}
// ClearAuthCookies 清除登录相关 cookie。
func ClearAuthCookies(c *gin.Context) {
forgetRequestAuthUser(c)
for _, name := range authCookieNames() {
writeCookie(c, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}
}
func authCookieNames() []string {
return []string{
"token", "_token_", "sid", "_s_token_",
"userId", "accountNo", "nickName", "introduction", "avatar",
"email", "inviteCode", "recharge", "vipType", "currentVipExpire", "showCardId", "showCardName",
"userType", "isRegister",
}
}
// AuthFromCookies 从 cookie 读取登录态,供 SSR 头部 / 个人中心展示。
func AuthFromCookies(c *gin.Context) (loggedIn bool, userName, avatar, email string) {
token := useragent.TokenFromRequest(c.Request)
if token == "" {
return false, "", "", ""
}
userName = cookieValue(c, "nickName")
if userName == "" {
userName = cookieValue(c, "accountNo")
}
avatar = usableAvatarURL(cookieValue(c, "avatar"))
email = cookieValue(c, "email")
return true, userName, avatar, email
}
// IsRegisteredFromCookies 是否正式注册账号(非游客)。
func IsRegisteredFromCookies(c *gin.Context) bool {
if useragent.TokenFromRequest(c.Request) == "" {
return false
}
if cookieValue(c, "isRegister") == "1" {
return true
}
return cookieValue(c, "userType") == "1"
}
func usableAvatarURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "/static/") {
return raw
}
// 无域名的相对路径暂不可用(解密拉取需要完整 URL)
return ""
}
+314
View File
@@ -0,0 +1,314 @@
package authser
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
const guestDevIDCookie = "guest_dev_id"
const guestSkipAutoCookie = "guest_skip_auto"
// EnsureGuestOnEnter 页面进入时:无 token(未自己登录)则走游客登录并写 cookie。
// 对应上游 POST /api/app/auth/guest。成功后会把会话补进当前 Request,便于同一次 SSR 读到登录态。
// 手动退出后带 guest_skip_auto 时跳过本次;清掉标记后,下次进页再自动游客。
func EnsureGuestOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if user := freshRequestAuthUser(c); user != nil {
return user
}
if requestAutoAuthSuppressed(c) {
return nil
}
// 已有任意会话(游客或正式账号)则跳过
if useragent.TokenFromRequest(c.Request) != "" {
return nil
}
// 手动退出:本次进页不自动游客,并清除标记,供下次进页恢复
if IsGuestSkipAuto(c) {
ClearGuestSkipAuto(c)
dropRequestCookie(c, guestSkipAutoCookie)
suppressRequestAutoAuth(c)
return nil
}
devID := ensureGuestDevID(c)
env, user, err := GuestLogin(c, GuestLoginReq{
DevID: devID,
DeviceID: devID,
Channel: guestChannel(c),
})
if err != nil {
log.Error("ensure guest on enter failed", log.E(err))
return nil
}
if env == nil || env.Code != 200 || user == nil || strings.TrimSpace(user.Token) == "" {
tip := ""
if env != nil {
tip = firstNonEmptyTip(env.Tip, env.Msg)
}
log.Error("ensure guest on enter rejected", log.E(fmt.Errorf("code=%v tip=%s", envCode(env), tip)))
return nil
}
SetAuthCookies(c, user)
return user
}
// SyncUserOnEnter 是 SSR 页面统一的会话同步入口。同一请求已经通过游客登录、
// getInfo 或其他认证流程取得过最新用户时直接复用,避免再次访问上游和重复写 Cookie。
func SyncUserOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if user := freshRequestAuthUser(c); user != nil {
return user
}
if requestAutoAuthSuppressed(c) {
return nil
}
if useragent.TokenFromRequest(c.Request) == "" {
return EnsureGuestOnEnter(c)
}
return RefreshUserOnEnter(c)
}
// RefreshUserOnEnter 页面进入时用 GET /api/app/auth/getInfo 刷新用户信息并写回 cookie。
// 保证刷新后头部昵称、头像、金币等与上游一致(如解锁后余额变化)。
func RefreshUserOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if user := freshRequestAuthUser(c); user != nil {
return user
}
if requestAutoAuthSuppressed(c) {
return nil
}
token := useragent.TokenFromRequest(c.Request)
if token == "" {
return nil
}
env, user, err := GetInfo(c)
if err != nil {
log.Error("refresh user on enter failed", log.E(err))
return nil
}
if env != nil && IsSessionExpiredCode(env.Code) {
if retry, _ := OnSessionExpired(c, env.Code); retry {
// 游客重建已经返回完整用户并写好 Cookie,无需立刻再 getInfo 一次。
if fresh := freshRequestAuthUser(c); fresh != nil {
return fresh
}
env, user, err = GetInfo(c)
if err != nil {
log.Error("refresh user on enter retry failed", log.E(err))
return nil
}
} else {
return nil
}
}
if env == nil || env.Code != 200 || user == nil {
return nil
}
if strings.TrimSpace(user.Token) == "" {
user.Token = useragent.TokenFromRequest(c.Request)
if user.Token == "" {
user.Token = token
}
}
SetAuthCookies(c, user)
return user
}
// RefreshGuestSession 游客会话失效时(如上游 6001 用户不存在)重新游客登录。
// 正式注册账号不处理,避免把用户踢成游客。
func RefreshGuestSession(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
if IsRegisteredFromCookies(c) {
return nil
}
forgetRequestAuthUser(c)
dropRequestCookie(c, "token")
dropRequestCookie(c, "_token_")
return EnsureGuestOnEnter(c)
}
func dropRequestCookie(c *gin.Context, name string) {
if c == nil || c.Request == nil || name == "" {
return
}
var parts []string
for _, ck := range c.Request.Cookies() {
if ck == nil || ck.Name == name {
continue
}
parts = append(parts, ck.Name+"="+ck.Value)
}
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
}
// MarkGuestSkipAuto 手动退出后标记:跳过紧接着的一次自动游客登录。
func MarkGuestSkipAuto(c *gin.Context) {
if c == nil {
return
}
suppressRequestAutoAuth(c)
secure := c.Request != nil && c.Request.TLS != nil
writeCookie(c, &http.Cookie{
Name: guestSkipAutoCookie,
Value: "1",
Path: "/",
MaxAge: 0, // session cookie
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// IsGuestSkipAuto 是否处于「手动退出后跳过自动游客」状态。
func IsGuestSkipAuto(c *gin.Context) bool {
return cookieValue(c, guestSkipAutoCookie) == "1"
}
// ClearGuestSkipAuto 清除手动退出跳过标记。
func ClearGuestSkipAuto(c *gin.Context) {
if c == nil {
return
}
writeCookie(c, &http.Cookie{
Name: guestSkipAutoCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}
func ensureGuestDevID(c *gin.Context) string {
if id := cookieValue(c, guestDevIDCookie); id != "" {
return id
}
id := newGuestDevID()
secure := c.Request != nil && c.Request.TLS != nil
setCookie(c, guestDevIDCookie, id, secure)
patchRequestCookie(c, guestDevIDCookie, id)
return id
}
func newGuestDevID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
now := time.Now()
return fmt.Sprintf("%016X%d", uint64(now.UnixNano()), now.UnixMilli())
}
return strings.ToUpper(hex.EncodeToString(b)) + strconv.FormatInt(time.Now().UnixMilli(), 10)
}
// ResolveChannel 统一解析渠道码并按首次归因落盘。
// 优先级:已有 channel cookie > 调用方传入值 > dc/ch/pc 查询参数;已有渠道不覆盖。
func ResolveChannel(c *gin.Context, candidate string) string {
channel := strings.TrimSpace(candidate)
if c == nil || c.Request == nil {
return channel
}
if stored := cookieValue(c, "channel"); stored != "" {
return stored
}
if channel == "" {
for _, key := range []string{"dc", "ch", "pc"} {
if value := strings.TrimSpace(c.Query(key)); value != "" {
channel = value
break
}
}
}
if channel == "" {
return ""
}
secure := c.Request.TLS != nil
setCookie(c, "channel", channel, secure)
patchRequestCookie(c, "channel", channel)
return channel
}
// guestChannel 为首屏服务端自动游客登录补渠道;cookie 用于复用前端首次归因结果。
func guestChannel(c *gin.Context) string {
return ResolveChannel(c, "")
}
func patchAuthCookiesToRequest(c *gin.Context, user *LoginResp) {
if c == nil || user == nil {
return
}
if strings.TrimSpace(user.Token) != "" {
patchRequestCookie(c, "token", user.Token)
}
patchRequestCookie(c, "userId", strconv.FormatInt(user.UserID, 10))
patchRequestCookie(c, "accountNo", user.AccountNo)
patchRequestCookie(c, "nickName", user.NickName)
patchRequestCookie(c, "introduction", user.Introduction)
patchRequestCookie(c, "avatar", user.Avatar)
patchRequestCookie(c, "email", user.Email)
patchRequestCookie(c, "inviteCode", user.InviteCode)
patchRequestCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10))
patchRequestCookie(c, "vipType", strconv.Itoa(user.VipType))
patchRequestCookie(c, "currentVipExpire", user.CurrentVipExpire)
patchRequestCookie(c, "showCardId", strconv.FormatInt(user.ShowCardID, 10))
patchRequestCookie(c, "showCardName", user.ShowCardName)
patchRequestCookie(c, "userType", strconv.Itoa(user.Type))
isReg := "0"
if user.IsRegister || user.Type == 1 {
isReg = "1"
}
patchRequestCookie(c, "isRegister", isReg)
}
func patchRequestCookie(c *gin.Context, name, value string) {
if c == nil || c.Request == nil || name == "" {
return
}
escaped := url.PathEscape(value)
var parts []string
for _, ck := range c.Request.Cookies() {
if ck == nil || ck.Name == name {
continue
}
parts = append(parts, ck.Name+"="+ck.Value)
}
parts = append(parts, name+"="+escaped)
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
}
func envCode(env *APIEnvelope) int {
if env == nil {
return 0
}
return env.Code
}
func firstNonEmptyTip(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+345
View File
@@ -0,0 +1,345 @@
package authser
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"videoweb/app/appg"
"videoweb/pub/common/useragent"
"github.com/gin-gonic/gin"
)
var authCookieNamesWritten = []string{
"token",
"userId",
"accountNo",
"nickName",
"avatar",
"recharge",
"vipType",
"currentVipExpire",
"userType",
"isRegister",
}
func TestSyncUserOnEnterCreatesGuestWithoutRefreshingIt(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case guestPath:
guestCalls.Add(1)
if r.Method != http.MethodPost {
t.Errorf("guest method = %s, want POST", r.Method)
}
var request GuestLoginReq
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode guest request: %v", err)
}
if request.DevID == "" || request.DeviceID != request.DevID {
t.Errorf("guest device ids = %#v, want matching non-empty ids", request)
}
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "new-guest"))
case getInfoPath:
getInfoCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("guest-token", "unexpected-refresh"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext()
user := SyncUserOnEnter(c)
if user == nil || user.Token != "guest-token" {
t.Fatalf("SyncUserOnEnter user = %#v, want guest-token", user)
}
if got := guestCalls.Load(); got != 1 {
t.Errorf("guest calls = %d, want 1", got)
}
if got := getInfoCalls.Load(); got != 0 {
t.Errorf("getInfo calls = %d, want 0", got)
}
assertRequestToken(t, c, "guest-token")
assertSingleAuthCookieSet(t, recorder)
}
func TestSyncUserOnEnterRefreshesExistingTokenOnlyOnce(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
authorization := make(chan string, 1)
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case guestPath:
guestCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("unexpected-guest-token", "unexpected-guest"))
case getInfoPath:
getInfoCalls.Add(1)
select {
case authorization <- r.Header.Get("Authorization"):
default:
}
// getInfo may omit token; the existing request token must be preserved.
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("", "fresh-user"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext(
&http.Cookie{Name: "token", Value: "existing-token"},
&http.Cookie{Name: "sid", Value: "existing-sid"},
)
first := SyncUserOnEnter(c)
second := SyncUserOnEnter(c)
if first == nil || first.Token != "existing-token" || first.NickName != "fresh-user" {
t.Fatalf("first SyncUserOnEnter user = %#v, want refreshed user with existing token", first)
}
if second == nil || second.Token != "existing-token" || second.NickName != "fresh-user" {
t.Fatalf("second SyncUserOnEnter user = %#v, want memoized refreshed user", second)
}
if got := guestCalls.Load(); got != 0 {
t.Errorf("guest calls = %d, want 0", got)
}
if got := getInfoCalls.Load(); got != 1 {
t.Errorf("getInfo calls = %d, want 1", got)
}
select {
case got := <-authorization:
if got != "existing-token" {
t.Errorf("Authorization = %q, want existing-token", got)
}
default:
t.Error("getInfo did not capture Authorization header")
}
assertRequestToken(t, c, "existing-token")
if got := cookieValue(c, "nickName"); got != "fresh-user" {
t.Errorf("request nickName cookie = %q, want fresh-user", got)
}
assertSingleAuthCookieSet(t, recorder)
}
func TestSyncUserOnEnterReusesGuestCreatedBeforeRender(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case guestPath:
guestCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "pre-render-guest"))
case getInfoPath:
getInfoCalls.Add(1)
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("pre-render-token", "unexpected-refresh"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext()
guest := EnsureGuestOnEnter(c)
user := SyncUserOnEnter(c)
if guest == nil || guest.Token != "pre-render-token" {
t.Fatalf("EnsureGuestOnEnter user = %#v, want pre-render guest", guest)
}
if user == nil || user.Token != "pre-render-token" || user.NickName != "pre-render-guest" {
t.Fatalf("SyncUserOnEnter user = %#v, want memoized pre-render guest", user)
}
if got := guestCalls.Load(); got != 1 {
t.Errorf("guest calls = %d, want 1", got)
}
if got := getInfoCalls.Load(); got != 0 {
t.Errorf("getInfo calls = %d, want 0", got)
}
assertRequestToken(t, c, "pre-render-token")
assertSingleAuthCookieSet(t, recorder)
}
func TestRefreshUserOnEnterReturnsRebuiltGuestWithoutRefreshingItAgain(t *testing.T) {
var guestCalls atomic.Int32
var getInfoCalls atomic.Int32
getInfoAuthorization := make(chan string, 1)
guestAuthorization := make(chan string, 1)
useAuthTestUpstream(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case getInfoPath:
getInfoCalls.Add(1)
select {
case getInfoAuthorization <- r.Header.Get("Authorization"):
default:
}
writeAuthTestEnvelope(w, http.StatusOK, 6001, nil)
case guestPath:
guestCalls.Add(1)
select {
case guestAuthorization <- r.Header.Get("Authorization"):
default:
}
writeAuthTestEnvelope(w, http.StatusOK, 200, testLoginUser("rebuilt-guest-token", "rebuilt-guest"))
default:
t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
c, recorder := newAuthTestContext(
&http.Cookie{Name: "token", Value: "expired-guest-token"},
&http.Cookie{Name: "isRegister", Value: "0"},
&http.Cookie{Name: "userType", Value: "0"},
)
user := RefreshUserOnEnter(c)
// The rebuilt guest must also be reusable by the page-level sync call.
synced := SyncUserOnEnter(c)
if user == nil || user.Token != "rebuilt-guest-token" || user.NickName != "rebuilt-guest" {
t.Fatalf("RefreshUserOnEnter user = %#v, want rebuilt guest", user)
}
if synced == nil || synced.Token != "rebuilt-guest-token" {
t.Fatalf("SyncUserOnEnter user = %#v, want memoized rebuilt guest", synced)
}
if got := getInfoCalls.Load(); got != 1 {
t.Errorf("getInfo calls = %d, want 1 (no refresh of rebuilt guest)", got)
}
if got := guestCalls.Load(); got != 1 {
t.Errorf("guest calls = %d, want 1", got)
}
select {
case got := <-getInfoAuthorization:
if got != "expired-guest-token" {
t.Errorf("getInfo Authorization = %q, want expired-guest-token", got)
}
default:
t.Error("getInfo did not capture Authorization header")
}
select {
case got := <-guestAuthorization:
if got != "" {
t.Errorf("guest Authorization = %q, want empty after expired token is removed", got)
}
default:
t.Error("guest login did not capture Authorization header")
}
assertRequestToken(t, c, "rebuilt-guest-token")
assertSingleAuthCookieSet(t, recorder)
}
func TestSetAuthCookiesUpsertsSameNameWithinResponse(t *testing.T) {
c, recorder := newAuthTestContext()
SetAuthCookies(c, testLoginUser("first-token", "first-user"))
SetAuthCookies(c, testLoginUser("latest-token", "latest-user"))
assertSingleAuthCookieSet(t, recorder)
assertRequestToken(t, c, "latest-token")
if got := cookieValue(c, "nickName"); got != "latest-user" {
t.Errorf("request nickName cookie = %q, want latest-user", got)
}
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == "token" && cookie.Value != "latest-token" {
t.Errorf("response token cookie = %q, want latest-token", cookie.Value)
}
}
}
func useAuthTestUpstream(t *testing.T, handler http.Handler) {
t.Helper()
server := httptest.NewServer(handler)
previousServerAddr := appg.Conf.ServerAddr
appg.Conf.ServerAddr = server.URL
t.Cleanup(func() {
appg.Conf.ServerAddr = previousServerAddr
server.Close()
})
}
func newAuthTestContext(cookies ...*http.Cookie) (*gin.Context, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "https://site.test/page", nil)
for _, cookie := range cookies {
c.Request.AddCookie(cookie)
}
return c, recorder
}
func writeAuthTestEnvelope(w http.ResponseWriter, status, code int, data *LoginResp) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *LoginResp `json:"data"`
}{
Code: code,
Msg: http.StatusText(status),
Data: data,
})
}
func testLoginUser(token, nickName string) *LoginResp {
return &LoginResp{
Token: token,
UserID: 101,
AccountNo: "account-101",
Email: "user101@example.test",
NickName: nickName,
// This field is intentionally large: it must stay out of Set-Cookie headers.
Introduction: strings.Repeat("简介", 150),
Avatar: "https://cdn.example.test/avatar.png",
InviteCode: "INVITE101",
VipType: 1,
CurrentVipExpire: "2099-01-01T00:00:00Z",
ShowCardID: 7,
ShowCardName: "monthly",
Type: 0,
Recharge: 88,
}
}
func assertRequestToken(t *testing.T, c *gin.Context, want string) {
t.Helper()
if got := useragent.TokenFromRequest(c.Request); got != want {
t.Errorf("request token = %q, want %q", got, want)
}
}
func assertSingleAuthCookieSet(t *testing.T, recorder *httptest.ResponseRecorder) {
t.Helper()
counts := make(map[string]int)
for _, cookie := range recorder.Result().Cookies() {
counts[cookie.Name]++
}
for name, count := range counts {
if count > 1 {
t.Errorf("Set-Cookie %q written %d times, want at most once", name, count)
}
}
for _, name := range authCookieNamesWritten {
if got := counts[name]; got != 1 {
t.Errorf("auth Set-Cookie %q written %d times, want exactly once", name, got)
}
}
for _, name := range []string{"introduction", "email", "inviteCode", "showCardId", "showCardName", "sid"} {
if got := counts[name]; got != 0 {
t.Errorf("non-session Set-Cookie %q written %d times, want none", name, got)
}
}
var headerBytes int
for _, raw := range recorder.Header().Values("Set-Cookie") {
headerBytes += len("Set-Cookie: ") + len(raw) + len("\r\n")
}
if headerBytes >= 4096 {
t.Errorf("Set-Cookie response headers = %d bytes, want below 4096", headerBytes)
}
}
+67
View File
@@ -0,0 +1,67 @@
package authser
import "github.com/gin-gonic/gin"
const requestAuthStateKey = "authser.request-auth-state"
// requestAuthState 只在单个 Gin 请求内有效,用来避免多个 SSR 服务重复刷新同一会话。
// freshUser 表示本请求已经从上游取得并写回过的最新用户;suppressAutoAuth
// 表示本请求明确不应再自动创建游客(例如用户刚退出或正式账号已过期)。
type requestAuthState struct {
freshUser *LoginResp
suppressAutoAuth bool
}
func authState(c *gin.Context) *requestAuthState {
if c == nil {
return nil
}
if value, ok := c.Get(requestAuthStateKey); ok {
if state, ok := value.(*requestAuthState); ok && state != nil {
return state
}
}
state := &requestAuthState{}
c.Set(requestAuthStateKey, state)
return state
}
func freshRequestAuthUser(c *gin.Context) *LoginResp {
state := authState(c)
if state == nil {
return nil
}
return state.freshUser
}
func rememberRequestAuthUser(c *gin.Context, user *LoginResp) {
state := authState(c)
if state == nil {
return
}
state.freshUser = user
state.suppressAutoAuth = false
}
func forgetRequestAuthUser(c *gin.Context) {
if state := authState(c); state != nil {
state.freshUser = nil
}
}
func suppressRequestAutoAuth(c *gin.Context) {
if state := authState(c); state != nil {
state.suppressAutoAuth = true
}
}
func allowRequestAutoAuth(c *gin.Context) {
if state := authState(c); state != nil {
state.suppressAutoAuth = false
}
}
func requestAutoAuthSuppressed(c *gin.Context) bool {
state := authState(c)
return state != nil && state.suppressAutoAuth
}
+58
View File
@@ -0,0 +1,58 @@
package authser
import "github.com/gin-gonic/gin"
// TokenExpiredCode 上游约定:token 失效,需重新登录。
const TokenExpiredCode = 1000
// IsSessionExpiredCode 是否为会话失效类业务码。
func IsSessionExpiredCode(code int) bool {
switch code {
case TokenExpiredCode, 1002, 1012, 6001: // InvalidToken / NoToken / InvalidateToken / 用户不存在
return true
default:
return false
}
}
// OnSessionExpired 处理上游会话失效。
// retry=true:游客已重新登录,调用方应重试原请求;
// needLogin=true:注册账号已清 cookie,调用方应跳转登录弹窗/页。
func OnSessionExpired(c *gin.Context, code int) (retry, needLogin bool) {
if c == nil || !IsSessionExpiredCode(code) {
return false, false
}
if IsRegisteredFromCookies(c) {
ClearAuthCookies(c)
dropRequestCookie(c, "token")
dropRequestCookie(c, "_token_")
// 正式账号失效后,本请求保持退出态,避免后续 HTML 渲染又静默转成游客。
suppressRequestAutoAuth(c)
return false, true
}
if RefreshGuestSession(c) != nil {
return true, false
}
ClearAuthCookies(c)
dropRequestCookie(c, "token")
dropRequestCookie(c, "_token_")
return false, false
}
// RecoverContentSession 用于允许游客访问的内容读取接口。
// 正式账号或游客 token 失效后,清理旧登录态、重新建立游客会话,调用方随后重试一次原请求。
// 写操作仍应使用 OnSessionExpired,避免把用户操作静默降级为游客操作。
func RecoverContentSession(c *gin.Context, code int) bool {
if c == nil || !IsSessionExpiredCode(code) {
return false
}
ClearAuthCookies(c)
for _, name := range authCookieNames() {
dropRequestCookie(c, name)
}
dropRequestCookie(c, guestSkipAutoCookie)
ClearGuestSkipAuto(c)
// 内容读取接口明确允许降级为游客,因此覆盖本请求可能已有的自动认证抑制。
allowRequestAutoAuth(c)
return EnsureGuestOnEnter(c) != nil
}
+95
View File
@@ -0,0 +1,95 @@
package authser
import "encoding/json"
// RegisterReq 对应 swagger authapi.registerReq
type RegisterReq struct {
AccountNo string `json:"accountNo"`
Email string `json:"email,omitempty"`
Password string `json:"password"`
AgentInviteCode string `json:"agentInviteCode,omitempty"`
DevID string `json:"devID,omitempty"`
Channel string `json:"channel,omitempty"`
}
// LoginReq 对应 swagger authapi.loginReq
type LoginReq struct {
Account string `json:"account"`
Password string `json:"password"`
DevID string `json:"devID,omitempty"`
Channel string `json:"channel,omitempty"`
}
// ResetPasswordReq 对应 swagger authapi.resetPasswordReq。
type ResetPasswordReq struct {
AccountNo string `json:"accountNo"`
Email string `json:"email"`
NewPassword string `json:"newPassword"`
}
// GuestLoginReq 对应 swagger authser.GuestLoginReq
// POST /api/app/auth/guest
type GuestLoginReq struct {
DevID string `json:"devID"`
DeviceID string `json:"deviceId,omitempty"`
Mac string `json:"mac,omitempty"`
Channel string `json:"channel,omitempty"`
}
// UpdateProfileReq 对应 swagger authapi.updateProfileReq
type UpdateProfileReq struct {
Avatar *string `json:"avatar,omitempty"`
Introduction *string `json:"introduction,omitempty"`
NickName *string `json:"nickName,omitempty"`
OldPassword *string `json:"oldPassword,omitempty"`
NewPassword *string `json:"newPassword,omitempty"`
PrivacyMode *bool `json:"privacyMode,omitempty"`
}
// LoginResp 对应 swagger authser.LoginResp
// GET /api/app/auth/getInfo、登录/注册/游客成功 data。
type LoginResp struct {
Token string `json:"token"`
UserID int64 `json:"userId"`
AccountNo string `json:"accountNo"`
Email string `json:"email"`
NickName string `json:"nickName"`
Introduction string `json:"introduction"` // 简介
Avatar string `json:"avatar"` // 头像
InviteCode string `json:"inviteCode"` // 邀请码
VipType int `json:"vipType"` // 0没卡 1月卡 2年卡
CurrentVipExpire string `json:"currentVipExpire"` // VIP 过期时间,需结合 vipType 判断是否有效
ShowCardID int64 `json:"showCardId"` // 最新会员卡 id
ShowCardName string `json:"showCardName"` // 最新会员卡名称,如「1个月会员」
Status int `json:"status"` // 0正常 1封禁 2禁言
Type int `json:"type"` // 身份类型:0游客 1普通用户
IsNewUser bool `json:"isNewUser"` // 本次是否新建游客
IsRegister bool `json:"isRegister"` // 是否已注册
HasPassword *bool `json:"hasPassword,omitempty"` // 是否已设置密码;上游未返回时由账号状态推断
LoginType int `json:"loginType"` // 登录方式
PrivacyMode bool `json:"privacyMode"` // 隐私模式
Score int64 `json:"score"` // 积分
Recharge int64 `json:"recharge"` // 金币余额
}
// APIEnvelope 上游统一响应壳。Data 在失败时可能是 string/null。
type APIEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data json.RawMessage `json:"data"`
}
// ImageUploadData 对应 swagger uploadser.ImageUploadData
type ImageUploadData struct {
ID string `json:"id"`
SourceNameFs string `json:"sourceNameFs"`
}
// ImageUploadResp 对应 swagger uploadser.ImageUploadResp
type ImageUploadResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data *ImageUploadData `json:"data"`
}
+97
View File
@@ -0,0 +1,97 @@
package authser
import (
"fmt"
"strings"
)
// SettingPageVM 账号设置页展示模型。
type SettingPageVM struct {
UserID int64
AccountNo string
HasAccount bool
HasPassword bool
NickName string
Introduction string
Email string
EmailMasked string
EmailBound bool
Avatar string // 可用头像 URL;空则模板用默认图
InviteCode string
PrivacyMode bool
VipType int
VipExpire string
Recharge int64
Score int64
DisplayName string
}
// BuildSettingVM 由 LoginResp 构建设置页 VM。
func BuildSettingVM(user *LoginResp) SettingPageVM {
if user == nil {
return SettingPageVM{}
}
nick := strings.TrimSpace(user.NickName)
account := strings.TrimSpace(user.AccountNo)
registered := user.IsRegister || user.Type == 1
hasAccount := registered && account != ""
hasPassword := hasAccount
if user.HasPassword != nil {
hasPassword = *user.HasPassword
}
email := strings.TrimSpace(user.Email)
intro := strings.TrimSpace(user.Introduction)
display := nick
if display == "" {
display = account
}
if display == "" {
display = "用户"
}
return SettingPageVM{
UserID: user.UserID,
AccountNo: account,
HasAccount: hasAccount,
HasPassword: hasPassword,
NickName: nick,
Introduction: intro,
Email: email,
EmailMasked: MaskEmail(email),
EmailBound: email != "",
Avatar: usableAvatarURL(user.Avatar),
InviteCode: strings.TrimSpace(user.InviteCode),
PrivacyMode: user.PrivacyMode,
VipType: user.VipType,
VipExpire: strings.TrimSpace(user.CurrentVipExpire),
Recharge: user.Recharge,
Score: user.Score,
DisplayName: display,
}
}
// MaskEmail 邮箱脱敏,如 ab***@mail.com。
func MaskEmail(email string) string {
email = strings.TrimSpace(email)
if email == "" {
return ""
}
at := strings.LastIndex(email, "@")
if at <= 0 || at == len(email)-1 {
return email
}
local := email[:at]
domain := email[at:]
runes := []rune(local)
if len(runes) <= 2 {
return string(runes[:1]) + "***" + domain
}
return string(runes[:2]) + "***" + domain
}
// FormatUserID 展示用 ID。
func FormatUserID(id int64) string {
if id <= 0 {
return ""
}
return fmt.Sprintf("%d", id)
}
+187
View File
@@ -0,0 +1,187 @@
package authser
import (
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func parseVipExpireMs(raw string) int64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
if n > 0 && n < 1e12 {
return n * 1000
}
return n
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return t.UnixMilli()
}
}
return 0
}
// UserIDFromCookies 读取 cookie userId。
func UserIDFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "userId")), 10, 64)
if n < 0 {
return 0
}
return n
}
// VipTypeFromCookies 读取 cookie vipType。
func VipTypeFromCookies(c *gin.Context) int {
if c == nil {
return 0
}
n, _ := strconv.Atoi(strings.TrimSpace(cookieValue(c, "vipType")))
return n
}
// VipTypeLabel vipType 文案:0普通 1月卡 2年卡(与 91ly / 编辑资料注释一致)。
func VipTypeLabel(vipType int) string {
switch vipType {
case 1:
return "月卡会员"
case 2:
return "年卡会员"
default:
return "普通会员"
}
}
// ShowCardNameFromCookies 读取 cookie showCardName。
func ShowCardNameFromCookies(c *gin.Context) string {
if c == nil {
return ""
}
return strings.TrimSpace(cookieValue(c, "showCardName"))
}
// ShowCardIDFromCookies 读取 cookie showCardId。
func ShowCardIDFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "showCardId")), 10, 64)
if n < 0 {
return 0
}
return n
}
// VipDisplayLabel 优先使用 getInfo.showCardName,否则回退 vipType 文案。
func VipDisplayLabel(showCardName string, vipType int) string {
if s := strings.TrimSpace(showCardName); s != "" {
return s
}
return VipTypeLabel(vipType)
}
// VipDisplayLabelFromCookies 从 cookie 组装会员展示名。
func VipDisplayLabelFromCookies(c *gin.Context) string {
return VipDisplayLabel(ShowCardNameFromCookies(c), VipTypeFromCookies(c))
}
// IsActiveVipUser 根据 vipType + currentVipExpire 判断是否有效会员。
func IsActiveVipUser(vipType int, currentVipExpire string) bool {
if vipType == 0 {
return false
}
expireMs := parseVipExpireMs(currentVipExpire)
if expireMs <= 0 {
return false
}
return expireMs > time.Now().UnixMilli()
}
// FormatVipExpireText 格式化到期时间展示。
func FormatVipExpireText(currentVipExpire string) string {
ms := parseVipExpireMs(currentVipExpire)
if ms <= 0 {
return ""
}
return time.UnixMilli(ms).In(time.Local).Format("有效期至 2006/01/02")
}
// VipExpireTextFromCookies 格式化到期时间展示。
func VipExpireTextFromCookies(c *gin.Context) string {
if c == nil {
return ""
}
return FormatVipExpireText(cookieValue(c, "currentVipExpire"))
}
// IsActiveVipFromCookies vipType≠0 且 currentVipExpire 未过期(与 91ly 一致)。
func IsActiveVipFromCookies(c *gin.Context) bool {
if c == nil {
return false
}
return IsActiveVipUser(VipTypeFromCookies(c), cookieValue(c, "currentVipExpire"))
}
// RechargeFromCookies 读取 cookie recharge(金币余额)。
func RechargeFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "recharge")), 10, 64)
if n < 0 {
return 0
}
return n
}
// SetRechargeCookie 更新金币余额 cookie(解锁/充值后同步)。
func SetRechargeCookie(c *gin.Context, balance int64) {
if c == nil {
return
}
if balance < 0 {
balance = 0
}
secure := c.Request != nil && c.Request.TLS != nil
setCookie(c, "recharge", strconv.FormatInt(balance, 10), secure)
}
// FormatRecharge 金币余额千分位展示。
func FormatRecharge(n int64) string {
if n < 0 {
n = 0
}
s := strconv.FormatInt(n, 10)
if len(s) <= 3 {
return s
}
var b strings.Builder
rem := len(s) % 3
if rem > 0 {
b.WriteString(s[:rem])
if len(s) > rem {
b.WriteByte(',')
}
}
for i := rem; i < len(s); i += 3 {
b.WriteString(s[i : i+3])
if i+3 < len(s) {
b.WriteByte(',')
}
}
return b.String()
}
+22
View File
@@ -0,0 +1,22 @@
package commentser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const likeSetPath = "/api/app/comment/like/set"
// LikeSet 设置评论点赞/取消点赞。
func LikeSet(c *gin.Context, req CommentLikeSetReq) (*APIEnvelope[CommentLikeSetResp], error) {
if req.CommentID <= 0 {
return nil, fmt.Errorf("comment like invalid commentId")
}
var env APIEnvelope[CommentLikeSetResp]
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+143
View File
@@ -0,0 +1,143 @@
package commentser
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
listPath = "/api/app/comment/list"
defaultCommentSize = 10
)
// ListQuery 评论列表查询参数。
type ListQuery struct {
ObjectID int64
ObjectType int
PageNum int
PageSize int
SortOrder int
}
// Normalize 校正分页与排序默认值。
func (q *ListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultCommentSize
}
if q.PageSize > 50 {
q.PageSize = 50
}
q.ObjectType = NormalizeObjectType(q.ObjectType)
if q.SortOrder != SortLatest {
q.SortOrder = SortHot
}
}
// FetchList 分页查询评论。
func FetchList(c *gin.Context, q ListQuery) (*GetCommentListResp, error) {
q.Normalize()
if q.ObjectID <= 0 {
return nil, fmt.Errorf("comment list invalid objectId")
}
params := url.Values{}
params.Set("objectId", strconv.FormatInt(q.ObjectID, 10))
params.Set("objectType", strconv.Itoa(q.ObjectType))
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
authser.EnsureGuestOnEnter(c)
requestPath := listPath + "?" + params.Encode()
env, err := fetchListOnce(c, requestPath)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) && authser.RecoverContentSession(c, env.Code) {
env, err = fetchListOnce(c, requestPath)
if err != nil {
return nil, err
}
}
if env.Code != 200 {
return nil, fmt.Errorf("comment list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
func fetchListOnce(c *gin.Context, path string) (*APIEnvelope[GetCommentListResp], error) {
var env APIEnvelope[GetCommentListResp]
if err := upstream.GetJSON(c, path, &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchListOrEmpty 失败时返回空列表。
func FetchListOrEmpty(c *gin.Context, q ListQuery) *GetCommentListResp {
data, err := FetchList(c, q)
if err != nil {
log.Error("fetch comment list failed", log.E(err))
return &GetCommentListResp{
ObjectID: q.ObjectID,
PageNum: q.PageNum,
PageSize: q.PageSize,
}
}
if data == nil {
return &GetCommentListResp{
ObjectID: q.ObjectID,
PageNum: q.PageNum,
PageSize: q.PageSize,
}
}
return data
}
// ParseSortOrder 解析站点评论排序(1 热门 / 2 最新)。
func ParseSortOrder(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n != SortLatest {
return SortHot
}
return SortLatest
}
// FormatCreatedAt 展示用时间格式化(非字段映射)。
func FormatCreatedAt(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return t.Local().Format("2006/1/2 15:04")
}
}
return raw
}
// AvatarOrDefault 头像空时回退默认图。
func AvatarOrDefault(avatar string) string {
avatar = strings.TrimSpace(avatar)
if avatar == "" {
return "/static/images/profile/avatar.png"
}
return avatar
}
+43
View File
@@ -0,0 +1,43 @@
package commentser
import (
"fmt"
"strings"
"unicode/utf8"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const sendPath = "/api/app/comment/send"
// Send 发布评论(一级 / 二级)。
func Send(c *gin.Context, req CommentSendReq) (*APIEnvelope[any], error) {
req.Text = strings.TrimSpace(req.Text)
if req.ObjectID <= 0 {
return nil, fmt.Errorf("comment send invalid objectId")
}
req.ObjectType = NormalizeObjectType(req.ObjectType)
if req.Text == "" {
return nil, fmt.Errorf("comment send empty text")
}
if utf8.RuneCountInString(req.Text) > 200 {
return nil, fmt.Errorf("comment send text too long")
}
if req.ParentsID < 0 {
req.ParentsID = 0
}
if req.ReplyCommentID < 0 {
req.ReplyCommentID = 0
}
// 二级评论:未显式指定 replyCommentId 时默认回复父评论
if req.ParentsID > 0 && req.ReplyCommentID <= 0 {
req.ReplyCommentID = req.ParentsID
}
var env APIEnvelope[any]
if _, err := upstream.PostJSON(c, sendPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+11
View File
@@ -0,0 +1,11 @@
package commentser
import "html/template"
// TemplateFuncs 评论展示辅助。
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"formatCommentTime": FormatCreatedAt,
"commentAvatar": AvatarOrDefault,
}
}
+83
View File
@@ -0,0 +1,83 @@
package commentser
// CommentSendReq 对应 swagger proto.CommentSendReq
// parentsId 为 0 发一级评论,大于 0 发二级评论;replyCommentId 为实际被回复的评论。
type CommentSendReq struct {
ObjectID int64 `json:"objectId"`
ObjectType int `json:"objectType"`
Text string `json:"text"`
ParentsID int64 `json:"parentsId"`
ReplyCommentID int64 `json:"replyCommentId"`
}
// CommentLikeSetReq 对应 swagger proto.CommentLikeSetReq
type CommentLikeSetReq struct {
CommentID int64 `json:"commentId"`
IsLike bool `json:"isLike"`
}
// CommentLikeSetResp 对应 swagger proto.CommentLikeSetResp
type CommentLikeSetResp struct {
CommentID int64 `json:"commentId"`
CountLike int64 `json:"countLike"`
IsLike int `json:"isLike"`
}
// CommentInfo 对应 swagger proto.CommentInfo
type CommentInfo struct {
ID int64 `json:"id"`
ObjectID int64 `json:"objectId"`
ObjectType int `json:"objectType"`
Text string `json:"text"`
UserID int64 `json:"userId"`
UserName string `json:"userName"`
UserAvatar string `json:"userAvatar"`
CountLike int64 `json:"countLike"`
CommentCount int64 `json:"commentCount"`
IsLike int `json:"isLike"`
CreatedAt string `json:"createdAt"`
Level int `json:"level,omitempty"`
ParentsID int64 `json:"parentsId,omitempty"`
ReplyCommentID int64 `json:"replyCommentId,omitempty"`
ReplyUserName string `json:"replyUserName,omitempty"`
Children []CommentInfo `json:"children,omitempty"`
}
// GetCommentListResp 对应 swagger proto.GetCommentListResp
type GetCommentListResp struct {
List []CommentInfo `json:"list"`
ObjectID int64 `json:"objectId"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data T `json:"data"`
}
const (
// ObjectTypePlaylet 短剧评论
ObjectTypePlaylet = 10
// ObjectTypeChapter 短剧剧集评论
ObjectTypeChapter = 11
// ObjectTypeVideo 视频评论
ObjectTypeVideo = 20
SortHot = 1 // 热门
SortLatest = 2 // 最新
)
// NormalizeObjectType 校正评论对象类型:10-短剧 11-短剧剧集 20-视频。
func NormalizeObjectType(objectType int) int {
switch objectType {
case ObjectTypeChapter, ObjectTypeVideo:
return objectType
default:
return ObjectTypePlaylet
}
}
+51
View File
@@ -0,0 +1,51 @@
package configser
import (
"fmt"
"sync"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const footerPath = "/api/app/config/footer/info"
const footerCacheTTL = 60 * time.Second
var (
footerMu sync.Mutex
footerCache *FooterInfo
footerAt time.Time
)
// FetchFooter 拉取底部配置,返回与 swagger proto.FooterInfo 一致的结构。
func FetchFooter(c *gin.Context) (*FooterInfo, error) {
var env APIEnvelope[FooterInfoResp]
if err := upstream.GetPublicJSON(c, footerPath, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("footer info code=%d msg=%s", env.Code, env.Msg)
}
info := env.Data.FooterInfo
return &info, nil
}
// FetchFooterOrEmpty 带短缓存;失败或空数据时返回空结构,不做写死兜底/字段映射。
func FetchFooterOrEmpty(c *gin.Context) FooterInfo {
footerMu.Lock()
defer footerMu.Unlock()
if footerCache != nil && time.Since(footerAt) < footerCacheTTL {
return *footerCache
}
info, err := FetchFooter(c)
if err != nil {
log.Error("fetch footer info failed", log.E(err))
return FooterInfo{}
}
footerCache = info
footerAt = time.Now()
return *info
}
+88
View File
@@ -0,0 +1,88 @@
package configser
import (
"fmt"
"strings"
)
// HomeRoadLinkVM 是“回家的路”页面中的可访问线路。
type HomeRoadLinkVM struct {
Label string
URL string
}
// HomeRoadContactVM 是防失联联系方式。
type HomeRoadContactVM struct {
Title string
Description string
Action string
URL string
}
// HomeRoadPageVM 将 config/info 中的地址字段整理成页面所需结构。
type HomeRoadPageVM struct {
MainURL string
Routes []HomeRoadLinkVM
Permanent []HomeRoadLinkVM
Contacts []HomeRoadContactVM
}
func firstHomeRoadURL(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return "/"
}
// BuildHomeRoadPageVM 使用服务端动态配置组装“回家的路”页面。
func BuildHomeRoadPageVM(cfg AppConfigVM) HomeRoadPageVM {
mainURL := firstHomeRoadURL(cfg.NewURLAddr, cfg.ForeverURLAddr)
routes := make([]HomeRoadLinkVM, 0, 3)
for _, item := range cfg.NavPageList {
if len(routes) == 3 {
break
}
if url := strings.TrimSpace(item.URL); url != "" {
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: url})
}
}
for _, url := range []string{cfg.NewURLAddr, cfg.NewURLAddr1, cfg.NewURLAddr2} {
if len(routes) == 3 {
break
}
if url = strings.TrimSpace(url); url != "" {
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: url})
}
}
for len(routes) < 3 {
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: mainURL})
}
permanent := make([]HomeRoadLinkVM, 0, 3)
for _, item := range []HomeRoadLinkVM{
{Label: "永久地址", URL: cfg.ForeverURLAddr},
{Label: "永久中转页", URL: cfg.NewURLAddr},
{Label: "最新中转页(免翻墙)", URL: cfg.NewURLAddr1},
} {
item.URL = strings.TrimSpace(item.URL)
if item.URL != "" {
permanent = append(permanent, item)
}
}
email := strings.TrimSpace(cfg.GmailAddr)
emailURL := ""
if email != "" {
emailURL = "mailto:" + email
}
contacts := []HomeRoadContactVM{
{Title: "✉ 邮箱自动回复", Description: "发送任意邮件到下方地址,30秒内自动回复当前所有可用入口的最新汇总。", Action: firstHomeRoadURL(email, "huangguoaiai@gmail.com") + " →", URL: firstHomeRoadURL(emailURL, "mailto:huangguoaiai@gmail.com")},
{Title: "➤ TG 官方交流群", Description: "获取域名变更通知的首选渠道,实时播报并加入用户互助社区。", Action: "立即订阅最新动态 →", URL: firstHomeRoadURL(cfg.TGGroup, "/")},
{Title: "◆ 官方商务合作", Description: "联系官方商务合作,洽谈商务事宜,精诚合作,财富共赢。", Action: "立即联系 →", URL: firstHomeRoadURL(cfg.Telegram, "/")},
{Title: "𝕏 推特社区", Description: "关注官方推特社区,第一时间获取地址变更与官方公告。", Action: "快速访问 →", URL: firstHomeRoadURL(cfg.TwitterAddr, "/")},
}
return HomeRoadPageVM{MainURL: mainURL, Routes: routes, Permanent: permanent, Contacts: contacts}
}
+56
View File
@@ -0,0 +1,56 @@
package configser
import (
"fmt"
"sync"
"time"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
configInfoPath = "/api/app/config/info"
configInfoCacheTTL = 60 * time.Second
)
var (
configMu sync.Mutex
configCache *AppConfigVM
configAt time.Time
)
// FetchConfigInfo 拉取 APP 公共配置。
func FetchConfigInfo(c *gin.Context) (*ConfigInfoResp, error) {
var env APIEnvelope[ConfigInfoResp]
if err := upstream.GetPublicJSON(c, configInfoPath, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("config info code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchAppConfigVMOrEmpty 带短缓存;刷新失败时优先返回最近一次成功配置。
func FetchAppConfigVMOrEmpty(c *gin.Context) AppConfigVM {
configMu.Lock()
defer configMu.Unlock()
if configCache != nil && time.Since(configAt) < configInfoCacheTTL {
return *configCache
}
info, err := FetchConfigInfo(c)
if err != nil {
log.Error("fetch app config info failed", log.E(err))
if configCache != nil {
return *configCache
}
return ApplyTrackerFallback(AppConfigVM{})
}
vm := ApplyTrackerFallback(BuildAppConfigVM(info))
configCache = &vm
configAt = time.Now()
return vm
}
+130
View File
@@ -0,0 +1,130 @@
package configser
import (
"fmt"
"html/template"
"net/url"
"strings"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const richtextListPath = "/api/app/config/richtext/list"
// RichTextPageVM 富文本页模板数据。
type RichTextPageVM struct {
Title string
Subtitle string
ContentHTML template.HTML
Found bool
}
// FetchRichTextList 查询富文本列表(ids / titles 逗号分隔)。
func FetchRichTextList(c *gin.Context, ids, titles string) ([]AppRichTextItemResp, error) {
params := url.Values{}
if s := strings.TrimSpace(ids); s != "" {
params.Set("ids", s)
}
if s := strings.TrimSpace(titles); s != "" {
params.Set("titles", s)
}
path := richtextListPath
if q := params.Encode(); q != "" {
path += "?" + q
}
var env APIEnvelope[AppRichTextListResp]
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("richtext list code=%d msg=%s", env.Code, env.Msg)
}
return env.Data.List, nil
}
// FetchRichTextByTitle 按标题取第一条启用内容;失败返回空页 VM。
func FetchRichTextByTitle(c *gin.Context, title string) RichTextPageVM {
title = strings.TrimSpace(title)
vm := RichTextPageVM{Title: title}
if title == "" {
return vm
}
list, err := FetchRichTextList(c, "", title)
if err != nil {
log.Error("fetch richtext by title failed", log.E(err), log.Any("title", title))
return vm
}
item := matchRichTextByTitle(list, title)
if item == nil {
return vm
}
displayTitle := strings.TrimSpace(item.Desc)
if displayTitle == "" {
displayTitle = strings.TrimSpace(item.Title)
}
if displayTitle == "" {
displayTitle = title
}
vm.Found = true
vm.Title = displayTitle
vm.Subtitle = strings.TrimSpace(item.Desc)
if vm.Subtitle == displayTitle {
vm.Subtitle = ""
}
vm.ContentHTML = template.HTML(item.Content)
return vm
}
func matchRichTextByTitle(list []AppRichTextItemResp, title string) *AppRichTextItemResp {
if len(list) == 0 {
return nil
}
for i := range list {
if strings.TrimSpace(list[i].Title) == title || strings.TrimSpace(list[i].Desc) == title {
return &list[i]
}
}
return &list[0]
}
// RichTextHref 返回富文本标题对应的站内 canonical 地址。
func RichTextHref(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "/richtext"
}
if href, ok := RichTextSemanticHref(title); ok {
return href
}
return "/richtext/" + url.PathEscape(title)
}
// RichTextSemanticHref 将固定栏目及上游常用别名收敛到唯一语义路由。
func RichTextSemanticHref(title string) (string, bool) {
normalized := strings.ToLower(strings.Join(strings.Fields(title), ""))
switch normalized {
case "关于我们", "aboutus":
return "/about", true
case "用户协议", "用户条款", "useragreement", "termsofservice":
return "/terms", true
case "隐私政策", "privacy", "privacypolicy":
return "/privacy", true
case "2257合规声明", "2257", "usc2257", "合规声明":
return "/2257", true
case "dmca版权声明", "dmca", "版权声明", "版权投诉":
return "/dmca", true
case "帮助中心", "疑问解答", "常见问题", "help", "helpcenter", "faq":
return "/help", true
case "问题反馈", "意见反馈", "联系我们", "feedback", "contact", "contactus":
return "/feedback", true
default:
return "", false
}
}
+94
View File
@@ -0,0 +1,94 @@
package configser
import (
"bytes"
"encoding/json"
"strings"
)
// UnmarshalJSON 兼容 seoNavInfo 为空字符串,以及 dhKeyworld 为字符串/数组。
func (s *SeoNavInfo) UnmarshalJSON(raw []byte) error {
if s == nil {
return nil
}
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil
}
if raw[0] == '"' {
var encoded string
if err := json.Unmarshal(raw, &encoded); err != nil {
return err
}
encoded = strings.TrimSpace(encoded)
if encoded == "" {
return nil
}
inner := bytes.TrimSpace([]byte(encoded))
if len(inner) == 0 || inner[0] != '{' {
return nil
}
return s.UnmarshalJSON(inner)
}
var aux struct {
DhTitle string `json:"dhTitle"`
DhDescription string `json:"dhDescription"`
DhSiteName string `json:"dhSiteName"`
DhKeyworld json.RawMessage `json:"dhKeyworld"`
Keywords json.RawMessage `json:"keywords"`
}
if err := json.Unmarshal(raw, &aux); err != nil {
return err
}
s.DhTitle = strings.TrimSpace(aux.DhTitle)
s.DhDescription = strings.TrimSpace(aux.DhDescription)
s.DhSiteName = strings.TrimSpace(aux.DhSiteName)
s.DhKeyworld = decodeNavKeywords(aux.DhKeyworld, aux.Keywords)
return nil
}
func decodeNavKeywords(values ...json.RawMessage) []string {
for _, raw := range values {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
continue
}
if raw[0] == '[' {
var list []string
if err := json.Unmarshal(raw, &list); err != nil {
continue
}
out := make([]string, 0, len(list))
for _, item := range list {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, item)
}
}
if len(out) > 0 {
return out
}
continue
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
continue
}
s = strings.TrimSpace(s)
if s == "" {
continue
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
if len(out) > 0 {
return out
}
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package configser
import (
"strings"
"videoweb/app/appg"
)
// ResolveTrackerAppID 优先 yaml,否则固定 JHA-1045。
func ResolveTrackerAppID() string {
return appg.TrackerAppID()
}
// ResolveReportSDKConfig 上游 reportSdkConfig 优先,本地 yaml 兜底。
func ResolveReportSDKConfig(fromAPI string) string {
if v := strings.TrimSpace(fromAPI); v != "" {
return v
}
return strings.TrimSpace(appg.Conf.Tracker.EncryptedConfig)
}
// ApplyTrackerFallback 补齐 AppConfigVM 中的上报密文。
func ApplyTrackerFallback(vm AppConfigVM) AppConfigVM {
vm.ReportSDKConfig = ResolveReportSDKConfig(vm.ReportSDKConfig)
return vm
}
+185
View File
@@ -0,0 +1,185 @@
package configser
// FooterInfoResp 对应 swagger proto.FooterInfoResp
type FooterInfoResp struct {
FooterInfo FooterInfo `json:"footerInfo"`
}
// FooterInfo 对应 swagger proto.FooterInfo
// GET /api/app/config/footer/info — 模板直接使用本结构,不做自定义 VM 映射。
type FooterInfo struct {
Brand FooterBrand `json:"brand"`
NavList []TranslationCategory `json:"navList"`
ContactList []FooterContact `json:"contactList"`
}
// FooterBrand 对应 swagger proto.FooterBrand
type FooterBrand struct {
Title string `json:"title"`
Content string `json:"content"`
}
// TranslationCategory 对应 swagger proto.TranslationCategory(footer.navList 元素)
type TranslationCategory struct {
ID int64 `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
URLValue string `json:"urlValue"`
CategoryType int `json:"categoryType"`
TranslationList []TranslationInfo `json:"translationList"`
SeoInfo SeoValue `json:"seoInfo"`
}
// SeoValue 对应 swagger pubdef.SeoValue(导航分类 seoInfo)
type SeoValue struct {
Title string `json:"title"`
Keyworld []string `json:"keyworld"`
Description string `json:"description"`
}
// TranslationInfo 对应 swagger proto.TranslationInfo
type TranslationInfo struct {
Name string `json:"name"`
URLValue string `json:"urlValue"`
IsFriendAds bool `json:"isFriendAds"`
}
// FooterContact 对应 swagger proto.FooterContact
type FooterContact struct {
Name string `json:"name"`
Value string `json:"value"`
}
// ConfigInfoResp 对应 swagger proto.ConfigInfoResp
// GET /api/app/config/info
// reportSdkConfig 与 proto.ConfigInfo 对齐;上游若尚未下发则为空,可由本地 yaml 兜底。
// config 为上游实际下发的完整站点配置(swagger 未完整声明,但含 potaoGroup 等字段)。
type ConfigInfoResp struct {
NewAddressInfo NewAddressInfo `json:"newAddressInfo"`
Config ConfigInfo `json:"config"`
Advitise AdvitiseData `json:"advitise"`
SeoInfo SeoInfo `json:"seoInfo"`
SeoNavInfo SeoNavInfo `json:"seoNavInfo"`
NavPageList []NavPageItem `json:"navPageList"`
ReportSDKConfig string `json:"reportSdkConfig"`
}
// ConfigInfo 对应上游 data.config(proto.ConfigInfo 子集,按页面需要取用)。
// 文档:http://172.104.181.99:25319/swagger/index.html#/APP配置相关接口/get_api_app_config_info
type ConfigInfo struct {
AppAddr string `json:"appAddr"`
OfficialAppAddr string `json:"officialAppAddr"`
ForeverURLAddr string `json:"foreverUrlAddr"`
NewURLAddr string `json:"newUrlAddr"`
NewURLAddr1 string `json:"newUrlAddr1"`
NewURLAddr2 string `json:"newUrlAddr2"`
GmailAddr string `json:"gmailAddr"`
QQ string `json:"qq"`
WeiChart string `json:"weiChart"`
TGGroup string `json:"tgGroup"`
Telegram string `json:"telegram"`
TelegramAdd string `json:"telegramAdd"`
PotaoGroup string `json:"potaoGroup"`
CustomerServiceHost string `json:"customerServiceHost"`
PDFAddr string `json:"pdfAddr"`
TwitterAddr string `json:"twitterAddr"`
GitAddr string `json:"gitAddr"`
FreeVPN string `json:"freeVPN"`
}
// AdvitiseData 对应 GET /api/app/config/info 返回的完整 advitise 数据。
type AdvitiseData struct {
HomeList []Advertisement `json:"homeList"`
DetailTopList []Advertisement `json:"detailTopList"`
NineGridList []Advertisement `json:"nineGridList"`
DetailBannerList []Advertisement `json:"detailbannerList"`
LaunchScreenList []Advertisement `json:"launchScreenList"`
FloatingList []Advertisement `json:"floatingList"`
AList []Advertisement `json:"AList"`
BList []Advertisement `json:"BList"`
DialogList []Advertisement `json:"dialogList"`
VideoInfoList []Advertisement `json:"videoInfoList"`
VideoList []Advertisement `json:"videoList"`
DownList []Advertisement `json:"downList"`
FriendList []Advertisement `json:"friendList"`
HomeBanner []Advertisement `json:"homeBanner"`
BottomBanner []Advertisement `json:"bottomBanner"`
TitleList []Advertisement `json:"titleList"`
PlayList []Advertisement `json:"playList"`
PauseyList []Advertisement `json:"pauseyList"`
}
// Advertisement 对应 swagger proxy.AdvitiseInfo。
type Advertisement struct {
ID string `json:"id"`
Images string `json:"images"`
Link string `json:"link"`
Location int `json:"location"`
Title string `json:"title"`
AdvertiseCode string `json:"advertiseCode"`
AdvertiseLocationCode string `json:"advertiseLocationCode"`
AdvertiseLocationName string `json:"advertiseLocationName"`
}
// NewAddressInfo 对应 swagger proto.NewAddressInfo(GET /api/app/config/info)。
type NewAddressInfo struct {
NewURLAddr string `json:"newUrlAddr"`
NewURLAddr1 string `json:"newUrlAddr1"`
NewURLAddr2 string `json:"newUrlAddr2"`
ForeverURLAddr string `json:"foreverUrlAddr"`
GmailAddr string `json:"gmailAddr"`
QQ string `json:"qq"`
TGGroup string `json:"tgGroup"`
Telegram string `json:"telegram"`
PotaoGroup string `json:"potaoGroup"`
CustomerServiceHost string `json:"customerServiceHost"`
AppAddr string `json:"appAddr"`
OfficialAppAddr string `json:"officialAppAddr"`
PDFAddr string `json:"pdfAddr"`
TwitterAddr string `json:"twitterAddr"`
}
// SeoInfo 对应 swagger proto.SeoInfo
type SeoInfo struct {
Title string `json:"title"`
Description string `json:"description"`
Keywords []string `json:"keywords"`
CanonicalURL string `json:"canonicalUrl"`
WebSiteName string `json:"webSiteName"`
}
// SeoNavInfo 对应 swagger proto.SeoNavInfo
// GET /api/app/config/info 的 seoNavInfo。
type SeoNavInfo struct {
DhTitle string `json:"dhTitle"`
DhDescription string `json:"dhDescription"`
DhKeyworld []string `json:"dhKeyworld"`
DhSiteName string `json:"dhSiteName"`
}
// NavPageItem 对应 swagger proto.NavPageItem(GET /api/app/config/info 的 navPageList)。
type NavPageItem struct {
Line int `json:"line"`
URL string `json:"url"`
}
// AppRichTextItemResp 对应 swagger proto.AppRichTextItemResp
// GET /api/app/config/richtext/list
type AppRichTextItemResp struct {
ID int64 `json:"id"`
Title string `json:"title"`
Desc string `json:"desc"`
Content string `json:"content"`
}
// AppRichTextListResp 对应 swagger proto.AppRichTextListResp
type AppRichTextListResp struct {
List []AppRichTextItemResp `json:"list"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
+244
View File
@@ -0,0 +1,244 @@
package configser
import (
"strconv"
"strings"
)
// AppConfigVM 注入全站页面的公共配置(字段名与接口 newAddressInfo / seoInfo 对齐)。
type AppConfigVM struct {
// Config 为 data.config 原样字段(页脚社交等优先用此对象)。
Config ConfigInfo `json:"config"`
ForeverURLAddr string `json:"foreverUrlAddr"`
NewURLAddr string `json:"newUrlAddr"`
NewURLAddr1 string `json:"newUrlAddr1"`
NewURLAddr2 string `json:"newUrlAddr2"`
GmailAddr string `json:"gmailAddr"`
QQ string `json:"qq"`
TGGroup string `json:"tgGroup"`
Telegram string `json:"telegram"`
TelegramAdd string `json:"telegramAdd"`
PotaoGroup string `json:"potaoGroup"`
CustomerServiceHost string `json:"customerServiceHost"`
AppAddr string `json:"appAddr"`
OfficialAppAddr string `json:"officialAppAddr"`
PDFAddr string `json:"pdfAddr"`
TwitterAddr string `json:"twitterAddr"`
GitAddr string `json:"gitAddr"`
FreeVPN string `json:"freeVPN"`
SeoInfo SeoInfo `json:"seoInfo,omitempty"`
SeoNavInfo SeoNavInfo `json:"seoNavInfo,omitempty"`
NavPageList []NavPageItem `json:"navPageList,omitempty"`
ReportSDKConfig string `json:"reportSdkConfig,omitempty"`
Advitise AdvitiseData `json:"advitise"`
DetailTopList []Advertisement `json:"detailTopList,omitempty"`
NineGridList []Advertisement `json:"nineGridList,omitempty"`
DetailBannerList []Advertisement `json:"detailbannerList,omitempty"`
BottomBanner []Advertisement `json:"bottomBanner,omitempty"`
DialogList []Advertisement `json:"dialogList,omitempty"`
}
// BuildAppConfigVM 从上游配置组装前端可用字段;社交/地址优先取 data.config。
func BuildAppConfigVM(info *ConfigInfoResp) AppConfigVM {
if info == nil {
return AppConfigVM{}
}
cfg := sanitizeConfigInfo(info.Config)
addr := info.NewAddressInfo
return AppConfigVM{
Config: cfg,
ForeverURLAddr: firstNonEmptyURL(cfg.ForeverURLAddr, addr.ForeverURLAddr),
NewURLAddr: firstNonEmptyURL(cfg.NewURLAddr, addr.NewURLAddr),
NewURLAddr1: firstNonEmptyURL(cfg.NewURLAddr1, addr.NewURLAddr1),
NewURLAddr2: firstNonEmptyURL(cfg.NewURLAddr2, addr.NewURLAddr2),
GmailAddr: firstNonEmpty(normalizeEmailAddress(cfg.GmailAddr), normalizeEmailAddress(addr.GmailAddr)),
QQ: firstNonEmpty(cfg.QQ, strings.TrimSpace(addr.QQ)),
TGGroup: firstNonEmptyURL(cfg.TGGroup, addr.TGGroup),
Telegram: firstNonEmptyURL(cfg.Telegram, addr.Telegram),
TelegramAdd: sanitizeURL(cfg.TelegramAdd),
PotaoGroup: firstNonEmptyURL(cfg.PotaoGroup, addr.PotaoGroup),
CustomerServiceHost: firstNonEmptyURL(cfg.CustomerServiceHost, addr.CustomerServiceHost),
AppAddr: firstNonEmptyURL(cfg.AppAddr, addr.AppAddr),
OfficialAppAddr: firstNonEmptyURL(cfg.OfficialAppAddr, addr.OfficialAppAddr),
PDFAddr: firstNonEmptyURL(cfg.PDFAddr, addr.PDFAddr),
TwitterAddr: firstNonEmptyURL(cfg.TwitterAddr, addr.TwitterAddr),
GitAddr: sanitizeURL(cfg.GitAddr),
FreeVPN: sanitizeURL(cfg.FreeVPN),
ReportSDKConfig: strings.TrimSpace(info.ReportSDKConfig),
NavPageList: buildNavPageList(info.NavPageList),
Advitise: info.Advitise,
DetailTopList: buildAdvertisementList(info.Advitise.DetailTopList),
NineGridList: buildAdvertisementList(info.Advitise.NineGridList),
DetailBannerList: buildAdvertisementList(info.Advitise.DetailBannerList),
BottomBanner: buildAdvertisementList(info.Advitise.BottomBanner),
DialogList: buildAdvertisementList(info.Advitise.DialogList),
SeoInfo: SeoInfo{
Title: strings.TrimSpace(info.SeoInfo.Title),
Description: strings.TrimSpace(info.SeoInfo.Description),
Keywords: info.SeoInfo.Keywords,
CanonicalURL: strings.TrimSpace(info.SeoInfo.CanonicalURL),
WebSiteName: strings.TrimSpace(info.SeoInfo.WebSiteName),
},
SeoNavInfo: SeoNavInfo{
DhTitle: strings.TrimSpace(info.SeoNavInfo.DhTitle),
DhDescription: strings.TrimSpace(info.SeoNavInfo.DhDescription),
DhKeyworld: info.SeoNavInfo.DhKeyworld,
DhSiteName: strings.TrimSpace(info.SeoNavInfo.DhSiteName),
},
}
}
func sanitizeConfigInfo(cfg ConfigInfo) ConfigInfo {
return ConfigInfo{
AppAddr: sanitizeURL(cfg.AppAddr),
OfficialAppAddr: sanitizeURL(cfg.OfficialAppAddr),
ForeverURLAddr: sanitizeURL(cfg.ForeverURLAddr),
NewURLAddr: sanitizeURL(cfg.NewURLAddr),
NewURLAddr1: sanitizeURL(cfg.NewURLAddr1),
NewURLAddr2: sanitizeURL(cfg.NewURLAddr2),
GmailAddr: normalizeEmailAddress(cfg.GmailAddr),
QQ: strings.TrimSpace(cfg.QQ),
WeiChart: strings.TrimSpace(cfg.WeiChart),
TGGroup: sanitizeURL(cfg.TGGroup),
Telegram: sanitizeURL(cfg.Telegram),
TelegramAdd: sanitizeURL(cfg.TelegramAdd),
PotaoGroup: sanitizeURL(cfg.PotaoGroup),
CustomerServiceHost: sanitizeURL(cfg.CustomerServiceHost),
PDFAddr: sanitizeURL(cfg.PDFAddr),
TwitterAddr: sanitizeURL(cfg.TwitterAddr),
GitAddr: sanitizeURL(cfg.GitAddr),
FreeVPN: sanitizeURL(cfg.FreeVPN),
}
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func buildAdvertisementList(list []Advertisement) []Advertisement {
if len(list) == 0 {
return nil
}
out := make([]Advertisement, 0, len(list))
for _, item := range list {
item.ID = strings.TrimSpace(item.ID)
item.AdvertiseCode = strings.TrimSpace(item.AdvertiseCode)
item.AdvertiseLocationCode = strings.TrimSpace(item.AdvertiseLocationCode)
item.AdvertiseLocationName = strings.TrimSpace(item.AdvertiseLocationName)
if item.AdvertiseCode != "" {
item.ID = item.AdvertiseCode
}
item.Images = sanitizeURL(item.Images)
item.Link = sanitizeURL(item.Link)
item.Title = strings.TrimSpace(item.Title)
if item.Images == "" {
continue
}
out = append(out, item)
}
return out
}
// NineGridRow 按首页设计每行返回两个 banner,超出当前行时返回空切片。
func (c AppConfigVM) NineGridRow(row int) []Advertisement {
if row < 0 {
return nil
}
start := row * 2
if start >= len(c.NineGridList) {
return nil
}
end := start + 2
if end > len(c.NineGridList) {
end = len(c.NineGridList)
}
return c.NineGridList[start:end]
}
// QRText 应用下载二维码及跳转地址:仅使用应用下载地址 appAddr。
func (c AppConfigVM) QRText() string {
return strings.TrimSpace(c.AppAddr)
}
func buildNavPageList(list []NavPageItem) []NavPageItem {
if len(list) == 0 {
return nil
}
out := make([]NavPageItem, 0, len(list))
for _, item := range list {
u := sanitizeURL(item.URL)
if u == "" {
continue
}
out = append(out, NavPageItem{Line: item.Line, URL: u})
}
return out
}
func (n NavPageItem) Label() string {
digits := []string{"", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"}
if n.Line >= 1 && n.Line <= 10 {
return "线路" + digits[n.Line]
}
if n.Line > 0 {
return "线路" + strconv.Itoa(n.Line)
}
return "线路"
}
func sanitizeURL(v string) string {
v = strings.TrimSpace(v)
v = strings.TrimRight(v, "\\")
return strings.TrimSpace(v)
}
// normalizeEmailAddress 兼容上游把邮箱误配为 mailto/http(s) 地址的情况。
func normalizeEmailAddress(value string) string {
value = strings.TrimSpace(value)
for {
lower := strings.ToLower(value)
switch {
case strings.HasPrefix(lower, "mailto:"):
value = strings.TrimSpace(value[len("mailto:"):])
case strings.HasPrefix(lower, "https://"):
value = strings.TrimSpace(value[len("https://"):])
case strings.HasPrefix(lower, "http://"):
value = strings.TrimSpace(value[len("http://"):])
default:
return value
}
}
}
func firstNonEmptyURL(values ...string) string {
for _, v := range values {
if u := sanitizeURL(v); u != "" {
return u
}
}
return ""
}
// TelegramURL 客服/商务 TG:优先 telegram,其次 telegramAdd、tgGroup。
func (c ConfigInfo) TelegramURL() string {
return firstNonEmptyURL(c.Telegram, c.TelegramAdd, c.TGGroup)
}
// TelegramHandle 从 TG 链接提取展示用 @用户名;无法解析时返回空。
func (c ConfigInfo) TelegramHandle() string {
raw := c.TelegramURL()
raw = strings.TrimRight(raw, "/")
if i := strings.LastIndex(raw, "/"); i >= 0 && i+1 < len(raw) {
raw = raw[i+1:]
}
raw = strings.TrimPrefix(strings.TrimSpace(raw), "@")
if raw == "" || strings.Contains(raw, ".") || strings.Contains(raw, "?") {
return ""
}
return "@" + raw
}
+30
View File
@@ -0,0 +1,30 @@
package feedbackser
import (
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const addPath = "/api/app/feedback/add"
const contactUsAddPath = "/api/app/contactus/add"
// Add 调用上游提交意见反馈。
func Add(c *gin.Context, req FeedbackReq) (*authser.APIEnvelope, error) {
var env authser.APIEnvelope
if _, err := upstream.PostJSON(c, addPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// AddContactUs 调用上游联系我们接口。
func AddContactUs(c *gin.Context, req ContactUsAddReq) (*authser.APIEnvelope, error) {
var env authser.APIEnvelope
if _, err := upstream.PostJSON(c, contactUsAddPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+23
View File
@@ -0,0 +1,23 @@
package feedbackser
// FeedbackReq 对应 swagger proto.FeedbackReq。
// type(接口说明):1-功能建议、2-播放问题、3-账号问题、4-其他
type FeedbackReq struct {
Type int `json:"type"`
Content string `json:"content"`
Contact string `json:"contact,omitempty"`
Screenshots []string `json:"screenshots,omitempty"`
}
// ContactUsAddReq 对应 swagger proto.ContactUsAddReq。
type ContactUsAddReq struct {
Type int `json:"type"`
Title string `json:"title"`
Content string `json:"content"`
Email string `json:"email"`
Attachments []string `json:"attachments,omitempty"`
}
type ContactUsAddResp struct {
Message string `json:"message"`
}
+56
View File
@@ -0,0 +1,56 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const boughtListPath = "/api/app/playlet/bought/list"
// PlayletBoughtData 对应 proto.PlayletBoughtData;只列出页面所需字段。
type PlayletBoughtData struct {
ID int64 `json:"id"`
Title string `json:"title"`
PlayName string `json:"playName"`
Slug string `json:"slug"`
Cover string `json:"cover"`
ThemeNames []string `json:"themeNames"`
TotalEpisode int `json:"totalEpisode"`
BoughtChapterCount int `json:"boughtChapterCount"`
CostCoins int64 `json:"costCoins"`
BoughtAt string `json:"boughtAt"`
LastChapterID int64 `json:"lastChapterId"`
LastEpisode int `json:"lastEpisode"`
WatchedCount int `json:"watchedCount"`
}
type PlayletBoughtListResp struct {
List []PlayletBoughtData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
func FetchBoughtList(c *gin.Context, pageNum, pageSize int) (*PlayletBoughtListResp, int, error) {
if pageNum < 1 {
pageNum = 1
}
if pageSize < 1 {
pageSize = 20
}
params := url.Values{}
params.Set("pageNum", strconv.Itoa(pageNum))
params.Set("pageSize", strconv.Itoa(pageSize))
var env APIEnvelope[PlayletBoughtListResp]
if err := upstream.GetJSON(c, boughtListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet bought list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
+81
View File
@@ -0,0 +1,81 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
categoryDetailPath = "/api/app/playlet/recommend/list"
defaultCategoryPageSize = 25
)
// CategoryDetailQuery 分类详情分页参数。categorySlug 非空时优先使用 slug。
type CategoryDetailQuery struct {
PageNum int
PageSize int
CategoryID int64
CategorySlug string
}
func (q *CategoryDetailQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultCategoryPageSize
}
if q.PageSize > 100 {
q.PageSize = 100
}
if q.CategoryID < 0 {
q.CategoryID = 0
}
q.CategorySlug = strings.TrimSpace(q.CategorySlug)
}
func categoryDetailListURL(q CategoryDetailQuery) string {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
if q.CategorySlug != "" {
params.Set("categorySlug", q.CategorySlug)
} else if q.CategoryID > 0 {
params.Set("categoryId", strconv.FormatInt(q.CategoryID, 10))
}
return categoryDetailPath + "?" + params.Encode()
}
// FetchCategoryDetail 根据分类 slug 或 ID 获取分类详情列表。
func FetchCategoryDetail(c *gin.Context, q CategoryDetailQuery) (*PlayletRecommendListResp, error) {
var env APIEnvelope[PlayletRecommendListResp]
if err := upstream.GetPublicJSON(c, categoryDetailListURL(q), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet category detail code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchCategoryDetailOrEmpty 保持页面可渲染,接口失败时返回当前分页的空结果。
func FetchCategoryDetailOrEmpty(c *gin.Context, q CategoryDetailQuery) *PlayletRecommendListResp {
q.Normalize()
data, err := FetchCategoryDetail(c, q)
if err != nil {
log.Error("fetch playlet category detail failed", log.E(err))
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
if data == nil {
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data.SeoInfo.Normalize()
return data
}
@@ -0,0 +1,63 @@
package playletser
import (
"strconv"
"strings"
)
func categoryDetailPageURL(categoryID int64, categorySlug string, pageNum int) string {
if pageNum <= 0 {
pageNum = 1
}
path := "/category"
if segment := pathSegment(categorySlug, categoryID); segment != "" {
path += "/" + segment
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path
}
func categoryDetailURL(categorySlug string, categoryID int64) string {
return categoryDetailPageURL(categoryID, categorySlug, 1)
}
// ParseCategoryDetailQuery 从语义化路由读取分类标识和页码。
func ParseCategoryDetailQuery(identifier, pageStr string) CategoryDetailQuery {
q := CategoryDetailQuery{PageSize: defaultCategoryPageSize}
identifier = strings.TrimSpace(identifier)
if id, err := strconv.ParseInt(identifier, 10, 64); err == nil && id > 0 {
q.CategoryID = id
} else {
q.CategorySlug = identifier
}
if page, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = page
}
q.Normalize()
return q
}
func CategoryDetailCanonicalPath(q CategoryDetailQuery) string {
q.Normalize()
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, q.PageNum)
}
func BuildCategoryDetailPagination(q CategoryDetailQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(page int) string {
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, page)
}, q.PageNum, q.PageSize, totalCount)
}
func CategoryDetailPageTitle(categoryName string, pageNum int, seo SeoInfo) string {
name := strings.TrimSpace(categoryName)
if name == "" {
name = strings.TrimSpace(seo.Title)
}
if name == "" {
name = "分类短剧"
}
return name + PageTitleSuffix(pageNum)
}
+272
View File
@@ -0,0 +1,272 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
chapterDetailPath = "/api/app/playlet/chapter/detail"
chapterListPath = "/api/app/playlet/chapter/list"
)
// PlayQuery 播放页参数。
type PlayQuery struct {
PlayletID int64
PlayletSlug string
ChapterID int64
}
// ParsePlayQuery 解析播放页的路径参数。
func ParsePlayQuery(playletKey, chapterIDRaw string) PlayQuery {
q := PlayQuery{PlayletSlug: strings.TrimSpace(playletKey)}
if n, err := strconv.ParseInt(strings.TrimSpace(playletKey), 10, 64); err == nil && n > 0 {
q.PlayletID = n
q.PlayletSlug = ""
}
if n, err := strconv.ParseInt(strings.TrimSpace(chapterIDRaw), 10, 64); err == nil && n > 0 {
q.ChapterID = n
}
return q
}
// FetchChapterDetail 查询剧集播放详情。
func FetchChapterDetail(c *gin.Context, chapterID, playletID int64, playletSlug string) (*PlayletChapterInfoResp, error) {
if chapterID <= 0 {
return nil, fmt.Errorf("chapter detail invalid chapterId")
}
params := url.Values{}
params.Set("chapterId", strconv.FormatInt(chapterID, 10))
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
params.Set("playletSlug", playletSlug)
} else if playletID > 0 {
params.Set("playletId", strconv.FormatInt(playletID, 10))
}
authser.EnsureGuestOnEnter(c)
var env APIEnvelope[PlayletChapterInfoResp]
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("chapter detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
env.Data.SeoInfo.Normalize()
return &env.Data, nil
}
// FetchChapterDetailOrNil 失败返回 nil。
func FetchChapterDetailOrNil(c *gin.Context, chapterID, playletID int64, playletSlug string) *PlayletChapterInfoResp {
data, err := FetchChapterDetail(c, chapterID, playletID, playletSlug)
if err != nil {
log.Error("fetch chapter detail failed", log.E(err))
return nil
}
if data == nil || data.ID <= 0 {
return nil
}
return data
}
// FetchChapterList 根据短剧 slug 或 ID 查询选集列表,slug 优先。
func FetchChapterList(c *gin.Context, playletID int64, playletSlug string) (*PlayletChapterListResp, error) {
params := url.Values{}
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
params.Set("playletSlug", playletSlug)
} else if playletID > 0 {
params.Set("playletId", strconv.FormatInt(playletID, 10))
} else {
return nil, fmt.Errorf("chapter list missing playletSlug or playletId")
}
authser.EnsureGuestOnEnter(c)
var env APIEnvelope[PlayletChapterListResp]
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet chapter list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
return &env.Data, nil
}
// ResolvePlayChapterID 无 chapterId 时取第 1 集(currentEpisode=1),否则取列表首条。
func ResolvePlayChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
if chapterID > 0 {
return chapterID
}
if playlet == nil {
return 0
}
var firstID int64
for _, ch := range playlet.PlayletChapterInfo {
if ch.ID <= 0 {
continue
}
if firstID == 0 {
firstID = ch.ID
}
if ch.CurrentEpisode == 1 {
return ch.ID
}
}
return firstID
}
// FindChapter 在详情选集中按 chapterId 查找。
func FindChapter(playlet *PlayletInfoResp, chapterID int64) *PlayletChapterData {
if playlet == nil || chapterID <= 0 {
return nil
}
for i := range playlet.PlayletChapterInfo {
if playlet.PlayletChapterInfo[i].ID == chapterID {
return &playlet.PlayletChapterInfo[i]
}
}
return nil
}
// NextChapterID 返回下一集 chapterId;没有则 0。
func NextChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
if playlet == nil || chapterID <= 0 {
return 0
}
found := false
for _, ch := range playlet.PlayletChapterInfo {
if found && ch.ID > 0 {
return ch.ID
}
if ch.ID == chapterID {
found = true
}
}
return 0
}
// FirstUnlockCost 取第一个需金币解锁的单集价格。
func FirstUnlockCost(chapters []PlayletChapterData) int {
for _, ch := range chapters {
if ch.NeedCoinGate() {
return ch.UnlockCost()
}
}
return 0
}
// ChapterFromList 将详情选集条目转成章节详情结构(chapter/detail 失败时的 UI 兜底)。
func ChapterFromList(ch PlayletChapterData) *PlayletChapterInfoResp {
return &PlayletChapterInfoResp{
ID: ch.ID,
PlayletID: ch.PlayletID,
CurrentEpisode: ch.CurrentEpisode,
Title: ch.Title,
MD5: ch.MD5,
Hash: ch.Hash,
Height: ch.Height,
Width: ch.Width,
Ratio: ch.Ratio,
VideoURL: ch.VideoURL,
MediaSize: ch.MediaSize,
MediaTime: ch.MediaTime,
PayType: ch.PayType,
SingleCoins: ch.SingleCoins,
CostCoins: ch.CostCoins,
Balance: ch.Balance,
CanWatch: ch.CanWatch,
IsUnlocked: ch.IsUnlocked,
NeedUnlock: ch.NeedUnlock,
NeedVip: ch.NeedVip,
VipValid: ch.VipValid,
}
}
// AllowWatch 是否可播放(优先 canWatch;兼容仅返回 videoUrl 的旧数据)。
func (c *PlayletChapterInfoResp) AllowWatch() bool {
if c == nil {
return false
}
if c.CanWatch {
return true
}
if c.NeedUnlock || c.NeedVip {
return false
}
return strings.TrimSpace(c.VideoURL) != "" && c.PayType == 0
}
// UnlockCost 金币解锁消耗:优先 costCoins。
func (c *PlayletChapterInfoResp) UnlockCost() int64 {
if c == nil {
return 0
}
if c.CostCoins > 0 {
return c.CostCoins
}
if c.SingleCoins > 0 {
return int64(c.SingleCoins)
}
return 0
}
// AllowWatch 详情选集是否可直达播放(优先 canWatch)。
func (c PlayletChapterData) AllowWatch() bool {
if c.CanWatch {
return true
}
if c.NeedUnlock || c.NeedVip {
return false
}
if c.IsUnlocked {
return true
}
return c.PayType == 0
}
// NeedCoinGate 是否弹出金币解锁。
func (c PlayletChapterData) NeedCoinGate() bool {
if c.AllowWatch() {
return false
}
if c.NeedUnlock {
return true
}
return c.PayType == 1
}
// NeedVipGate 是否弹出 VIP 开通。
func (c PlayletChapterData) NeedVipGate() bool {
if c.AllowWatch() || c.NeedCoinGate() {
return false
}
if c.NeedVip {
return true
}
return c.PayType == 2
}
// UnlockCost 金币解锁消耗:优先 costCoins。
func (c PlayletChapterData) UnlockCost() int {
if c.CostCoins > 0 {
return int(c.CostCoins)
}
return c.SingleCoins
}
+84
View File
@@ -0,0 +1,84 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const chapterCollectListPath = "/api/app/playlet/chapter/collect/list"
// PlayletChapterCollectData 对应 swagger proto.PlayletChapterCollectData
type PlayletChapterCollectData struct {
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
PlayletID int64 `json:"playletId"`
PlayletSlug string `json:"playletSlug"`
PlayletTitle string `json:"playletTitle"`
PlayletCover string `json:"playletCover"`
CurrentEpisode int `json:"currentEpisode"`
TotalEpisode int `json:"totalEpisode"`
UpdateStatus int `json:"updateStatus"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
IsCollect int `json:"isCollect"`
CollectedAt string `json:"collectedAt"`
MediaTime int `json:"mediaTime"`
PayType int `json:"payType"`
SingleCoins int `json:"singleCoins"`
VideoURL string `json:"videoUrl"`
}
// PlayletChapterCollectListResp 对应 swagger proto.PlayletChapterCollectListResp
type PlayletChapterCollectListResp struct {
List []PlayletChapterCollectData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// FetchChapterCollectList 拉取当前用户剧集收藏列表。
// 第二个返回值为上游业务 code(网络错误时为 0)。
func FetchChapterCollectList(c *gin.Context, q CollectListQuery) (*PlayletChapterCollectListResp, int, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[PlayletChapterCollectListResp]
if err := upstream.GetJSON(c, chapterCollectListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet chapter collect list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
// FetchChapterCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
func FetchChapterCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletChapterCollectListResp {
data, code, err := FetchChapterCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
data, _, err = FetchChapterCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
}
log.Error("fetch playlet chapter collect list failed", log.E(err))
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
+63
View File
@@ -0,0 +1,63 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const (
chapterLikeSetPath = "/api/app/playlet/chapter/like/set"
chapterCollectSetPath = "/api/app/playlet/chapter/collect/set"
)
// PlayletChapterLikeSetReq 对应 swagger proto.PlayletChapterLikeSetReq
type PlayletChapterLikeSetReq struct {
ChapterID int64 `json:"chapterId"`
IsLike bool `json:"isLike"`
}
// PlayletChapterLikeSetResp 对应 swagger proto.PlayletChapterLikeSetResp
type PlayletChapterLikeSetResp struct {
ChapterID int64 `json:"chapterId"`
CountLike int64 `json:"countLike"`
IsLike int `json:"isLike"`
}
// ChapterLikeSet 设置剧集点赞/取消点赞。
func ChapterLikeSet(c *gin.Context, req PlayletChapterLikeSetReq) (*APIEnvelope[PlayletChapterLikeSetResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter like invalid chapterId")
}
var env APIEnvelope[PlayletChapterLikeSetResp]
if _, err := upstream.PostJSON(c, chapterLikeSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// PlayletChapterCollectSetReq 对应 swagger proto.PlayletChapterCollectSetReq
type PlayletChapterCollectSetReq struct {
ChapterID int64 `json:"chapterId"`
IsCollect bool `json:"isCollect"`
}
// PlayletChapterCollectSetResp 对应 swagger proto.PlayletChapterCollectSetResp
type PlayletChapterCollectSetResp struct {
ChapterID int64 `json:"chapterId"`
CountCollect int64 `json:"countCollect"`
IsCollect int `json:"isCollect"`
}
// ChapterCollectSet 设置剧集收藏/取消收藏。
func ChapterCollectSet(c *gin.Context, req PlayletChapterCollectSetReq) (*APIEnvelope[PlayletChapterCollectSetResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter collect invalid chapterId")
}
var env APIEnvelope[PlayletChapterCollectSetResp]
if _, err := upstream.PostJSON(c, chapterCollectSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+40
View File
@@ -0,0 +1,40 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const chapterUnlockPath = "/api/app/playlet/chapter/unlock"
// PlayletChapterUnlockReq 对应 swagger proto.PlayletChapterUnlockReq
// POST /api/app/playlet/chapter/unlock
type PlayletChapterUnlockReq struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
// PlayletChapterUnlockResp 对应 swagger proto.PlayletChapterUnlockResp
type PlayletChapterUnlockResp struct {
Balance int64 `json:"balance"`
CanWatch bool `json:"canWatch"`
ChangeCoin int64 `json:"changeCoin"`
ChapterID int64 `json:"chapterId"`
CostCoins int64 `json:"costCoins"`
PlayletID int64 `json:"playletId"`
Unlocked bool `json:"unlocked"`
}
// ChapterUnlock 使用金币解锁剧集(幂等)。
func ChapterUnlock(c *gin.Context, req PlayletChapterUnlockReq) (*APIEnvelope[PlayletChapterUnlockResp], error) {
if req.ChapterID <= 0 || req.PlayletID <= 0 {
return nil, fmt.Errorf("chapter unlock invalid chapterId/playletId")
}
var env APIEnvelope[PlayletChapterUnlockResp]
if _, err := upstream.PostJSON(c, chapterUnlockPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+39
View File
@@ -0,0 +1,39 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const chapterWatchReportPath = "/api/app/playlet/chapter/watch/report"
// PlayletChapterWatchReportReq 对应 swagger proto.PlayletChapterWatchReportReq
type PlayletChapterWatchReportReq struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId,omitempty"`
}
// PlayletChapterWatchReportResp 对应 swagger proto.PlayletChapterWatchReportResp
type PlayletChapterWatchReportResp struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
CurrentEpisode int `json:"currentEpisode"`
LastChapterID int64 `json:"lastChapterId"`
LastEpisode int `json:"lastEpisode"`
WatchedCount int `json:"watchedCount"`
UnwatchedCount int `json:"unwatchedCount"`
}
// ChapterWatchReport 上报短剧剧集观看。
func ChapterWatchReport(c *gin.Context, req PlayletChapterWatchReportReq) (*APIEnvelope[PlayletChapterWatchReportResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter watch report invalid chapterId")
}
var env APIEnvelope[PlayletChapterWatchReportResp]
if _, err := upstream.PostJSON(c, chapterWatchReportPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+161
View File
@@ -0,0 +1,161 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
collectSetPath = "/api/app/playlet/collect/set"
collectListPath = "/api/app/playlet/collect/list"
defaultCollectPageSize = 40
CollectTypePlaylet = "playlet"
CollectTypeChapter = "chapter"
)
// PlayletCollectSetReq 对应 swagger proto.PlayletCollectSetReq
type PlayletCollectSetReq struct {
ID int64 `json:"id"`
IsCollect bool `json:"isCollect"`
}
// PlayletCollectSetResp 对应 swagger proto.PlayletCollectSetResp
type PlayletCollectSetResp struct {
ID int64 `json:"id"`
CountCollect int64 `json:"countCollect"`
IsCollect int `json:"isCollect"`
}
// PlayletCollectData 对应 swagger proto.PlayletCollectData
type PlayletCollectData struct {
ID int64 `json:"id"`
Title string `json:"title"`
PlayName string `json:"playName"`
Slug string `json:"slug"`
Cover string `json:"cover"`
Desc string `json:"desc"`
TagIDs []int64 `json:"tagIds"`
TagNames []string `json:"tagNames"`
TagSlugs []string `json:"tagSlugs"`
ThemeIDs []int64 `json:"themeIds"`
ThemeNames []string `json:"themeNames"`
ThemeSlugs []string `json:"themeSlugs"`
TotalEpisode int `json:"totalEpisode"`
UpdateStatus int `json:"updateStatus"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
IsFeatured bool `json:"isFeatured"`
IsNew bool `json:"isNew"`
IsRecommend bool `json:"isRecommend"`
IsCollect int `json:"isCollect"`
CollectedAt string `json:"collectedAt"`
WatchedCount int `json:"watchedCount"`
UnwatchedCount int `json:"unwatchedCount"`
LastChapterID int64 `json:"lastChapterId"`
LastEpisode int `json:"lastEpisode"`
}
// PlayletCollectListResp 对应 swagger proto.PlayletCollectListResp
type PlayletCollectListResp struct {
List []PlayletCollectData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// CollectListQuery 收藏列表分页参数。
type CollectListQuery struct {
PageNum int
PageSize int
WatchStatus int // 0 全部、1 追剧中、2 已看完;仅短剧收藏列表使用
Type string // playlet | chapter
}
// NormalizeCollectType 归一化收藏类型。
func NormalizeCollectType(raw string) string {
switch strings.TrimSpace(strings.ToLower(raw)) {
case CollectTypeChapter, "2", "episode", "ep":
return CollectTypeChapter
default:
return CollectTypePlaylet
}
}
// Normalize 校正分页默认值。
func (q *CollectListQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultCollectPageSize
}
if q.PageSize > 50 {
q.PageSize = 50
}
if q.WatchStatus < 0 || q.WatchStatus > 2 {
q.WatchStatus = 0
}
q.Type = NormalizeCollectType(q.Type)
}
// CollectSet 设置短剧收藏/取消收藏。
func CollectSet(c *gin.Context, req PlayletCollectSetReq) (*APIEnvelope[PlayletCollectSetResp], error) {
if req.ID <= 0 {
return nil, fmt.Errorf("playlet collect invalid id")
}
var env APIEnvelope[PlayletCollectSetResp]
if _, err := upstream.PostJSON(c, collectSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchCollectList 拉取当前用户短剧收藏列表。
// 第二个返回值为上游业务 code(网络错误时为 0)。
func FetchCollectList(c *gin.Context, q CollectListQuery) (*PlayletCollectListResp, int, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("watchStatus", strconv.Itoa(q.WatchStatus))
var env APIEnvelope[PlayletCollectListResp]
if err := upstream.GetJSON(c, collectListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet collect list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
// FetchCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
func FetchCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletCollectListResp {
data, code, err := FetchCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
data, _, err = FetchCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
}
log.Error("fetch playlet collect list failed", log.E(err))
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
+89
View File
@@ -0,0 +1,89 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
)
func collectPageURL(pageNum int, collectType string) string {
collectType = NormalizeCollectType(collectType)
path := "/collect"
if collectType != CollectTypePlaylet {
path += "/" + url.PathEscape(collectType)
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path
}
func CollectCanonicalPath(q CollectListQuery) string {
q.Normalize()
return collectPageURL(q.PageNum, q.Type)
}
// BuildCollectPagination 收藏列表分页。
func BuildCollectPagination(q CollectListQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(p int) string {
return collectPageURL(p, q.Type)
}, q.PageNum, q.PageSize, totalCount)
}
// ParseCollectListQuery 从 query 解析分页与类型(playlet 短剧 / chapter 剧集)。
func ParseCollectListQuery(pageStr, typeStr string) CollectListQuery {
q := CollectListQuery{
PageSize: defaultCollectPageSize,
Type: NormalizeCollectType(typeStr),
}
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = n
}
q.Normalize()
return q
}
// collectProgressText 进度文案:优先最近观看集,其次未看/已看完。
func collectProgressText(item PlayletCollectData) string {
if item.LastEpisode > 0 {
return fmt.Sprintf("第%d集", item.LastEpisode)
}
if item.TotalEpisode > 0 && item.UnwatchedCount <= 0 {
return "已看完"
}
if item.UnwatchedCount > 0 {
return fmt.Sprintf("%d集未看", item.UnwatchedCount)
}
return ""
}
// collectTagText 收藏卡片标签:仅展示 TagNames 第一项,无标签时不显示。
func collectTagText(item PlayletCollectData) string {
return firstName(item.TagNames)
}
// chapterCollectEpisodeLabel 剧集收藏角标:总集数。
func chapterCollectEpisodeLabel(item PlayletChapterCollectData) string {
return episodeText(item.TotalEpisode)
}
// chapterCollectDesc 剧集收藏副标题。
func chapterCollectDesc(item PlayletChapterCollectData) string {
if t := strings.TrimSpace(item.ChapterTitle); t != "" {
return t
}
if item.CurrentEpisode > 0 {
return fmt.Sprintf("第%d集", item.CurrentEpisode)
}
return ""
}
// chapterCollectMeta 剧集收藏右侧文案:第 N 集。
func chapterCollectMeta(item PlayletChapterCollectData) string {
if item.CurrentEpisode > 0 {
return fmt.Sprintf("第%d集", item.CurrentEpisode)
}
return updateStatusText(item.UpdateStatus)
}
+133
View File
@@ -0,0 +1,133 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
detailPath = "/api/app/playlet/detail"
defaultEpisodeRange = 30
)
// EpisodeRange 剧集分段(仅 UI 分页展示,非接口字段映射)。
type EpisodeRange struct {
Start int
End int
Label string
}
// FetchDetail 根据短剧 ID 查询详情。
func FetchDetail(c *gin.Context, id int64) (*PlayletInfoResp, error) {
if id <= 0 {
return nil, fmt.Errorf("playlet detail invalid id")
}
return fetchDetail(c, "id", strconv.FormatInt(id, 10))
}
// FetchDetailByKey 优先按 slug 查询;纯数字路由段按 ID 查询。
func FetchDetailByKey(c *gin.Context, key string) (*PlayletInfoResp, error) {
key = strings.TrimSpace(key)
if key == "" {
return nil, fmt.Errorf("playlet detail missing route key")
}
if id := ParseDetailID(key); id > 0 {
return FetchDetail(c, id)
}
return fetchDetail(c, "slug", key)
}
func fetchDetail(c *gin.Context, queryKey, value string) (*PlayletInfoResp, error) {
authser.EnsureGuestOnEnter(c)
env, err := fetchDetailOnce(c, queryKey, value)
if err != nil {
return nil, err
}
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
if authser.RecoverContentSession(c, env.Code) {
env, err = fetchDetailOnce(c, queryKey, value)
if err != nil {
return nil, err
}
}
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
env.Data.SeoInfo.Normalize()
return &env.Data, nil
}
func fetchDetailOnce(c *gin.Context, queryKey, value string) (*APIEnvelope[PlayletInfoResp], error) {
params := url.Values{}
params.Set(queryKey, value)
var env APIEnvelope[PlayletInfoResp]
if err := upstream.GetJSON(c, detailPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
return &env, nil
}
// FetchDetailOrNilByKey 失败或无效时返回 nil。
func FetchDetailOrNilByKey(c *gin.Context, key string) *PlayletInfoResp {
data, err := FetchDetailByKey(c, key)
if err != nil {
log.Error("fetch playlet detail by route key failed", log.E(err))
return nil
}
if data == nil || data.ID <= 0 {
return nil
}
return data
}
// ParseDetailID 解析纯数字路由段。
func ParseDetailID(raw string) int64 {
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || id <= 0 {
return 0
}
return id
}
// BuildEpisodeRanges 按集数分段(默认每段 30 集)。
func BuildEpisodeRanges(chapters []PlayletChapterData, totalEpisode, size int) []EpisodeRange {
if size <= 0 {
size = defaultEpisodeRange
}
maxEp := 0
for _, ch := range chapters {
if ch.CurrentEpisode > maxEp {
maxEp = ch.CurrentEpisode
}
}
if totalEpisode > maxEp {
maxEp = totalEpisode
}
if maxEp <= 0 {
maxEp = len(chapters)
}
if maxEp <= 0 {
return nil
}
out := make([]EpisodeRange, 0, (maxEp+size-1)/size)
for start := 1; start <= maxEp; start += size {
end := start + size - 1
if end > maxEp {
end = maxEp
}
out = append(out, EpisodeRange{
Start: start,
End: end,
Label: fmt.Sprintf("%d–%d", start, end),
})
}
return out
}
+69
View File
@@ -0,0 +1,69 @@
package playletser
import (
"bytes"
"encoding/json"
"strings"
)
// UnmarshalJSON 兼容上游 data 为对象、null、空字符串,或二次 JSON 编码的字符串。
func (e *APIEnvelope[T]) UnmarshalJSON(raw []byte) error {
if e == nil {
return nil
}
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil
}
var aux struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &aux); err != nil {
return err
}
e.Code = aux.Code
e.Msg = aux.Msg
e.Tip = aux.Tip
data, err := unmarshalEnvelopeData[T](aux.Data)
if err != nil {
return err
}
e.Data = data
return nil
}
func unmarshalEnvelopeData[T any](raw json.RawMessage) (T, error) {
var zero T
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return zero, nil
}
if raw[0] == '"' {
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return zero, err
}
s = strings.TrimSpace(s)
if s == "" {
return zero, nil
}
inner := bytes.TrimSpace([]byte(s))
if len(inner) == 0 {
return zero, nil
}
if inner[0] != '{' && inner[0] != '[' {
return zero, nil
}
if err := json.Unmarshal(inner, &zero); err != nil {
return zero, err
}
return zero, nil
}
if err := json.Unmarshal(raw, &zero); err != nil {
return zero, err
}
return zero, nil
}
+33
View File
@@ -0,0 +1,33 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const forwardHeatPath = "/api/app/playlet/forward/heat"
// PlayletForwardHeatReq 对应 swagger proto.PlayletForwardHeatReq
type PlayletForwardHeatReq struct {
ID int64 `json:"id"`
}
// PlayletForwardHeatResp 对应 swagger proto.PlayletForwardHeatResp
type PlayletForwardHeatResp struct {
ID int64 `json:"id"`
HeatValue int64 `json:"heatValue"`
}
// ForwardHeat 短剧转发增加热度。
func ForwardHeat(c *gin.Context, req PlayletForwardHeatReq) (*APIEnvelope[PlayletForwardHeatResp], error) {
if req.ID <= 0 {
return nil, fmt.Errorf("playlet forward heat invalid id")
}
var env APIEnvelope[PlayletForwardHeatResp]
if _, err := upstream.PostJSON(c, forwardHeatPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
+132
View File
@@ -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)
}
+166
View File
@@ -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
}
+229
View File
@@ -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))
}
+239
View File
@@ -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)]
}
+37
View File
@@ -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
}
+44
View File
@@ -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
}
+83
View File
@@ -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
}
+82
View File
@@ -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 "热播榜"
}
}
+35
View File
@@ -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
}
+355
View File
@@ -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
}
+254
View File
@@ -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
}
+152
View File
@@ -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
}
+177
View File
@@ -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
}
+81
View File
@@ -0,0 +1,81 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const operateListPath = "/api/app/playlet/operate/list"
type OperateListQuery struct {
OperateType int
FilterType int
PageNum int
PageSize int
}
func (q *OperateListQuery) Normalize() {
if q.OperateType < 1 || q.OperateType > 2 {
q.OperateType = 1
}
if q.FilterType < 1 || q.FilterType > 3 {
q.FilterType = 1
}
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = 40
}
if q.PageSize > 100 {
q.PageSize = 100
}
}
// FetchOperateList 按运营类型和筛选类型查询已上架短剧。
func FetchOperateList(c *gin.Context, q OperateListQuery) (*PlayletOperateListResp, error) {
q.Normalize()
params := url.Values{}
params.Set("operateType", strconv.Itoa(q.OperateType))
params.Set("filterType", strconv.Itoa(q.FilterType))
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[PlayletOperateListResp]
if err := upstream.GetPublicJSON(c, operateListPath+"?"+params.Encode(), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet operate list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
}
return &env.Data, nil
}
// FetchOperateListOrEmpty 为 SSR 页面提供容错,接口异常时仍渲染完整页面骨架。
func FetchOperateListOrEmpty(c *gin.Context, q OperateListQuery) *PlayletOperateListResp {
q.Normalize()
data, err := FetchOperateList(c, q)
if err != nil {
log.Error("fetch playlet operate list failed", log.E(err))
return &PlayletOperateListResp{
OperateType: q.OperateType,
FilterType: q.FilterType,
PageNum: q.PageNum,
PageSize: q.PageSize,
}
}
if data == nil {
return &PlayletOperateListResp{
OperateType: q.OperateType,
FilterType: q.FilterType,
PageNum: q.PageNum,
PageSize: q.PageSize,
}
}
return data
}
+80
View File
@@ -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
}
+111
View File
@@ -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
}
+83
View File
@@ -0,0 +1,83 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"strings"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const (
recommendListPath = "/api/app/playlet/list"
defaultRecommendSize = 25
)
// RecommendQuery 专题详情分页参数。
type RecommendQuery struct {
PageNum int
PageSize int
SortOrder int
ThemeID int64
ThemeSlug string
}
// Normalize 校正分页默认值。
func (q *RecommendQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultRecommendSize
}
if q.PageSize > 100 {
q.PageSize = 100
}
if q.SortOrder != sortTodayUpdate {
q.SortOrder = sortHeat
}
}
func recommendListURL(q RecommendQuery) string {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
if slug := strings.TrimSpace(q.ThemeSlug); slug != "" {
params.Set("themeSlug", slug)
} else if q.ThemeID > 0 {
params.Set("themeId", strconv.FormatInt(q.ThemeID, 10))
}
return recommendListPath + "?" + params.Encode()
}
// FetchRecommendList 按 Swagger 的题材 ID 或 slug 参数分页查询专题短剧。
func FetchRecommendList(c *gin.Context, q RecommendQuery) (*PlayletVideoResp, error) {
var env APIEnvelope[PlayletVideoResp]
if err := upstream.GetPublicJSON(c, recommendListURL(q), &env); err != nil {
return nil, err
}
if env.Code != 200 {
return nil, fmt.Errorf("playlet recommend list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, nil
}
// FetchRecommendListOrEmpty 失败时返回空列表。
func FetchRecommendListOrEmpty(c *gin.Context, q RecommendQuery) *PlayletVideoResp {
q.Normalize()
data, err := FetchRecommendList(c, q)
if err != nil {
log.Error("fetch playlet recommend list failed", log.E(err))
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
if data == nil {
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
data.SeoInfo.Normalize()
return data
}
+64
View File
@@ -0,0 +1,64 @@
package playletser
import (
"strconv"
"strings"
)
func recommendPageURL(themeID int64, themeSlug string, pageNum int) string {
if pageNum <= 0 {
pageNum = 1
}
path := "/topic-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
}
+176
View File
@@ -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
}
+381
View File
@@ -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]+)$)`)
+123
View File
@@ -0,0 +1,123 @@
package playletser
import (
"strconv"
"strings"
)
const (
seriesOperateType = 2
seriesPageSize = 25
seriesFilterLatest = 1
seriesFilterHot = 2
seriesFilterOrigin = 3
)
var seriesFilters = []struct {
Type int
Slug string
Label string
}{
{Type: seriesFilterLatest, Slug: "latest", Label: "最新更新"},
{Type: seriesFilterHot, Slug: "popular", Label: "当前热播"},
{Type: seriesFilterOrigin, Slug: "original", Label: "独家原创"},
}
// ParseSeriesQuery 将语义化筛选路由转换为 Swagger 的 operateType/filterType 参数。
func ParseSeriesQuery(filterSlug, pageStr string) (OperateListQuery, bool) {
filterType, ok := seriesFilterType(filterSlug)
if !ok {
return OperateListQuery{}, false
}
q := OperateListQuery{
OperateType: seriesOperateType,
FilterType: filterType,
PageSize: seriesPageSize,
}
if page, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
q.PageNum = page
}
q.Normalize()
return q, true
}
func seriesFilterType(slug string) (int, bool) {
slug = strings.TrimSpace(slug)
if slug == "" || slug == "latest" {
return seriesFilterLatest, true
}
for _, filter := range seriesFilters {
if filter.Slug == slug {
return filter.Type, true
}
}
return 0, false
}
func seriesFilterSlug(filterType int) string {
for _, filter := range seriesFilters {
if filter.Type == filterType {
return filter.Slug
}
}
return "latest"
}
func seriesPageURL(filterType, pageNum int) string {
if pageNum <= 0 {
pageNum = 1
}
path := "/series"
if filterType != seriesFilterLatest {
path += "/" + seriesFilterSlug(filterType)
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path
}
// SeriesCanonicalPath 返回 AI 成人剧集页唯一规范路径。
func SeriesCanonicalPath(q OperateListQuery) string {
q.Normalize()
return seriesPageURL(q.FilterType, q.PageNum)
}
// BuildSeriesFilterTabs 构建 Figma 中的三个运营筛选项。
func BuildSeriesFilterTabs(q OperateListQuery) []FilterChipVM {
q.Normalize()
out := make([]FilterChipVM, 0, len(seriesFilters))
for _, filter := range seriesFilters {
out = append(out, FilterChipVM{
Label: filter.Label,
URL: seriesPageURL(filter.Type, 1),
Active: q.FilterType == filter.Type,
})
}
return out
}
func SeriesSectionTitle(filterType int) string {
for _, filter := range seriesFilters {
if filter.Type == filterType {
return filter.Label + "的AI成人剧集"
}
}
return "最新更新的AI成人剧集"
}
func BuildSeriesPagination(q OperateListQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(page int) string {
return seriesPageURL(q.FilterType, page)
}, q.PageNum, q.PageSize, totalCount)
}
// SeriesPathFromLegacyList 将原 /list/ai-series 排序地址映射到新页面。
func SeriesPathFromLegacyList(sortOrder, pageNum int) string {
filterType := seriesFilterLatest
if sortOrder == sortHeat {
filterType = seriesFilterHot
}
return seriesPageURL(filterType, pageNum)
}
+110
View File
@@ -0,0 +1,110 @@
package playletser
import (
"strconv"
"strings"
)
const (
shortPlayOperateType = 1
shortPlayPageSize = 25
)
var shortPlayFilters = []struct {
Type int
Slug string
Label string
}{
{Type: 1, Slug: "latest", Label: "最新更新"},
{Type: 2, Slug: "popular", Label: "当前热播"},
{Type: 3, Slug: "original", Label: "独家原创"},
}
// ParseShortPlayQuery 将短剧页路由转换为 operate/list 的 Swagger 参数。
func ParseShortPlayQuery(filter, page string) (OperateListQuery, bool) {
filterType, ok := shortPlayFilterType(filter)
if !ok {
return OperateListQuery{}, false
}
q := OperateListQuery{
OperateType: shortPlayOperateType,
FilterType: filterType,
PageSize: shortPlayPageSize,
}
if pageNum, err := strconv.Atoi(strings.TrimSpace(page)); err == nil {
q.PageNum = pageNum
}
q.Normalize()
return q, true
}
func shortPlayFilterType(filter string) (int, bool) {
filter = strings.TrimSpace(filter)
switch filter {
case "", "latest", "2":
return 1, true
case "popular", "1":
return 2, true
case "original", "3":
return 3, true
default:
return 0, false
}
}
func shortPlayFilterSlug(filterType int) string {
for _, filter := range shortPlayFilters {
if filter.Type == filterType {
return filter.Slug
}
}
return "latest"
}
func shortPlayPageURL(filterType, pageNum int) string {
if pageNum <= 0 {
pageNum = 1
}
path := "/list/ai-short-play"
if filterType != 1 || pageNum > 1 {
path += "/" + shortPlayFilterSlug(filterType)
}
if pageNum > 1 {
path += "/page/" + strconv.Itoa(pageNum)
}
return path
}
func ShortPlayCanonicalPath(q OperateListQuery) string {
q.Normalize()
return shortPlayPageURL(q.FilterType, q.PageNum)
}
func BuildShortPlayFilterTabs(q OperateListQuery) []FilterChipVM {
q.Normalize()
out := make([]FilterChipVM, 0, len(shortPlayFilters))
for _, filter := range shortPlayFilters {
out = append(out, FilterChipVM{
Label: filter.Label,
URL: shortPlayPageURL(filter.Type, 1),
Active: q.FilterType == filter.Type,
})
}
return out
}
func ShortPlaySectionTitle(filterType int) string {
for _, filter := range shortPlayFilters {
if filter.Type == filterType {
return filter.Label + "的AI成人短剧"
}
}
return "最新更新的AI成人短剧"
}
func BuildShortPlayPagination(q OperateListQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(func(page int) string {
return shortPlayPageURL(q.FilterType, page)
}, q.PageNum, q.PageSize, totalCount)
}
+155
View File
@@ -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
}
+291
View File
@@ -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)
}
+72
View File
@@ -0,0 +1,72 @@
package playletser
import (
"strconv"
"strings"
)
const defaultThemeIndexPageSize = 20
// ThemeIndexQuery 专题列表页分页参数。
type ThemeIndexQuery struct {
PageNum int
PageSize int
}
func (q *ThemeIndexQuery) Normalize() {
if q.PageNum <= 0 {
q.PageNum = 1
}
if q.PageSize <= 0 {
q.PageSize = defaultThemeIndexPageSize
}
}
// ParseThemeIndexQuery 从语义化分页路由读取页码。
func ParseThemeIndexQuery(page string) ThemeIndexQuery {
q := ThemeIndexQuery{PageSize: defaultThemeIndexPageSize}
if n, err := strconv.Atoi(strings.TrimSpace(page)); err == nil {
q.PageNum = n
}
q.Normalize()
return q
}
// BuildThemeIndexPage 过滤已启用专题,并按设计稿每页 20 项分页。
func BuildThemeIndexPage(themes []PlayletThemeItem, q ThemeIndexQuery) ([]PlayletThemeItem, int64) {
q.Normalize()
visible := make([]PlayletThemeItem, 0, len(themes))
for _, theme := range themes {
if !theme.Status || theme.ID <= 0 || strings.TrimSpace(theme.Name) == "" {
continue
}
visible = append(visible, theme)
}
total := int64(len(visible))
start := (q.PageNum - 1) * q.PageSize
if start >= len(visible) {
return nil, total
}
end := start + q.PageSize
if end > len(visible) {
end = len(visible)
}
return visible[start:end], total
}
func themeIndexPageURL(page int) string {
if page <= 1 {
return "/topic"
}
return "/topic/page/" + strconv.Itoa(page)
}
func ThemeIndexCanonicalPath(q ThemeIndexQuery) string {
q.Normalize()
return themeIndexPageURL(q.PageNum)
}
func BuildThemeIndexPagination(q ThemeIndexQuery, totalCount int64) PaginationVM {
q.Normalize()
return BuildPagination(themeIndexPageURL, q.PageNum, q.PageSize, totalCount)
}
+67
View File
@@ -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
}
+218
View File
@@ -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 "编辑排序"
}
}
+167
View File
@@ -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
}
+488
View File
@@ -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
}
+429
View File
@@ -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"`
}
+180
View File
@@ -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 ""
}
+281
View File
@@ -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
}
}
+125
View File
@@ -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"`
}
+496
View File
@@ -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()
}
+180
View File
@@ -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
}
+940
View File
@@ -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)
}
+59
View File
@@ -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), " ")
}
+75
View File
@@ -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
`
+226
View File
@@ -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, "/")
}
+59
View File
@@ -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
}
+90
View File
@@ -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})
}
+46
View File
@@ -0,0 +1,46 @@
package translationser
// AppTranslationInfo 对应 swagger proto.AppTranslationInfo
// GET /api/app/translation/category/list
type AppTranslationInfo struct {
CategoryList []TranslationCategory `json:"categoryList"`
}
// TranslationCategory 对应 swagger proto.TranslationCategory
type TranslationCategory struct {
ID int64 `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
URLValue string `json:"urlValue"`
CategoryType int `json:"categoryType"` // 1 URL 2 二级 3 底部
TranslationList []TranslationInfo `json:"translationList"`
SeoInfo SeoValue `json:"seoInfo"`
}
// SeoValue 对应 swagger pubdef.SeoValue(导航分类 seoInfo)
type SeoValue struct {
Title string `json:"title"`
Keyworld []string `json:"keyworld"`
Description string `json:"description"`
}
// TranslationInfo 对应 swagger proto.TranslationInfo
type TranslationInfo struct {
Name string `json:"name"`
URLValue string `json:"urlValue"`
IsFriendAds bool `json:"isFriendAds"`
}
// APIEnvelope 上游统一响应壳
type APIEnvelope[T any] struct {
Code int `json:"code"`
Msg string `json:"msg"`
Tip string `json:"tip"`
Data T `json:"data"`
}
const (
TranslationTypeURL = 1 // URL 模式
TranslationTypeSub = 2 // 二级导航
TranslationTypeBottom = 3 // 底部导航
)
+465
View File
@@ -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
}
}
+222
View File
@@ -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] + "..."
}
+42
View File
@@ -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
}
+130
View File
@@ -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}
}
+70
View File
@@ -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)
}
+141
View File
@@ -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
}
+37
View File
@@ -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
}
+60
View File
@@ -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)
}
+186
View File
@@ -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
}
+203
View File
@@ -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)
}