Initial commit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package applistser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const listPath = "/api/app/applist/list"
|
||||
|
||||
// FetchList 拉取应用分类列表。categoryId=0 表示默认。
|
||||
func FetchList(c *gin.Context, categoryID int64) (*AppListResp, error) {
|
||||
path := fmt.Sprintf("%s?categoryId=%d", listPath, categoryID)
|
||||
var env APIEnvelope[AppListResp]
|
||||
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("applist code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchListOrEmpty 失败时返回空结构,保证 SSR 可渲染。
|
||||
func FetchListOrEmpty(c *gin.Context, categoryID int64) *AppListResp {
|
||||
data, err := FetchList(c, categoryID)
|
||||
if err != nil {
|
||||
log.Error("fetch applist failed", log.E(err))
|
||||
return &AppListResp{}
|
||||
}
|
||||
if data == nil {
|
||||
return &AppListResp{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package applistser
|
||||
|
||||
// AppListResp 对应 swagger proto.AppListResp 的业务字段(忽略无关 config/advitise)。
|
||||
type AppListResp struct {
|
||||
Category AppCategoryInfo `json:"category"`
|
||||
CategoryList []AppCategoryInfo `json:"categoryList"`
|
||||
}
|
||||
|
||||
// AppCategoryInfo 对应 swagger proto.AppCategoryInfo
|
||||
type AppCategoryInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Code int `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Intro string `json:"intro"`
|
||||
Sort int `json:"sort"`
|
||||
AppList []AppInfoData `json:"appList"`
|
||||
}
|
||||
|
||||
// AppInfoData 对应 swagger proto.AppInfoData
|
||||
type AppInfoData struct {
|
||||
ID int64 `json:"id"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
Name string `json:"name"`
|
||||
Intro string `json:"intro"`
|
||||
Description string `json:"description"`
|
||||
Logo string `json:"logo"`
|
||||
URL string `json:"url"`
|
||||
Sort int `json:"sort"`
|
||||
AdvitiseID string `json:"advitiseId"`
|
||||
Location int `json:"location"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package applistser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PageVM 福利应用页模板数据
|
||||
type PageVM struct {
|
||||
Categories []CategoryVM
|
||||
}
|
||||
|
||||
type CategoryVM struct {
|
||||
ID int64
|
||||
SectionID string
|
||||
Title string
|
||||
Intro string
|
||||
Total int
|
||||
Apps []AppCardVM
|
||||
IsFirst bool
|
||||
}
|
||||
|
||||
type AppCardVM struct {
|
||||
ID int64
|
||||
Name string
|
||||
Intro string
|
||||
Logo string
|
||||
URL string
|
||||
}
|
||||
|
||||
// BuildPageVM 按接口返回的 categoryList / appList 顺序组装页面数据,不做二次排序。
|
||||
func BuildPageVM(data *AppListResp) PageVM {
|
||||
vm := PageVM{Categories: []CategoryVM{}}
|
||||
if data == nil {
|
||||
return vm
|
||||
}
|
||||
list := data.CategoryList
|
||||
if len(list) == 0 && data.Category.ID > 0 {
|
||||
list = []AppCategoryInfo{data.Category}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return vm
|
||||
}
|
||||
|
||||
for i, cat := range list {
|
||||
cards := make([]AppCardVM, 0, len(cat.AppList))
|
||||
for _, app := range cat.AppList {
|
||||
name := strings.TrimSpace(app.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
href := strings.TrimSpace(app.URL)
|
||||
if href == "" {
|
||||
href = "#"
|
||||
}
|
||||
intro := strings.TrimSpace(app.Intro)
|
||||
if intro == "" {
|
||||
intro = strings.TrimSpace(app.Description)
|
||||
}
|
||||
cards = append(cards, AppCardVM{
|
||||
ID: app.ID,
|
||||
Name: name,
|
||||
Intro: intro,
|
||||
Logo: strings.TrimSpace(app.Logo),
|
||||
URL: href,
|
||||
})
|
||||
}
|
||||
title := strings.TrimSpace(cat.Title)
|
||||
if title == "" {
|
||||
title = "应用分类"
|
||||
}
|
||||
vm.Categories = append(vm.Categories, CategoryVM{
|
||||
ID: cat.ID,
|
||||
SectionID: fmt.Sprintf("app-category-%d", cat.ID),
|
||||
Title: title,
|
||||
Intro: strings.TrimSpace(cat.Intro),
|
||||
Total: len(cards),
|
||||
Apps: cards,
|
||||
IsFirst: i == 0,
|
||||
})
|
||||
}
|
||||
return vm
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
registerPath = "/api/app/auth/register"
|
||||
loginPath = "/api/app/auth/login"
|
||||
resetPath = "/api/app/auth/resetPassword"
|
||||
guestPath = "/api/app/auth/guest"
|
||||
getInfoPath = "/api/app/auth/getInfo"
|
||||
profilePath = "/api/app/auth/profile"
|
||||
uploadPath = "/api/app/upload/image"
|
||||
cookieMaxAge = 7 * 24 * 3600
|
||||
)
|
||||
|
||||
// Register 调用上游注册接口。
|
||||
func Register(c *gin.Context, req RegisterReq) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, registerPath, req, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// ResetPassword 通过账号和邮箱校验后重置密码。
|
||||
func ResetPassword(c *gin.Context, req ResetPasswordReq) (*APIEnvelope, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, resetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// Login 调用上游登录接口。
|
||||
func Login(c *gin.Context, req LoginReq) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, loginPath, req, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// GuestLogin 调用上游游客登录(按设备 ID 创建或找回游客账号)。
|
||||
func GuestLogin(c *gin.Context, req GuestLoginReq) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, guestPath, req, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// GetInfo 通过 token 拉取最新用户信息。
|
||||
func GetInfo(c *gin.Context) (*APIEnvelope, *LoginResp, error) {
|
||||
var env APIEnvelope
|
||||
if err := upstream.GetJSON(c, getInfoPath, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return &env, nil, nil
|
||||
}
|
||||
user, err := decodeLoginData(env.Data)
|
||||
if err != nil {
|
||||
return &env, nil, err
|
||||
}
|
||||
return &env, user, nil
|
||||
}
|
||||
|
||||
// UpdateProfile 修改资料(昵称 / 头像等)。
|
||||
func UpdateProfile(c *gin.Context, req UpdateProfileReq) (*APIEnvelope, error) {
|
||||
var env APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, profilePath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// UploadImage 上传图片;头像场景不加水印。
|
||||
func UploadImage(c *gin.Context, fileName string, r io.Reader) (*ImageUploadResp, error) {
|
||||
var resp ImageUploadResp
|
||||
_, err := upstream.PostMultipart(c, uploadPath, map[string]string{
|
||||
"watermark": "0",
|
||||
}, &upstream.MultipartFile{
|
||||
FieldName: "file",
|
||||
FileName: fileName,
|
||||
Reader: r,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func decodeLoginData(raw json.RawMessage) (*LoginResp, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" || string(raw) == `""` {
|
||||
return nil, fmt.Errorf("empty login data")
|
||||
}
|
||||
var user LoginResp
|
||||
if err := json.Unmarshal(raw, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// SetAuthCookies 将登录/注册返回写入 cookie。
|
||||
func SetAuthCookies(c *gin.Context, user *LoginResp) {
|
||||
if c == nil || user == nil {
|
||||
return
|
||||
}
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
if strings.TrimSpace(user.Token) != "" {
|
||||
setCookie(c, "token", user.Token, secure)
|
||||
}
|
||||
setCookie(c, "userId", strconv.FormatInt(user.UserID, 10), secure)
|
||||
setCookie(c, "accountNo", user.AccountNo, secure)
|
||||
setCookie(c, "nickName", user.NickName, secure)
|
||||
setCookie(c, "introduction", user.Introduction, secure)
|
||||
setCookie(c, "avatar", user.Avatar, secure)
|
||||
setCookie(c, "email", user.Email, secure)
|
||||
setCookie(c, "inviteCode", user.InviteCode, secure)
|
||||
setCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10), secure)
|
||||
setCookie(c, "vipType", strconv.Itoa(user.VipType), secure)
|
||||
setCookie(c, "currentVipExpire", user.CurrentVipExpire, secure)
|
||||
setCookie(c, "showCardId", strconv.FormatInt(user.ShowCardID, 10), secure)
|
||||
setCookie(c, "showCardName", user.ShowCardName, secure)
|
||||
setCookie(c, "userType", strconv.Itoa(user.Type), secure)
|
||||
isReg := "0"
|
||||
if user.IsRegister || user.Type == 1 {
|
||||
isReg = "1"
|
||||
}
|
||||
setCookie(c, "isRegister", isReg, secure)
|
||||
if sid := useragent.SIDFromRequest(c.Request); sid != "" {
|
||||
setCookie(c, "sid", sid, secure)
|
||||
}
|
||||
}
|
||||
|
||||
func setCookie(c *gin.Context, name, value string, secure bool) {
|
||||
// Cookie Value 仅允许 ASCII;中文昵称等需 URL 编码。
|
||||
// 使用 PathEscape(空格→%20),与前端 encodeURIComponent 一致;
|
||||
// 避免 QueryEscape 的空格→+,否则 JS decodeURIComponent 会留下字面量 "+"。
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: url.PathEscape(value),
|
||||
Path: "/",
|
||||
MaxAge: cookieMaxAge,
|
||||
HttpOnly: false,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(7 * 24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func cookieValue(c *gin.Context, name string) string {
|
||||
ck, err := c.Request.Cookie(name)
|
||||
if err != nil || ck == nil {
|
||||
return ""
|
||||
}
|
||||
raw := strings.TrimSpace(ck.Value)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// QueryUnescape 同时兼容历史 QueryEscape(+)与 PathEscape(%20)
|
||||
if decoded, err := url.QueryUnescape(raw); err == nil {
|
||||
return strings.TrimSpace(decoded)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ClearAuthCookies 清除登录相关 cookie。
|
||||
func ClearAuthCookies(c *gin.Context) {
|
||||
for _, name := range authCookieNames() {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func authCookieNames() []string {
|
||||
return []string{
|
||||
"token", "_token_", "sid", "_s_token_",
|
||||
"userId", "accountNo", "nickName", "introduction", "avatar",
|
||||
"email", "inviteCode", "recharge", "vipType", "currentVipExpire", "showCardId", "showCardName",
|
||||
"userType", "isRegister",
|
||||
}
|
||||
}
|
||||
|
||||
// AuthFromCookies 从 cookie 读取登录态,供 SSR 头部 / 个人中心展示。
|
||||
func AuthFromCookies(c *gin.Context) (loggedIn bool, userName, avatar, email string) {
|
||||
token := useragent.TokenFromRequest(c.Request)
|
||||
if token == "" {
|
||||
return false, "", "", ""
|
||||
}
|
||||
userName = cookieValue(c, "nickName")
|
||||
if userName == "" {
|
||||
userName = cookieValue(c, "accountNo")
|
||||
}
|
||||
avatar = usableAvatarURL(cookieValue(c, "avatar"))
|
||||
email = cookieValue(c, "email")
|
||||
return true, userName, avatar, email
|
||||
}
|
||||
|
||||
// IsRegisteredFromCookies 是否正式注册账号(非游客)。
|
||||
func IsRegisteredFromCookies(c *gin.Context) bool {
|
||||
if useragent.TokenFromRequest(c.Request) == "" {
|
||||
return false
|
||||
}
|
||||
if cookieValue(c, "isRegister") == "1" {
|
||||
return true
|
||||
}
|
||||
return cookieValue(c, "userType") == "1"
|
||||
}
|
||||
|
||||
func usableAvatarURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "/static/") {
|
||||
return raw
|
||||
}
|
||||
// 无域名的相对路径暂不可用(解密拉取需要完整 URL)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const guestDevIDCookie = "guest_dev_id"
|
||||
const guestSkipAutoCookie = "guest_skip_auto"
|
||||
|
||||
// EnsureGuestOnEnter 页面进入时:无 token(未自己登录)则走游客登录并写 cookie。
|
||||
// 对应上游 POST /api/app/auth/guest。成功后会把会话补进当前 Request,便于同一次 SSR 读到登录态。
|
||||
// 手动退出后带 guest_skip_auto 时跳过本次;清掉标记后,下次进页再自动游客。
|
||||
func EnsureGuestOnEnter(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
// 已有任意会话(游客或正式账号)则跳过
|
||||
if useragent.TokenFromRequest(c.Request) != "" {
|
||||
return nil
|
||||
}
|
||||
// 手动退出:本次进页不自动游客,并清除标记,供下次进页恢复
|
||||
if IsGuestSkipAuto(c) {
|
||||
ClearGuestSkipAuto(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
devID := ensureGuestDevID(c)
|
||||
env, user, err := GuestLogin(c, GuestLoginReq{
|
||||
DevID: devID,
|
||||
DeviceID: devID,
|
||||
Channel: guestChannel(c),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("ensure guest on enter failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
if env == nil || env.Code != 200 || user == nil || strings.TrimSpace(user.Token) == "" {
|
||||
tip := ""
|
||||
if env != nil {
|
||||
tip = firstNonEmptyTip(env.Tip, env.Msg)
|
||||
}
|
||||
log.Error("ensure guest on enter rejected", log.E(fmt.Errorf("code=%v tip=%s", envCode(env), tip)))
|
||||
return nil
|
||||
}
|
||||
|
||||
SetAuthCookies(c, user)
|
||||
ClearGuestSkipAuto(c)
|
||||
patchAuthCookiesToRequest(c, user)
|
||||
return user
|
||||
}
|
||||
|
||||
// RefreshGuestSession 游客会话失效时(如上游 6001 用户不存在)重新游客登录。
|
||||
// 正式注册账号不处理,避免把用户踢成游客。
|
||||
func RefreshGuestSession(c *gin.Context) *LoginResp {
|
||||
if c == nil || c.Request == nil {
|
||||
return nil
|
||||
}
|
||||
if IsRegisteredFromCookies(c) {
|
||||
return nil
|
||||
}
|
||||
dropRequestCookie(c, "token")
|
||||
dropRequestCookie(c, "_token_")
|
||||
return EnsureGuestOnEnter(c)
|
||||
}
|
||||
|
||||
func dropRequestCookie(c *gin.Context, name string) {
|
||||
if c == nil || c.Request == nil || name == "" {
|
||||
return
|
||||
}
|
||||
var parts []string
|
||||
for _, ck := range c.Request.Cookies() {
|
||||
if ck == nil || ck.Name == name {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, ck.Name+"="+ck.Value)
|
||||
}
|
||||
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
// MarkGuestSkipAuto 手动退出后标记:跳过紧接着的一次自动游客登录。
|
||||
func MarkGuestSkipAuto(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: guestSkipAutoCookie,
|
||||
Value: "1",
|
||||
Path: "/",
|
||||
MaxAge: 0, // session cookie
|
||||
HttpOnly: false,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// IsGuestSkipAuto 是否处于「手动退出后跳过自动游客」状态。
|
||||
func IsGuestSkipAuto(c *gin.Context) bool {
|
||||
return cookieValue(c, guestSkipAutoCookie) == "1"
|
||||
}
|
||||
|
||||
// ClearGuestSkipAuto 清除手动退出跳过标记。
|
||||
func ClearGuestSkipAuto(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: guestSkipAutoCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func ensureGuestDevID(c *gin.Context) string {
|
||||
if id := cookieValue(c, guestDevIDCookie); id != "" {
|
||||
return id
|
||||
}
|
||||
id := newGuestDevID()
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
setCookie(c, guestDevIDCookie, id, secure)
|
||||
patchRequestCookie(c, guestDevIDCookie, id)
|
||||
return id
|
||||
}
|
||||
|
||||
func newGuestDevID() string {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
now := time.Now()
|
||||
return fmt.Sprintf("%016X%d", uint64(now.UnixNano()), now.UnixMilli())
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(b)) + strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
}
|
||||
|
||||
// guestChannel 为首屏服务端自动游客登录补渠道;cookie 用于复用前端首次归因结果。
|
||||
func guestChannel(c *gin.Context) string {
|
||||
if c == nil || c.Request == nil {
|
||||
return ""
|
||||
}
|
||||
if channel := cookieValue(c, "channel"); channel != "" {
|
||||
return channel
|
||||
}
|
||||
for _, key := range []string{"dc", "ch", "pc"} {
|
||||
if channel := strings.TrimSpace(c.Query(key)); channel != "" {
|
||||
secure := c.Request.TLS != nil
|
||||
setCookie(c, "channel", channel, secure)
|
||||
patchRequestCookie(c, "channel", channel)
|
||||
return channel
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func patchAuthCookiesToRequest(c *gin.Context, user *LoginResp) {
|
||||
if c == nil || user == nil {
|
||||
return
|
||||
}
|
||||
patchRequestCookie(c, "token", user.Token)
|
||||
patchRequestCookie(c, "userId", strconv.FormatInt(user.UserID, 10))
|
||||
patchRequestCookie(c, "accountNo", user.AccountNo)
|
||||
patchRequestCookie(c, "nickName", user.NickName)
|
||||
patchRequestCookie(c, "introduction", user.Introduction)
|
||||
patchRequestCookie(c, "avatar", user.Avatar)
|
||||
patchRequestCookie(c, "email", user.Email)
|
||||
patchRequestCookie(c, "inviteCode", user.InviteCode)
|
||||
patchRequestCookie(c, "recharge", strconv.FormatInt(user.Recharge, 10))
|
||||
patchRequestCookie(c, "vipType", strconv.Itoa(user.VipType))
|
||||
patchRequestCookie(c, "currentVipExpire", user.CurrentVipExpire)
|
||||
patchRequestCookie(c, "showCardId", strconv.FormatInt(user.ShowCardID, 10))
|
||||
patchRequestCookie(c, "showCardName", user.ShowCardName)
|
||||
patchRequestCookie(c, "userType", strconv.Itoa(user.Type))
|
||||
isReg := "0"
|
||||
if user.IsRegister || user.Type == 1 {
|
||||
isReg = "1"
|
||||
}
|
||||
patchRequestCookie(c, "isRegister", isReg)
|
||||
}
|
||||
|
||||
func patchRequestCookie(c *gin.Context, name, value string) {
|
||||
if c == nil || c.Request == nil || name == "" {
|
||||
return
|
||||
}
|
||||
escaped := url.PathEscape(value)
|
||||
var parts []string
|
||||
for _, ck := range c.Request.Cookies() {
|
||||
if ck == nil || ck.Name == name {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, ck.Name+"="+ck.Value)
|
||||
}
|
||||
parts = append(parts, name+"="+escaped)
|
||||
c.Request.Header.Set("Cookie", strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
func envCode(env *APIEnvelope) int {
|
||||
if env == nil {
|
||||
return 0
|
||||
}
|
||||
return env.Code
|
||||
}
|
||||
|
||||
func firstNonEmptyTip(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package authser
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// TokenExpiredCode 上游约定:token 失效,需重新登录。
|
||||
const TokenExpiredCode = 1000
|
||||
|
||||
// IsSessionExpiredCode 是否为会话失效类业务码。
|
||||
func IsSessionExpiredCode(code int) bool {
|
||||
switch code {
|
||||
case TokenExpiredCode, 1002, 1012, 6001: // InvalidToken / NoToken / InvalidateToken / 用户不存在
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// OnSessionExpired 处理上游会话失效。
|
||||
// retry=true:游客已重新登录,调用方应重试原请求;
|
||||
// needLogin=true:注册账号已清 cookie,调用方应跳转登录弹窗/页。
|
||||
func OnSessionExpired(c *gin.Context, code int) (retry, needLogin bool) {
|
||||
if c == nil || !IsSessionExpiredCode(code) {
|
||||
return false, false
|
||||
}
|
||||
if IsRegisteredFromCookies(c) {
|
||||
ClearAuthCookies(c)
|
||||
dropRequestCookie(c, "token")
|
||||
dropRequestCookie(c, "_token_")
|
||||
return false, true
|
||||
}
|
||||
if RefreshGuestSession(c) != nil {
|
||||
return true, false
|
||||
}
|
||||
ClearAuthCookies(c)
|
||||
dropRequestCookie(c, "token")
|
||||
dropRequestCookie(c, "_token_")
|
||||
return false, false
|
||||
}
|
||||
|
||||
// RecoverContentSession 用于允许游客访问的内容读取接口。
|
||||
// 正式账号或游客 token 失效后,清理旧登录态、重新建立游客会话,调用方随后重试一次原请求。
|
||||
// 写操作仍应使用 OnSessionExpired,避免把用户操作静默降级为游客操作。
|
||||
func RecoverContentSession(c *gin.Context, code int) bool {
|
||||
if c == nil || !IsSessionExpiredCode(code) {
|
||||
return false
|
||||
}
|
||||
ClearAuthCookies(c)
|
||||
for _, name := range authCookieNames() {
|
||||
dropRequestCookie(c, name)
|
||||
}
|
||||
dropRequestCookie(c, guestSkipAutoCookie)
|
||||
ClearGuestSkipAuto(c)
|
||||
return EnsureGuestOnEnter(c) != nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package authser
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// RegisterReq 对应 swagger authapi.registerReq
|
||||
type RegisterReq struct {
|
||||
AccountNo string `json:"accountNo"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Password string `json:"password"`
|
||||
AgentInviteCode string `json:"agentInviteCode,omitempty"`
|
||||
DevID string `json:"devID,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// LoginReq 对应 swagger authapi.loginReq
|
||||
type LoginReq struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
DevID string `json:"devID,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// ResetPasswordReq 对应 swagger authapi.resetPasswordReq。
|
||||
type ResetPasswordReq struct {
|
||||
AccountNo string `json:"accountNo"`
|
||||
Email string `json:"email"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
// GuestLoginReq 对应 swagger authser.GuestLoginReq
|
||||
// POST /api/app/auth/guest
|
||||
type GuestLoginReq struct {
|
||||
DevID string `json:"devID"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Mac string `json:"mac,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProfileReq 对应 swagger authapi.updateProfileReq
|
||||
type UpdateProfileReq struct {
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
Introduction string `json:"introduction,omitempty"`
|
||||
NickName string `json:"nickName,omitempty"`
|
||||
OldPassword string `json:"oldPassword,omitempty"`
|
||||
NewPassword string `json:"newPassword,omitempty"`
|
||||
PrivacyMode *bool `json:"privacyMode,omitempty"`
|
||||
}
|
||||
|
||||
// LoginResp 对应 swagger authser.LoginResp
|
||||
// GET /api/app/auth/getInfo、登录/注册/游客成功 data。
|
||||
type LoginResp struct {
|
||||
Token string `json:"token"`
|
||||
UserID int64 `json:"userId"`
|
||||
AccountNo string `json:"accountNo"`
|
||||
Email string `json:"email"`
|
||||
NickName string `json:"nickName"`
|
||||
Introduction string `json:"introduction"` // 简介
|
||||
Avatar string `json:"avatar"` // 头像
|
||||
InviteCode string `json:"inviteCode"` // 邀请码
|
||||
VipType int `json:"vipType"` // 0没卡 1月卡 2年卡
|
||||
CurrentVipExpire string `json:"currentVipExpire"` // VIP 过期时间,需结合 vipType 判断是否有效
|
||||
ShowCardID int64 `json:"showCardId"` // 最新会员卡 id
|
||||
ShowCardName string `json:"showCardName"` // 最新会员卡名称,如「1个月会员」
|
||||
Status int `json:"status"` // 0正常 1封禁 2禁言
|
||||
Type int `json:"type"` // 身份类型:0游客 1普通用户
|
||||
IsNewUser bool `json:"isNewUser"` // 本次是否新建游客
|
||||
IsRegister bool `json:"isRegister"` // 是否已注册
|
||||
LoginType int `json:"loginType"` // 登录方式
|
||||
PrivacyMode bool `json:"privacyMode"` // 隐私模式
|
||||
Score int64 `json:"score"` // 积分
|
||||
Recharge int64 `json:"recharge"` // 金币余额
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳。Data 在失败时可能是 string/null。
|
||||
type APIEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// ImageUploadData 对应 swagger uploadser.ImageUploadData
|
||||
type ImageUploadData struct {
|
||||
ID string `json:"id"`
|
||||
SourceNameFs string `json:"sourceNameFs"`
|
||||
}
|
||||
|
||||
// ImageUploadResp 对应 swagger uploadser.ImageUploadResp
|
||||
type ImageUploadResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data *ImageUploadData `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SettingPageVM 账号设置页展示模型。
|
||||
type SettingPageVM struct {
|
||||
UserID int64
|
||||
AccountNo string
|
||||
NickName string
|
||||
Introduction string
|
||||
Email string
|
||||
EmailMasked string
|
||||
EmailBound bool
|
||||
Avatar string // 可用头像 URL;空则模板用默认图
|
||||
InviteCode string
|
||||
PrivacyMode bool
|
||||
VipType int
|
||||
VipExpire string
|
||||
Recharge int64
|
||||
Score int64
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// BuildSettingVM 由 LoginResp 构建设置页 VM。
|
||||
func BuildSettingVM(user *LoginResp) SettingPageVM {
|
||||
if user == nil {
|
||||
return SettingPageVM{}
|
||||
}
|
||||
nick := strings.TrimSpace(user.NickName)
|
||||
account := strings.TrimSpace(user.AccountNo)
|
||||
email := strings.TrimSpace(user.Email)
|
||||
intro := strings.TrimSpace(user.Introduction)
|
||||
display := nick
|
||||
if display == "" {
|
||||
display = account
|
||||
}
|
||||
if display == "" {
|
||||
display = "用户"
|
||||
}
|
||||
return SettingPageVM{
|
||||
UserID: user.UserID,
|
||||
AccountNo: account,
|
||||
NickName: nick,
|
||||
Introduction: intro,
|
||||
Email: email,
|
||||
EmailMasked: MaskEmail(email),
|
||||
EmailBound: email != "",
|
||||
Avatar: usableAvatarURL(user.Avatar),
|
||||
InviteCode: strings.TrimSpace(user.InviteCode),
|
||||
PrivacyMode: user.PrivacyMode,
|
||||
VipType: user.VipType,
|
||||
VipExpire: strings.TrimSpace(user.CurrentVipExpire),
|
||||
Recharge: user.Recharge,
|
||||
Score: user.Score,
|
||||
DisplayName: display,
|
||||
}
|
||||
}
|
||||
|
||||
// MaskEmail 邮箱脱敏,如 ab***@mail.com。
|
||||
func MaskEmail(email string) string {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return ""
|
||||
}
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at <= 0 || at == len(email)-1 {
|
||||
return email
|
||||
}
|
||||
local := email[:at]
|
||||
domain := email[at:]
|
||||
runes := []rune(local)
|
||||
if len(runes) <= 2 {
|
||||
return string(runes[:1]) + "***" + domain
|
||||
}
|
||||
return string(runes[:2]) + "***" + domain
|
||||
}
|
||||
|
||||
// FormatUserID 展示用 ID。
|
||||
func FormatUserID(id int64) string {
|
||||
if id <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", id)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package authser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func parseVipExpireMs(raw string) int64 {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
if n > 0 && n < 1e12 {
|
||||
return n * 1000
|
||||
}
|
||||
return n
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// UserIDFromCookies 读取 cookie userId。
|
||||
func UserIDFromCookies(c *gin.Context) int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "userId")), 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// VipTypeFromCookies 读取 cookie vipType。
|
||||
func VipTypeFromCookies(c *gin.Context) int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(cookieValue(c, "vipType")))
|
||||
return n
|
||||
}
|
||||
|
||||
// VipTypeLabel vipType 文案:0普通 1月卡 2年卡(与 91ly / 编辑资料注释一致)。
|
||||
func VipTypeLabel(vipType int) string {
|
||||
switch vipType {
|
||||
case 1:
|
||||
return "月卡会员"
|
||||
case 2:
|
||||
return "年卡会员"
|
||||
default:
|
||||
return "普通会员"
|
||||
}
|
||||
}
|
||||
|
||||
// ShowCardNameFromCookies 读取 cookie showCardName。
|
||||
func ShowCardNameFromCookies(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cookieValue(c, "showCardName"))
|
||||
}
|
||||
|
||||
// ShowCardIDFromCookies 读取 cookie showCardId。
|
||||
func ShowCardIDFromCookies(c *gin.Context) int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "showCardId")), 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// VipDisplayLabel 优先使用 getInfo.showCardName,否则回退 vipType 文案。
|
||||
func VipDisplayLabel(showCardName string, vipType int) string {
|
||||
if s := strings.TrimSpace(showCardName); s != "" {
|
||||
return s
|
||||
}
|
||||
return VipTypeLabel(vipType)
|
||||
}
|
||||
|
||||
// VipDisplayLabelFromCookies 从 cookie 组装会员展示名。
|
||||
func VipDisplayLabelFromCookies(c *gin.Context) string {
|
||||
return VipDisplayLabel(ShowCardNameFromCookies(c), VipTypeFromCookies(c))
|
||||
}
|
||||
|
||||
// IsActiveVipUser 根据 vipType + currentVipExpire 判断是否有效会员。
|
||||
func IsActiveVipUser(vipType int, currentVipExpire string) bool {
|
||||
if vipType == 0 {
|
||||
return false
|
||||
}
|
||||
expireMs := parseVipExpireMs(currentVipExpire)
|
||||
if expireMs <= 0 {
|
||||
return false
|
||||
}
|
||||
return expireMs > time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
// FormatVipExpireText 格式化到期时间展示。
|
||||
func FormatVipExpireText(currentVipExpire string) string {
|
||||
ms := parseVipExpireMs(currentVipExpire)
|
||||
if ms <= 0 {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).In(time.Local).Format("有效期至 2006/01/02")
|
||||
}
|
||||
|
||||
// VipExpireTextFromCookies 格式化到期时间展示。
|
||||
func VipExpireTextFromCookies(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return FormatVipExpireText(cookieValue(c, "currentVipExpire"))
|
||||
}
|
||||
|
||||
// IsActiveVipFromCookies vipType≠0 且 currentVipExpire 未过期(与 91ly 一致)。
|
||||
func IsActiveVipFromCookies(c *gin.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return IsActiveVipUser(VipTypeFromCookies(c), cookieValue(c, "currentVipExpire"))
|
||||
}
|
||||
|
||||
// RechargeFromCookies 读取 cookie recharge(金币余额)。
|
||||
func RechargeFromCookies(c *gin.Context) int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "recharge")), 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// SetRechargeCookie 更新金币余额 cookie(解锁/充值后同步)。
|
||||
func SetRechargeCookie(c *gin.Context, balance int64) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if balance < 0 {
|
||||
balance = 0
|
||||
}
|
||||
secure := c.Request != nil && c.Request.TLS != nil
|
||||
setCookie(c, "recharge", strconv.FormatInt(balance, 10), secure)
|
||||
}
|
||||
|
||||
// FormatRecharge 金币余额千分位展示。
|
||||
func FormatRecharge(n int64) string {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
s := strconv.FormatInt(n, 10)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
rem := len(s) % 3
|
||||
if rem > 0 {
|
||||
b.WriteString(s[:rem])
|
||||
if len(s) > rem {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
}
|
||||
for i := rem; i < len(s); i += 3 {
|
||||
b.WriteString(s[i : i+3])
|
||||
if i+3 < len(s) {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const likeSetPath = "/api/app/comment/like/set"
|
||||
|
||||
// LikeSet 设置评论点赞/取消点赞。
|
||||
func LikeSet(c *gin.Context, req CommentLikeSetReq) (*APIEnvelope[CommentLikeSetResp], error) {
|
||||
if req.CommentID <= 0 {
|
||||
return nil, fmt.Errorf("comment like invalid commentId")
|
||||
}
|
||||
var env APIEnvelope[CommentLikeSetResp]
|
||||
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
listPath = "/api/app/comment/list"
|
||||
defaultCommentSize = 10
|
||||
)
|
||||
|
||||
// ListQuery 评论列表查询参数。
|
||||
type ListQuery struct {
|
||||
ObjectID int64
|
||||
ObjectType int
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// Normalize 校正分页与排序默认值。
|
||||
func (q *ListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultCommentSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.ObjectType != ObjectTypeChapter {
|
||||
q.ObjectType = ObjectTypePlaylet
|
||||
}
|
||||
if q.SortOrder != SortLatest {
|
||||
q.SortOrder = SortHot
|
||||
}
|
||||
}
|
||||
|
||||
// FetchList 分页查询评论。
|
||||
func FetchList(c *gin.Context, q ListQuery) (*GetCommentListResp, error) {
|
||||
q.Normalize()
|
||||
if q.ObjectID <= 0 {
|
||||
return nil, fmt.Errorf("comment list invalid objectId")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("objectId", strconv.FormatInt(q.ObjectID, 10))
|
||||
params.Set("objectType", strconv.Itoa(q.ObjectType))
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
|
||||
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
requestPath := listPath + "?" + params.Encode()
|
||||
env, err := fetchListOnce(c, requestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) && authser.RecoverContentSession(c, env.Code) {
|
||||
env, err = fetchListOnce(c, requestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("comment list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
func fetchListOnce(c *gin.Context, path string) (*APIEnvelope[GetCommentListResp], error) {
|
||||
var env APIEnvelope[GetCommentListResp]
|
||||
if err := upstream.GetJSON(c, path, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchListOrEmpty 失败时返回空列表。
|
||||
func FetchListOrEmpty(c *gin.Context, q ListQuery) *GetCommentListResp {
|
||||
data, err := FetchList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch comment list failed", log.E(err))
|
||||
return &GetCommentListResp{
|
||||
ObjectID: q.ObjectID,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
if data == nil {
|
||||
return &GetCommentListResp{
|
||||
ObjectID: q.ObjectID,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ParseSortOrder 解析站点评论排序(1 热门 / 2 最新)。
|
||||
func ParseSortOrder(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n != SortLatest {
|
||||
return SortHot
|
||||
}
|
||||
return SortLatest
|
||||
}
|
||||
|
||||
// FormatCreatedAt 展示用时间格式化(非字段映射)。
|
||||
func FormatCreatedAt(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t.Local().Format("2006/1/2 15:04")
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// AvatarOrDefault 头像空时回退默认图。
|
||||
func AvatarOrDefault(avatar string) string {
|
||||
avatar = strings.TrimSpace(avatar)
|
||||
if avatar == "" {
|
||||
return "/static/images/avatar.png"
|
||||
}
|
||||
return avatar
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package commentser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const sendPath = "/api/app/comment/send"
|
||||
|
||||
// Send 发布评论(一级 / 二级)。
|
||||
func Send(c *gin.Context, req CommentSendReq) (*APIEnvelope[any], error) {
|
||||
req.Text = strings.TrimSpace(req.Text)
|
||||
if req.ObjectID <= 0 {
|
||||
return nil, fmt.Errorf("comment send invalid objectId")
|
||||
}
|
||||
if req.ObjectType != ObjectTypeChapter {
|
||||
req.ObjectType = ObjectTypePlaylet
|
||||
}
|
||||
if req.Text == "" {
|
||||
return nil, fmt.Errorf("comment send empty text")
|
||||
}
|
||||
if utf8.RuneCountInString(req.Text) > 200 {
|
||||
return nil, fmt.Errorf("comment send text too long")
|
||||
}
|
||||
if req.ParentsID < 0 {
|
||||
req.ParentsID = 0
|
||||
}
|
||||
if req.ReplyCommentID < 0 {
|
||||
req.ReplyCommentID = 0
|
||||
}
|
||||
// 二级评论:未显式指定 replyCommentId 时默认回复父评论
|
||||
if req.ParentsID > 0 && req.ReplyCommentID <= 0 {
|
||||
req.ReplyCommentID = req.ParentsID
|
||||
}
|
||||
|
||||
var env APIEnvelope[any]
|
||||
if _, err := upstream.PostJSON(c, sendPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package commentser
|
||||
|
||||
import "html/template"
|
||||
|
||||
// TemplateFuncs 评论展示辅助。
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatCommentTime": FormatCreatedAt,
|
||||
"commentAvatar": AvatarOrDefault,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package commentser
|
||||
|
||||
// CommentSendReq 对应 swagger proto.CommentSendReq
|
||||
// parentsId 为 0 发一级评论,大于 0 发二级评论;replyCommentId 为实际被回复的评论。
|
||||
type CommentSendReq struct {
|
||||
ObjectID int64 `json:"objectId"`
|
||||
ObjectType int `json:"objectType"`
|
||||
Text string `json:"text"`
|
||||
ParentsID int64 `json:"parentsId"`
|
||||
ReplyCommentID int64 `json:"replyCommentId"`
|
||||
}
|
||||
|
||||
// CommentLikeSetReq 对应 swagger proto.CommentLikeSetReq
|
||||
type CommentLikeSetReq struct {
|
||||
CommentID int64 `json:"commentId"`
|
||||
IsLike bool `json:"isLike"`
|
||||
}
|
||||
|
||||
// CommentLikeSetResp 对应 swagger proto.CommentLikeSetResp
|
||||
type CommentLikeSetResp struct {
|
||||
CommentID int64 `json:"commentId"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsLike int `json:"isLike"`
|
||||
}
|
||||
|
||||
// CommentInfo 对应 swagger proto.CommentInfo
|
||||
type CommentInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
ObjectID int64 `json:"objectId"`
|
||||
ObjectType int `json:"objectType"`
|
||||
Text string `json:"text"`
|
||||
UserID int64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
UserAvatar string `json:"userAvatar"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
CommentCount int64 `json:"commentCount"`
|
||||
IsLike int `json:"isLike"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Level int `json:"level,omitempty"`
|
||||
ParentsID int64 `json:"parentsId,omitempty"`
|
||||
ReplyCommentID int64 `json:"replyCommentId,omitempty"`
|
||||
ReplyUserName string `json:"replyUserName,omitempty"`
|
||||
Children []CommentInfo `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// GetCommentListResp 对应 swagger proto.GetCommentListResp
|
||||
type GetCommentListResp struct {
|
||||
List []CommentInfo `json:"list"`
|
||||
ObjectID int64 `json:"objectId"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
const (
|
||||
// ObjectTypePlaylet 短剧评论
|
||||
ObjectTypePlaylet = 10
|
||||
// ObjectTypeChapter 短剧剧集评论
|
||||
ObjectTypeChapter = 11
|
||||
|
||||
SortHot = 1 // 热门
|
||||
SortLatest = 2 // 最新
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const footerPath = "/api/app/config/footer/info"
|
||||
const footerCacheTTL = 60 * time.Second
|
||||
|
||||
var (
|
||||
footerMu sync.Mutex
|
||||
footerCache *FooterInfo
|
||||
footerAt time.Time
|
||||
)
|
||||
|
||||
// FetchFooter 拉取底部配置,返回与 swagger proto.FooterInfo 一致的结构。
|
||||
func FetchFooter(c *gin.Context) (*FooterInfo, error) {
|
||||
var env APIEnvelope[FooterInfoResp]
|
||||
if err := upstream.GetPublicJSON(c, footerPath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("footer info code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
info := env.Data.FooterInfo
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// FetchFooterOrEmpty 带短缓存;失败或空数据时返回空结构,不做写死兜底/字段映射。
|
||||
func FetchFooterOrEmpty(c *gin.Context) FooterInfo {
|
||||
footerMu.Lock()
|
||||
defer footerMu.Unlock()
|
||||
if footerCache != nil && time.Since(footerAt) < footerCacheTTL {
|
||||
return *footerCache
|
||||
}
|
||||
|
||||
info, err := FetchFooter(c)
|
||||
if err != nil {
|
||||
log.Error("fetch footer info failed", log.E(err))
|
||||
return FooterInfo{}
|
||||
}
|
||||
footerCache = info
|
||||
footerAt = time.Now()
|
||||
return *info
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HomeRoadLinkVM 是“回家的路”页面中的可访问线路。
|
||||
type HomeRoadLinkVM struct {
|
||||
Label string
|
||||
URL string
|
||||
}
|
||||
|
||||
// HomeRoadContactVM 是防失联联系方式。
|
||||
type HomeRoadContactVM struct {
|
||||
Title string
|
||||
Description string
|
||||
Action string
|
||||
URL string
|
||||
}
|
||||
|
||||
// HomeRoadPageVM 将 config/info 中的地址字段整理成页面所需结构。
|
||||
type HomeRoadPageVM struct {
|
||||
MainURL string
|
||||
Routes []HomeRoadLinkVM
|
||||
Permanent []HomeRoadLinkVM
|
||||
Contacts []HomeRoadContactVM
|
||||
}
|
||||
|
||||
func firstHomeRoadURL(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// BuildHomeRoadPageVM 使用服务端动态配置组装“回家的路”页面。
|
||||
func BuildHomeRoadPageVM(cfg AppConfigVM) HomeRoadPageVM {
|
||||
mainURL := firstHomeRoadURL(cfg.NewURLAddr, cfg.ForeverURLAddr)
|
||||
routes := make([]HomeRoadLinkVM, 0, 3)
|
||||
for _, item := range cfg.NavPageList {
|
||||
if len(routes) == 3 {
|
||||
break
|
||||
}
|
||||
if url := strings.TrimSpace(item.URL); url != "" {
|
||||
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: url})
|
||||
}
|
||||
}
|
||||
for _, url := range []string{cfg.NewURLAddr, cfg.NewURLAddr1, cfg.NewURLAddr2} {
|
||||
if len(routes) == 3 {
|
||||
break
|
||||
}
|
||||
if url = strings.TrimSpace(url); url != "" {
|
||||
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: url})
|
||||
}
|
||||
}
|
||||
for len(routes) < 3 {
|
||||
routes = append(routes, HomeRoadLinkVM{Label: fmt.Sprintf("备用地址 %02d", len(routes)+1), URL: mainURL})
|
||||
}
|
||||
|
||||
permanent := make([]HomeRoadLinkVM, 0, 3)
|
||||
for _, item := range []HomeRoadLinkVM{
|
||||
{Label: "永久地址", URL: cfg.ForeverURLAddr},
|
||||
{Label: "永久中转页", URL: cfg.NewURLAddr},
|
||||
{Label: "最新中转页(免翻墙)", URL: cfg.NewURLAddr1},
|
||||
} {
|
||||
item.URL = strings.TrimSpace(item.URL)
|
||||
if item.URL != "" {
|
||||
permanent = append(permanent, item)
|
||||
}
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(cfg.GmailAddr)
|
||||
emailURL := ""
|
||||
if email != "" {
|
||||
emailURL = "mailto:" + email
|
||||
}
|
||||
contacts := []HomeRoadContactVM{
|
||||
{Title: "✉ 邮箱自动回复", Description: "发送任意邮件到下方地址,30秒内自动回复当前所有可用入口的最新汇总。", Action: firstHomeRoadURL(email, "huangguoaiai@gmail.com") + " →", URL: firstHomeRoadURL(emailURL, "mailto:huangguoaiai@gmail.com")},
|
||||
{Title: "➤ TG 官方交流群", Description: "获取域名变更通知的首选渠道,实时播报并加入用户互助社区。", Action: "立即订阅最新动态 →", URL: firstHomeRoadURL(cfg.TGGroup, "/")},
|
||||
{Title: "◆ 官方商务合作", Description: "联系官方商务合作,洽谈商务事宜,精诚合作,财富共赢。", Action: "立即联系 →", URL: firstHomeRoadURL(cfg.Telegram, "/")},
|
||||
{Title: "𝕏 推特社区", Description: "关注官方推特社区,第一时间获取地址变更与官方公告。", Action: "快速访问 →", URL: firstHomeRoadURL(cfg.TwitterAddr, "/")},
|
||||
}
|
||||
|
||||
return HomeRoadPageVM{MainURL: mainURL, Routes: routes, Permanent: permanent, Contacts: contacts}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package configser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildHomeRoadPageVMUsesDynamicConfig(t *testing.T) {
|
||||
cfg := AppConfigVM{
|
||||
NewURLAddr: "https://main.example.com",
|
||||
NewURLAddr1: "https://relay.example.com",
|
||||
NewURLAddr2: "https://latest.example.com",
|
||||
ForeverURLAddr: "https://forever.example.com",
|
||||
PDFAddr: "https://publish.example.com",
|
||||
GmailAddr: "home@example.com",
|
||||
TGGroup: "https://t.me/example",
|
||||
Telegram: "https://t.me/business-example",
|
||||
CustomerServiceHost: "https://service.example.com",
|
||||
TwitterAddr: "https://x.com/example",
|
||||
NavPageList: []NavPageItem{
|
||||
{Line: 1, URL: "https://line1.example.com"},
|
||||
{Line: 2, URL: "https://line2.example.com"},
|
||||
{Line: 3, URL: "https://line3.example.com"},
|
||||
},
|
||||
}
|
||||
vm := BuildHomeRoadPageVM(cfg)
|
||||
if vm.MainURL != cfg.NewURLAddr || len(vm.Routes) != 3 || vm.Routes[1].URL != "https://line2.example.com" {
|
||||
t.Fatalf("unexpected route VM: %#v", vm)
|
||||
}
|
||||
if len(vm.Permanent) != 3 || vm.Permanent[1].URL != cfg.NewURLAddr || vm.Permanent[2].URL != cfg.NewURLAddr1 {
|
||||
t.Fatalf("unexpected permanent links: %#v", vm.Permanent)
|
||||
}
|
||||
if len(vm.Contacts) != 4 || vm.Contacts[0].URL != "mailto:home@example.com" || vm.Contacts[2].URL != cfg.Telegram {
|
||||
t.Fatalf("unexpected contacts: %#v", vm.Contacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHomeRoadPageVMSkipsEmptyPermanentAddresses(t *testing.T) {
|
||||
vm := BuildHomeRoadPageVM(AppConfigVM{NewURLAddr: "https://relay.example.com"})
|
||||
if len(vm.Permanent) != 1 || vm.Permanent[0].Label != "永久中转页" {
|
||||
t.Fatalf("unexpected permanent links: %#v", vm.Permanent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
configInfoPath = "/api/app/config/info"
|
||||
configInfoCacheTTL = 60 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
configMu sync.Mutex
|
||||
configCache *AppConfigVM
|
||||
configAt time.Time
|
||||
)
|
||||
|
||||
// FetchConfigInfo 拉取 APP 公共配置。
|
||||
func FetchConfigInfo(c *gin.Context) (*ConfigInfoResp, error) {
|
||||
var env APIEnvelope[ConfigInfoResp]
|
||||
if err := upstream.GetPublicJSON(c, configInfoPath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("config info code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchAppConfigVMOrEmpty 带短缓存,失败时返回空配置。
|
||||
func FetchAppConfigVMOrEmpty(c *gin.Context) AppConfigVM {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
if configCache != nil && time.Since(configAt) < configInfoCacheTTL {
|
||||
return *configCache
|
||||
}
|
||||
|
||||
info, err := FetchConfigInfo(c)
|
||||
if err != nil {
|
||||
log.Error("fetch app config info failed", log.E(err))
|
||||
return AppConfigVM{}
|
||||
}
|
||||
vm := BuildAppConfigVM(info)
|
||||
configCache = &vm
|
||||
configAt = time.Now()
|
||||
return vm
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const richtextListPath = "/api/app/config/richtext/list"
|
||||
|
||||
// RichTextPageVM 富文本页模板数据。
|
||||
type RichTextPageVM struct {
|
||||
Title string
|
||||
Subtitle string
|
||||
ContentHTML template.HTML
|
||||
Found bool
|
||||
}
|
||||
|
||||
// FetchRichTextList 查询富文本列表(ids / titles 逗号分隔)。
|
||||
func FetchRichTextList(c *gin.Context, ids, titles string) ([]AppRichTextItemResp, error) {
|
||||
params := url.Values{}
|
||||
if s := strings.TrimSpace(ids); s != "" {
|
||||
params.Set("ids", s)
|
||||
}
|
||||
if s := strings.TrimSpace(titles); s != "" {
|
||||
params.Set("titles", s)
|
||||
}
|
||||
path := richtextListPath
|
||||
if q := params.Encode(); q != "" {
|
||||
path += "?" + q
|
||||
}
|
||||
|
||||
var env APIEnvelope[AppRichTextListResp]
|
||||
if err := upstream.GetPublicJSON(c, path, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("richtext list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return env.Data.List, nil
|
||||
}
|
||||
|
||||
// FetchRichTextByTitle 按标题取第一条启用内容;失败返回空页 VM。
|
||||
func FetchRichTextByTitle(c *gin.Context, title string) RichTextPageVM {
|
||||
title = strings.TrimSpace(title)
|
||||
vm := RichTextPageVM{Title: title}
|
||||
if title == "" {
|
||||
return vm
|
||||
}
|
||||
|
||||
list, err := FetchRichTextList(c, "", title)
|
||||
if err != nil {
|
||||
log.Error("fetch richtext by title failed", log.E(err), log.Any("title", title))
|
||||
return vm
|
||||
}
|
||||
|
||||
item := matchRichTextByTitle(list, title)
|
||||
if item == nil {
|
||||
return vm
|
||||
}
|
||||
|
||||
displayTitle := strings.TrimSpace(item.Desc)
|
||||
if displayTitle == "" {
|
||||
displayTitle = strings.TrimSpace(item.Title)
|
||||
}
|
||||
if displayTitle == "" {
|
||||
displayTitle = title
|
||||
}
|
||||
|
||||
vm.Found = true
|
||||
vm.Title = displayTitle
|
||||
vm.Subtitle = strings.TrimSpace(item.Desc)
|
||||
if vm.Subtitle == displayTitle {
|
||||
vm.Subtitle = ""
|
||||
}
|
||||
vm.ContentHTML = template.HTML(item.Content)
|
||||
return vm
|
||||
}
|
||||
|
||||
func matchRichTextByTitle(list []AppRichTextItemResp, title string) *AppRichTextItemResp {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range list {
|
||||
if strings.TrimSpace(list[i].Title) == title || strings.TrimSpace(list[i].Desc) == title {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
return &list[0]
|
||||
}
|
||||
|
||||
// RichTextHref 页脚导航跳转一律进入富文本页。
|
||||
func RichTextHref(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return "/richtext/"
|
||||
}
|
||||
return "/richtext/" + title + "/"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package configser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRichTextHrefUsesRichtextRoutes(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"关于我们": "/richtext/关于我们/",
|
||||
"用户协议": "/richtext/用户协议/",
|
||||
"隐私政策": "/richtext/隐私政策/",
|
||||
"帮助中心": "/richtext/帮助中心/",
|
||||
"问题反馈": "/richtext/问题反馈/",
|
||||
"自定义内容": "/richtext/自定义内容/",
|
||||
}
|
||||
for title, want := range tests {
|
||||
if got := RichTextHref(title); got != want {
|
||||
t.Fatalf("RichTextHref(%q) = %q, want %q", title, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnmarshalJSON 兼容 seoNavInfo 为空字符串,以及 dhKeyworld 为字符串/数组。
|
||||
func (s *SeoNavInfo) UnmarshalJSON(raw []byte) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '"' {
|
||||
var encoded string
|
||||
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded = strings.TrimSpace(encoded)
|
||||
if encoded == "" {
|
||||
return nil
|
||||
}
|
||||
inner := bytes.TrimSpace([]byte(encoded))
|
||||
if len(inner) == 0 || inner[0] != '{' {
|
||||
return nil
|
||||
}
|
||||
return s.UnmarshalJSON(inner)
|
||||
}
|
||||
var aux struct {
|
||||
DhTitle string `json:"dhTitle"`
|
||||
DhDescription string `json:"dhDescription"`
|
||||
DhSiteName string `json:"dhSiteName"`
|
||||
DhKeyworld json.RawMessage `json:"dhKeyworld"`
|
||||
Keywords json.RawMessage `json:"keywords"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
s.DhTitle = strings.TrimSpace(aux.DhTitle)
|
||||
s.DhDescription = strings.TrimSpace(aux.DhDescription)
|
||||
s.DhSiteName = strings.TrimSpace(aux.DhSiteName)
|
||||
s.DhKeyworld = decodeNavKeywords(aux.DhKeyworld, aux.Keywords)
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeNavKeywords(values ...json.RawMessage) []string {
|
||||
for _, raw := range values {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
continue
|
||||
}
|
||||
if raw[0] == '[' {
|
||||
var list []string
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
continue
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
continue
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
continue
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/pub/pubdef"
|
||||
)
|
||||
|
||||
// ResolveTrackerAppID 优先 yaml,否则固定 JHA-1043。
|
||||
func ResolveTrackerAppID() string {
|
||||
if id := strings.TrimSpace(appg.Conf.Tracker.AppID); id != "" {
|
||||
return id
|
||||
}
|
||||
return pubdef.TrackerAppId()
|
||||
}
|
||||
|
||||
// ResolveReportSDKConfig 上游 reportSdkConfig 优先,本地 yaml 兜底。
|
||||
func ResolveReportSDKConfig(fromAPI string) string {
|
||||
if v := strings.TrimSpace(fromAPI); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(appg.Conf.Tracker.EncryptedConfig)
|
||||
}
|
||||
|
||||
// ApplyTrackerFallback 补齐 AppConfigVM 中的上报密文。
|
||||
func ApplyTrackerFallback(vm AppConfigVM) AppConfigVM {
|
||||
vm.ReportSDKConfig = ResolveReportSDKConfig(vm.ReportSDKConfig)
|
||||
return vm
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package configser
|
||||
|
||||
// FooterInfoResp 对应 swagger proto.FooterInfoResp
|
||||
type FooterInfoResp struct {
|
||||
FooterInfo FooterInfo `json:"footerInfo"`
|
||||
}
|
||||
|
||||
// FooterInfo 对应 swagger proto.FooterInfo
|
||||
// GET /api/app/config/footer/info — 模板直接使用本结构,不做自定义 VM 映射。
|
||||
type FooterInfo struct {
|
||||
Brand FooterBrand `json:"brand"`
|
||||
NavList []TranslationCategory `json:"navList"`
|
||||
ContactList []FooterContact `json:"contactList"`
|
||||
}
|
||||
|
||||
// FooterBrand 对应 swagger proto.FooterBrand
|
||||
type FooterBrand struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// TranslationCategory 对应 swagger proto.TranslationCategory(footer.navList 元素)
|
||||
type TranslationCategory struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
URLValue string `json:"urlValue"`
|
||||
CategoryType int `json:"categoryType"`
|
||||
TranslationList []TranslationInfo `json:"translationList"`
|
||||
SeoInfo SeoValue `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// SeoValue 对应 swagger pubdef.SeoValue(导航分类 seoInfo)
|
||||
type SeoValue struct {
|
||||
Title string `json:"title"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// TranslationInfo 对应 swagger proto.TranslationInfo
|
||||
type TranslationInfo struct {
|
||||
Name string `json:"name"`
|
||||
URLValue string `json:"urlValue"`
|
||||
IsFriendAds bool `json:"isFriendAds"`
|
||||
}
|
||||
|
||||
// FooterContact 对应 swagger proto.FooterContact
|
||||
type FooterContact struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// ConfigInfoResp 对应 swagger proto.ConfigInfoResp
|
||||
// GET /api/app/config/info
|
||||
// reportSdkConfig 与 proto.ConfigInfo 对齐;上游若尚未下发则为空,可由本地 yaml 兜底。
|
||||
// config 为上游实际下发的完整站点配置(swagger 未完整声明,但含 potaoGroup 等字段)。
|
||||
type ConfigInfoResp struct {
|
||||
NewAddressInfo NewAddressInfo `json:"newAddressInfo"`
|
||||
Config ConfigInfo `json:"config"`
|
||||
Advitise AdvitiseData `json:"advitise"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
SeoNavInfo SeoNavInfo `json:"seoNavInfo"`
|
||||
NavPageList []NavPageItem `json:"navPageList"`
|
||||
ReportSDKConfig string `json:"reportSdkConfig"`
|
||||
}
|
||||
|
||||
// ConfigInfo 对应上游 data.config(proto.ConfigInfo 子集,按页面需要取用)。
|
||||
type ConfigInfo struct {
|
||||
PotaoGroup string `json:"potaoGroup"`
|
||||
}
|
||||
|
||||
// AdvitiseData 对应 GET /api/app/config/info 返回的完整 advitise 数据。
|
||||
type AdvitiseData struct {
|
||||
HomeList []Advertisement `json:"homeList"`
|
||||
DetailTopList []Advertisement `json:"detailTopList"`
|
||||
NineGridList []Advertisement `json:"nineGridList"`
|
||||
DetailBannerList []Advertisement `json:"detailbannerList"`
|
||||
LaunchScreenList []Advertisement `json:"launchScreenList"`
|
||||
FloatingList []Advertisement `json:"floatingList"`
|
||||
AList []Advertisement `json:"AList"`
|
||||
BList []Advertisement `json:"BList"`
|
||||
DialogList []Advertisement `json:"dialogList"`
|
||||
VideoInfoList []Advertisement `json:"videoInfoList"`
|
||||
VideoList []Advertisement `json:"videoList"`
|
||||
DownList []Advertisement `json:"downList"`
|
||||
FriendList []Advertisement `json:"friendList"`
|
||||
HomeBanner []Advertisement `json:"homeBanner"`
|
||||
BottomBanner []Advertisement `json:"bottomBanner"`
|
||||
TitleList []Advertisement `json:"titleList"`
|
||||
PlayList []Advertisement `json:"playList"`
|
||||
PauseyList []Advertisement `json:"pauseyList"`
|
||||
}
|
||||
|
||||
// Advertisement 对应 swagger proxy.AdvitiseInfo。
|
||||
type Advertisement struct {
|
||||
ID string `json:"id"`
|
||||
Images string `json:"images"`
|
||||
Link string `json:"link"`
|
||||
Location int `json:"location"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// NewAddressInfo 对应 swagger proto.NewAddressInfo(GET /api/app/config/info)。
|
||||
type NewAddressInfo struct {
|
||||
NewURLAddr string `json:"newUrlAddr"`
|
||||
NewURLAddr1 string `json:"newUrlAddr1"`
|
||||
NewURLAddr2 string `json:"newUrlAddr2"`
|
||||
ForeverURLAddr string `json:"foreverUrlAddr"`
|
||||
GmailAddr string `json:"gmailAddr"`
|
||||
QQ string `json:"qq"`
|
||||
TGGroup string `json:"tgGroup"`
|
||||
Telegram string `json:"telegram"`
|
||||
PotaoGroup string `json:"potaoGroup"`
|
||||
CustomerServiceHost string `json:"customerServiceHost"`
|
||||
AppAddr string `json:"appAddr"`
|
||||
OfficialAppAddr string `json:"officialAppAddr"`
|
||||
PDFAddr string `json:"pdfAddr"`
|
||||
TwitterAddr string `json:"twitterAddr"`
|
||||
}
|
||||
|
||||
// SeoInfo 对应 swagger proto.SeoInfo
|
||||
type SeoInfo struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Keywords []string `json:"keywords"`
|
||||
CanonicalURL string `json:"canonicalUrl"`
|
||||
WebSiteName string `json:"webSiteName"`
|
||||
}
|
||||
|
||||
// SeoNavInfo 对应 swagger proto.SeoNavInfo
|
||||
// GET /api/app/config/info 的 seoNavInfo。
|
||||
type SeoNavInfo struct {
|
||||
DhTitle string `json:"dhTitle"`
|
||||
DhDescription string `json:"dhDescription"`
|
||||
DhKeyworld []string `json:"dhKeyworld"`
|
||||
DhSiteName string `json:"dhSiteName"`
|
||||
}
|
||||
|
||||
// NavPageItem 对应 swagger proto.NavPageItem(GET /api/app/config/info 的 navPageList)。
|
||||
type NavPageItem struct {
|
||||
Line int `json:"line"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// AppRichTextItemResp 对应 swagger proto.AppRichTextItemResp
|
||||
// GET /api/app/config/richtext/list
|
||||
type AppRichTextItemResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Desc string `json:"desc"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// AppRichTextListResp 对应 swagger proto.AppRichTextListResp
|
||||
type AppRichTextListResp struct {
|
||||
List []AppRichTextItemResp `json:"list"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package configser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AppConfigVM 注入全站页面的公共配置(字段名与接口 newAddressInfo / seoInfo 对齐)。
|
||||
type AppConfigVM struct {
|
||||
ForeverURLAddr string `json:"foreverUrlAddr"`
|
||||
NewURLAddr string `json:"newUrlAddr"`
|
||||
NewURLAddr1 string `json:"newUrlAddr1"`
|
||||
NewURLAddr2 string `json:"newUrlAddr2"`
|
||||
GmailAddr string `json:"gmailAddr"`
|
||||
QQ string `json:"qq"`
|
||||
TGGroup string `json:"tgGroup"`
|
||||
Telegram string `json:"telegram"`
|
||||
PotaoGroup string `json:"potaoGroup"`
|
||||
CustomerServiceHost string `json:"customerServiceHost"`
|
||||
AppAddr string `json:"appAddr"`
|
||||
OfficialAppAddr string `json:"officialAppAddr"`
|
||||
PDFAddr string `json:"pdfAddr"`
|
||||
TwitterAddr string `json:"twitterAddr"`
|
||||
SeoInfo SeoInfo `json:"seoInfo,omitempty"`
|
||||
SeoNavInfo SeoNavInfo `json:"seoNavInfo,omitempty"`
|
||||
NavPageList []NavPageItem `json:"navPageList,omitempty"`
|
||||
ReportSDKConfig string `json:"reportSdkConfig,omitempty"`
|
||||
Advitise AdvitiseData `json:"advitise"`
|
||||
DetailTopList []Advertisement `json:"detailTopList,omitempty"`
|
||||
NineGridList []Advertisement `json:"nineGridList,omitempty"`
|
||||
DetailBannerList []Advertisement `json:"detailbannerList,omitempty"`
|
||||
BottomBanner []Advertisement `json:"bottomBanner,omitempty"`
|
||||
DialogList []Advertisement `json:"dialogList,omitempty"`
|
||||
}
|
||||
|
||||
// BuildAppConfigVM 从上游配置组装前端可用字段。
|
||||
func BuildAppConfigVM(info *ConfigInfoResp) AppConfigVM {
|
||||
if info == nil {
|
||||
return AppConfigVM{}
|
||||
}
|
||||
addr := info.NewAddressInfo
|
||||
return AppConfigVM{
|
||||
ForeverURLAddr: sanitizeURL(addr.ForeverURLAddr),
|
||||
NewURLAddr: sanitizeURL(addr.NewURLAddr),
|
||||
NewURLAddr1: sanitizeURL(addr.NewURLAddr1),
|
||||
NewURLAddr2: sanitizeURL(addr.NewURLAddr2),
|
||||
GmailAddr: strings.TrimSpace(addr.GmailAddr),
|
||||
QQ: strings.TrimSpace(addr.QQ),
|
||||
TGGroup: sanitizeURL(addr.TGGroup),
|
||||
Telegram: sanitizeURL(addr.Telegram),
|
||||
PotaoGroup: sanitizeURL(firstNonEmptyURL(info.Config.PotaoGroup, addr.PotaoGroup)),
|
||||
CustomerServiceHost: sanitizeURL(addr.CustomerServiceHost),
|
||||
AppAddr: sanitizeURL(addr.AppAddr),
|
||||
OfficialAppAddr: sanitizeURL(addr.OfficialAppAddr),
|
||||
PDFAddr: sanitizeURL(addr.PDFAddr),
|
||||
TwitterAddr: sanitizeURL(addr.TwitterAddr),
|
||||
ReportSDKConfig: strings.TrimSpace(info.ReportSDKConfig),
|
||||
NavPageList: buildNavPageList(info.NavPageList),
|
||||
Advitise: info.Advitise,
|
||||
DetailTopList: buildAdvertisementList(info.Advitise.DetailTopList),
|
||||
NineGridList: buildAdvertisementList(info.Advitise.NineGridList),
|
||||
DetailBannerList: buildAdvertisementList(info.Advitise.DetailBannerList),
|
||||
BottomBanner: buildAdvertisementList(info.Advitise.BottomBanner),
|
||||
DialogList: buildAdvertisementList(info.Advitise.DialogList),
|
||||
SeoInfo: SeoInfo{
|
||||
Title: strings.TrimSpace(info.SeoInfo.Title),
|
||||
Description: strings.TrimSpace(info.SeoInfo.Description),
|
||||
Keywords: info.SeoInfo.Keywords,
|
||||
CanonicalURL: strings.TrimSpace(info.SeoInfo.CanonicalURL),
|
||||
WebSiteName: strings.TrimSpace(info.SeoInfo.WebSiteName),
|
||||
},
|
||||
SeoNavInfo: SeoNavInfo{
|
||||
DhTitle: strings.TrimSpace(info.SeoNavInfo.DhTitle),
|
||||
DhDescription: strings.TrimSpace(info.SeoNavInfo.DhDescription),
|
||||
DhKeyworld: info.SeoNavInfo.DhKeyworld,
|
||||
DhSiteName: strings.TrimSpace(info.SeoNavInfo.DhSiteName),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildAdvertisementList(list []Advertisement) []Advertisement {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Advertisement, 0, len(list))
|
||||
for _, item := range list {
|
||||
item.ID = strings.TrimSpace(item.ID)
|
||||
item.Images = sanitizeURL(item.Images)
|
||||
item.Link = sanitizeURL(item.Link)
|
||||
item.Title = strings.TrimSpace(item.Title)
|
||||
if item.Images == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NineGridRow 按首页设计每行返回两个 banner,超出当前行时返回空切片。
|
||||
func (c AppConfigVM) NineGridRow(row int) []Advertisement {
|
||||
if row < 0 {
|
||||
return nil
|
||||
}
|
||||
start := row * 2
|
||||
if start >= len(c.NineGridList) {
|
||||
return nil
|
||||
}
|
||||
end := start + 2
|
||||
if end > len(c.NineGridList) {
|
||||
end = len(c.NineGridList)
|
||||
}
|
||||
return c.NineGridList[start:end]
|
||||
}
|
||||
|
||||
// QRText 下载弹窗二维码、跳转及复制链接内容:仅使用应用下载地址 appAddr。
|
||||
func (c AppConfigVM) QRText() string {
|
||||
return strings.TrimSpace(c.AppAddr)
|
||||
}
|
||||
|
||||
func buildNavPageList(list []NavPageItem) []NavPageItem {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]NavPageItem, 0, len(list))
|
||||
for _, item := range list {
|
||||
u := sanitizeURL(item.URL)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, NavPageItem{Line: item.Line, URL: u})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (n NavPageItem) Label() string {
|
||||
digits := []string{"", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"}
|
||||
if n.Line >= 1 && n.Line <= 10 {
|
||||
return "线路" + digits[n.Line]
|
||||
}
|
||||
if n.Line > 0 {
|
||||
return "线路" + strconv.Itoa(n.Line)
|
||||
}
|
||||
return "线路"
|
||||
}
|
||||
|
||||
func sanitizeURL(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimRight(v, "\\")
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func firstNonEmptyURL(values ...string) string {
|
||||
for _, v := range values {
|
||||
if sanitizeURL(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package configser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAppConfigVMQRTextUsesAppAddr(t *testing.T) {
|
||||
cfg := AppConfigVM{
|
||||
AppAddr: " https://download.example.com/app ",
|
||||
ForeverURLAddr: "https://www.example.com/",
|
||||
}
|
||||
|
||||
if got, want := cfg.QRText(), "https://download.example.com/app"; got != want {
|
||||
t.Fatalf("QRText() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAppConfigVMIncludesValidAdvertisements(t *testing.T) {
|
||||
vm := BuildAppConfigVM(&ConfigInfoResp{
|
||||
NewAddressInfo: NewAddressInfo{Telegram: " https://t.me/business ", QQ: " 2168045324 ", OfficialAppAddr: " https://app.example.com/download\\ "},
|
||||
Config: ConfigInfo{PotaoGroup: " https://potato.example.com/group\\ "},
|
||||
Advitise: AdvitiseData{
|
||||
HomeList: []Advertisement{{ID: "home", Images: "https://cdn.example.com/home.gif"}},
|
||||
DetailTopList: []Advertisement{{ID: "top", Images: " https://cdn.example.com/top.gif "}},
|
||||
NineGridList: []Advertisement{
|
||||
{ID: "banner-1", Images: "https://cdn.example.com/banner-1.gif"},
|
||||
{ID: "banner-empty", Images: " "},
|
||||
{ID: "banner-2", Images: "https://cdn.example.com/banner-2.gif"},
|
||||
{ID: "banner-3", Images: "https://cdn.example.com/banner-3.gif"},
|
||||
},
|
||||
DetailBannerList: []Advertisement{{ID: "detail", Images: "https://cdn.example.com/detail.gif"}},
|
||||
BottomBanner: []Advertisement{
|
||||
{ID: " bottom ", Images: " https://cdn.example.com/bottom.gif ", Link: " https://example.com/bottom "},
|
||||
{ID: "bottom-empty", Images: " "},
|
||||
},
|
||||
DialogList: []Advertisement{
|
||||
{ID: " 1 ", Images: " https://cdn.example.com/ad.webp\\ ", Link: " https://example.com ", Title: " 广告一 "},
|
||||
{ID: "2", Images: " ", Title: "无图广告"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if vm.Telegram != "https://t.me/business" {
|
||||
t.Fatalf("Telegram = %q, want sanitized business Telegram URL", vm.Telegram)
|
||||
}
|
||||
if vm.PotaoGroup != "https://potato.example.com/group" {
|
||||
t.Fatalf("PotaoGroup = %q, want sanitized config.potaoGroup", vm.PotaoGroup)
|
||||
}
|
||||
if vm.QQ != "2168045324" {
|
||||
t.Fatalf("QQ = %q, want trimmed QQ group", vm.QQ)
|
||||
}
|
||||
if vm.OfficialAppAddr != "https://app.example.com/download" {
|
||||
t.Fatalf("OfficialAppAddr = %q, want sanitized official app URL", vm.OfficialAppAddr)
|
||||
}
|
||||
|
||||
if len(vm.Advitise.HomeList) != 1 || vm.Advitise.HomeList[0].ID != "home" {
|
||||
t.Fatalf("Advitise.HomeList = %#v", vm.Advitise.HomeList)
|
||||
}
|
||||
if len(vm.Advitise.NineGridList) != 4 {
|
||||
t.Fatalf("Advitise.NineGridList length = %d, want raw length 4", len(vm.Advitise.NineGridList))
|
||||
}
|
||||
if len(vm.DetailTopList) != 1 || vm.DetailTopList[0].ID != "top" {
|
||||
t.Fatalf("DetailTopList = %#v", vm.DetailTopList)
|
||||
}
|
||||
if len(vm.NineGridList) != 3 {
|
||||
t.Fatalf("NineGridList length = %d, want 3", len(vm.NineGridList))
|
||||
}
|
||||
if len(vm.DetailBannerList) != 1 || vm.DetailBannerList[0].ID != "detail" {
|
||||
t.Fatalf("DetailBannerList = %#v", vm.DetailBannerList)
|
||||
}
|
||||
if len(vm.BottomBanner) != 1 || vm.BottomBanner[0].ID != "bottom" || vm.BottomBanner[0].Images != "https://cdn.example.com/bottom.gif" {
|
||||
t.Fatalf("BottomBanner = %#v", vm.BottomBanner)
|
||||
}
|
||||
if len(vm.DialogList) != 1 {
|
||||
t.Fatalf("DialogList length = %d, want 1", len(vm.DialogList))
|
||||
}
|
||||
got := vm.DialogList[0]
|
||||
if got.ID != "1" || got.Images != "https://cdn.example.com/ad.webp" || got.Link != "https://example.com" || got.Title != "广告一" {
|
||||
t.Fatalf("DialogList[0] = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNineGridRowReturnsTwoItemsPerRow(t *testing.T) {
|
||||
cfg := AppConfigVM{NineGridList: []Advertisement{{ID: "1"}, {ID: "2"}, {ID: "3"}}}
|
||||
if got := cfg.NineGridRow(0); len(got) != 2 || got[0].ID != "1" || got[1].ID != "2" {
|
||||
t.Fatalf("NineGridRow(0) = %#v", got)
|
||||
}
|
||||
if got := cfg.NineGridRow(1); len(got) != 1 || got[0].ID != "3" {
|
||||
t.Fatalf("NineGridRow(1) = %#v", got)
|
||||
}
|
||||
if got := cfg.NineGridRow(2); len(got) != 0 {
|
||||
t.Fatalf("NineGridRow(2) = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package feedbackser
|
||||
|
||||
import (
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const addPath = "/api/app/feedback/add"
|
||||
|
||||
const contactUsAddPath = "/api/app/contactus/add"
|
||||
|
||||
// Add 调用上游提交意见反馈。
|
||||
func Add(c *gin.Context, req FeedbackReq) (*authser.APIEnvelope, error) {
|
||||
var env authser.APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, addPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// AddContactUs 调用上游联系我们接口。
|
||||
func AddContactUs(c *gin.Context, req ContactUsAddReq) (*authser.APIEnvelope, error) {
|
||||
var env authser.APIEnvelope
|
||||
if _, err := upstream.PostJSON(c, contactUsAddPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package feedbackser
|
||||
|
||||
// FeedbackReq 对应 swagger proto.FeedbackReq
|
||||
// type: 1-功能建议、2-播放问题、3-账号问题、4-其他(与接口 description 一致)
|
||||
type FeedbackReq struct {
|
||||
Type int `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Contact string `json:"contact,omitempty"`
|
||||
Screenshots []string `json:"screenshots,omitempty"`
|
||||
}
|
||||
|
||||
// ContactUsAddReq 对应 swagger proto.ContactUsAddReq。
|
||||
type ContactUsAddReq struct {
|
||||
Type int `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Email string `json:"email"`
|
||||
Attachments []string `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
type ContactUsAddResp struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
categoryDetailPath = "/api/app/playlet/recommend/list"
|
||||
defaultCategoryPageSize = 25
|
||||
)
|
||||
|
||||
// CategoryDetailQuery 分类详情分页参数。categorySlug 非空时优先使用 slug。
|
||||
type CategoryDetailQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
CategoryID int64
|
||||
CategorySlug string
|
||||
}
|
||||
|
||||
func (q *CategoryDetailQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultCategoryPageSize
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
if q.CategoryID < 0 {
|
||||
q.CategoryID = 0
|
||||
}
|
||||
q.CategorySlug = strings.TrimSpace(q.CategorySlug)
|
||||
}
|
||||
|
||||
func categoryDetailListURL(q CategoryDetailQuery) string {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
if q.CategorySlug != "" {
|
||||
params.Set("categorySlug", q.CategorySlug)
|
||||
} else if q.CategoryID > 0 {
|
||||
params.Set("categoryId", strconv.FormatInt(q.CategoryID, 10))
|
||||
}
|
||||
return categoryDetailPath + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// FetchCategoryDetail 根据分类 slug 或 ID 获取分类详情列表。
|
||||
func FetchCategoryDetail(c *gin.Context, q CategoryDetailQuery) (*PlayletRecommendListResp, error) {
|
||||
var env APIEnvelope[PlayletRecommendListResp]
|
||||
if err := upstream.GetPublicJSON(c, categoryDetailListURL(q), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet category detail code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchCategoryDetailOrEmpty 保持页面可渲染,接口失败时返回当前分页的空结果。
|
||||
func FetchCategoryDetailOrEmpty(c *gin.Context, q CategoryDetailQuery) *PlayletRecommendListResp {
|
||||
q.Normalize()
|
||||
data, err := FetchCategoryDetail(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet category detail failed", log.E(err))
|
||||
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletRecommendListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCategoryDetailListURLUsesSlugBeforeID(t *testing.T) {
|
||||
got := categoryDetailListURL(CategoryDetailQuery{
|
||||
PageNum: 2,
|
||||
CategoryID: 18,
|
||||
CategorySlug: "latest-video",
|
||||
})
|
||||
for _, want := range []string{"/api/app/playlet/recommend/list?", "categorySlug=latest-video", "pageNum=2", "pageSize=25"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("categoryDetailListURL() = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "categoryId=") {
|
||||
t.Fatalf("categoryDetailListURL() = %q, categoryId must be omitted when slug exists", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCategoryDetailQueryAndCanonicalPath(t *testing.T) {
|
||||
slugQuery := ParseCategoryDetailQuery("latest-video", "3")
|
||||
if slugQuery.CategorySlug != "latest-video" || slugQuery.PageNum != 3 || slugQuery.PageSize != 25 {
|
||||
t.Fatalf("ParseCategoryDetailQuery(slug) = %#v", slugQuery)
|
||||
}
|
||||
if got := CategoryDetailCanonicalPath(slugQuery); got != "/category/latest-video/page/3/" {
|
||||
t.Fatalf("CategoryDetailCanonicalPath(slug) = %q", got)
|
||||
}
|
||||
|
||||
idQuery := ParseCategoryDetailQuery("42", "")
|
||||
if idQuery.CategoryID != 42 || idQuery.CategorySlug != "" {
|
||||
t.Fatalf("ParseCategoryDetailQuery(id) = %#v", idQuery)
|
||||
}
|
||||
if got := CategoryDetailCanonicalPath(idQuery); got != "/category/42/" {
|
||||
t.Fatalf("CategoryDetailCanonicalPath(id) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryDetailURLPrefersSlugAndFallsBackToID(t *testing.T) {
|
||||
if got := categoryDetailURL("latest-video", 42); got != "/category/latest-video/" {
|
||||
t.Fatalf("categoryDetailURL(slug) = %q", got)
|
||||
}
|
||||
if got := categoryDetailURL("", 42); got != "/category/42/" {
|
||||
t.Fatalf("categoryDetailURL(id) = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func categoryDetailPageURL(categoryID int64, categorySlug string, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/category"
|
||||
if segment := pathSegment(categorySlug, categoryID); segment != "" {
|
||||
path += "/" + segment
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
func categoryDetailURL(categorySlug string, categoryID int64) string {
|
||||
return categoryDetailPageURL(categoryID, categorySlug, 1)
|
||||
}
|
||||
|
||||
// ParseCategoryDetailQuery 从语义化路由读取分类标识和页码。
|
||||
func ParseCategoryDetailQuery(identifier, pageStr string) CategoryDetailQuery {
|
||||
q := CategoryDetailQuery{PageSize: defaultCategoryPageSize}
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if id, err := strconv.ParseInt(identifier, 10, 64); err == nil && id > 0 {
|
||||
q.CategoryID = id
|
||||
} else {
|
||||
q.CategorySlug = identifier
|
||||
}
|
||||
if page, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = page
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
func CategoryDetailCanonicalPath(q CategoryDetailQuery) string {
|
||||
q.Normalize()
|
||||
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, q.PageNum)
|
||||
}
|
||||
|
||||
func BuildCategoryDetailPagination(q CategoryDetailQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(page int) string {
|
||||
return categoryDetailPageURL(q.CategoryID, q.CategorySlug, page)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
func CategoryDetailPageTitle(categoryName string, pageNum int, seo SeoInfo) string {
|
||||
name := strings.TrimSpace(categoryName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(seo.Title)
|
||||
}
|
||||
if name == "" {
|
||||
name = "分类短剧"
|
||||
}
|
||||
return name + PageTitleSuffix(pageNum)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
chapterDetailPath = "/api/app/playlet/chapter/detail"
|
||||
chapterListPath = "/api/app/playlet/chapter/list"
|
||||
)
|
||||
|
||||
// PlayQuery 播放页参数。
|
||||
type PlayQuery struct {
|
||||
PlayletID int64
|
||||
PlayletSlug string
|
||||
ChapterID int64
|
||||
}
|
||||
|
||||
// ParsePlayQuery 解析播放页的路径参数。
|
||||
func ParsePlayQuery(playletKey, chapterIDRaw string) PlayQuery {
|
||||
q := PlayQuery{PlayletSlug: strings.TrimSpace(playletKey)}
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(playletKey), 10, 64); err == nil && n > 0 {
|
||||
q.PlayletID = n
|
||||
q.PlayletSlug = ""
|
||||
}
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(chapterIDRaw), 10, 64); err == nil && n > 0 {
|
||||
q.ChapterID = n
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// FetchChapterDetail 查询剧集播放详情。
|
||||
func FetchChapterDetail(c *gin.Context, chapterID, playletID int64, playletSlug string) (*PlayletChapterInfoResp, error) {
|
||||
if chapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter detail invalid chapterId")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("chapterId", strconv.FormatInt(chapterID, 10))
|
||||
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
|
||||
params.Set("playletSlug", playletSlug)
|
||||
} else if playletID > 0 {
|
||||
params.Set("playletId", strconv.FormatInt(playletID, 10))
|
||||
}
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
var env APIEnvelope[PlayletChapterInfoResp]
|
||||
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
if err := upstream.GetJSON(c, chapterDetailPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("chapter detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
env.Data.SeoInfo.Normalize()
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchChapterDetailOrNil 失败返回 nil。
|
||||
func FetchChapterDetailOrNil(c *gin.Context, chapterID, playletID int64, playletSlug string) *PlayletChapterInfoResp {
|
||||
data, err := FetchChapterDetail(c, chapterID, playletID, playletSlug)
|
||||
if err != nil {
|
||||
log.Error("fetch chapter detail failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
if data == nil || data.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchChapterList 根据短剧 slug 或 ID 查询选集列表,slug 优先。
|
||||
func FetchChapterList(c *gin.Context, playletID int64, playletSlug string) (*PlayletChapterListResp, error) {
|
||||
params := url.Values{}
|
||||
if playletSlug = strings.TrimSpace(playletSlug); playletSlug != "" {
|
||||
params.Set("playletSlug", playletSlug)
|
||||
} else if playletID > 0 {
|
||||
params.Set("playletId", strconv.FormatInt(playletID, 10))
|
||||
} else {
|
||||
return nil, fmt.Errorf("chapter list missing playletSlug or playletId")
|
||||
}
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
var env APIEnvelope[PlayletChapterListResp]
|
||||
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
if err := upstream.GetJSON(c, chapterListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet chapter list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// ResolvePlayChapterID 无 chapterId 时取短剧第一章。
|
||||
func ResolvePlayChapterID(playlet *PlayletInfoResp, chapterID int64) int64 {
|
||||
if chapterID > 0 {
|
||||
return chapterID
|
||||
}
|
||||
if playlet == nil {
|
||||
return 0
|
||||
}
|
||||
for _, ch := range playlet.PlayletChapterInfo {
|
||||
if ch.ID > 0 {
|
||||
return ch.ID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AllowWatch 是否可播放(优先 canWatch;兼容仅返回 videoUrl 的旧数据)。
|
||||
func (c *PlayletChapterInfoResp) AllowWatch() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if c.CanWatch {
|
||||
return true
|
||||
}
|
||||
if c.NeedUnlock || c.NeedVip {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(c.VideoURL) != "" && c.PayType == 0
|
||||
}
|
||||
|
||||
// UnlockCost 金币解锁消耗:优先 costCoins。
|
||||
func (c *PlayletChapterInfoResp) UnlockCost() int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
if c.CostCoins > 0 {
|
||||
return c.CostCoins
|
||||
}
|
||||
if c.SingleCoins > 0 {
|
||||
return int64(c.SingleCoins)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AllowWatch 详情选集是否可直达播放(优先 canWatch)。
|
||||
func (c PlayletChapterData) AllowWatch() bool {
|
||||
if c.CanWatch {
|
||||
return true
|
||||
}
|
||||
if c.NeedUnlock || c.NeedVip {
|
||||
return false
|
||||
}
|
||||
if c.IsUnlocked {
|
||||
return true
|
||||
}
|
||||
return c.PayType == 0
|
||||
}
|
||||
|
||||
// NeedCoinGate 是否弹出金币解锁。
|
||||
func (c PlayletChapterData) NeedCoinGate() bool {
|
||||
if c.AllowWatch() {
|
||||
return false
|
||||
}
|
||||
if c.NeedUnlock {
|
||||
return true
|
||||
}
|
||||
return c.PayType == 1
|
||||
}
|
||||
|
||||
// NeedVipGate 是否弹出 VIP 开通。
|
||||
func (c PlayletChapterData) NeedVipGate() bool {
|
||||
if c.AllowWatch() || c.NeedCoinGate() {
|
||||
return false
|
||||
}
|
||||
if c.NeedVip {
|
||||
return true
|
||||
}
|
||||
return c.PayType == 2
|
||||
}
|
||||
|
||||
// UnlockCost 金币解锁消耗:优先 costCoins。
|
||||
func (c PlayletChapterData) UnlockCost() int {
|
||||
if c.CostCoins > 0 {
|
||||
return int(c.CostCoins)
|
||||
}
|
||||
return c.SingleCoins
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const chapterCollectListPath = "/api/app/playlet/chapter/collect/list"
|
||||
|
||||
// PlayletChapterCollectData 对应 swagger proto.PlayletChapterCollectData
|
||||
type PlayletChapterCollectData struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
PlayletSlug string `json:"playletSlug"`
|
||||
PlayletTitle string `json:"playletTitle"`
|
||||
PlayletCover string `json:"playletCover"`
|
||||
CurrentEpisode int `json:"currentEpisode"`
|
||||
TotalEpisode int `json:"totalEpisode"`
|
||||
UpdateStatus int `json:"updateStatus"`
|
||||
CountBrowse int64 `json:"countBrowse"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
CollectedAt string `json:"collectedAt"`
|
||||
MediaTime int `json:"mediaTime"`
|
||||
PayType int `json:"payType"`
|
||||
SingleCoins int `json:"singleCoins"`
|
||||
VideoURL string `json:"videoUrl"`
|
||||
}
|
||||
|
||||
// PlayletChapterCollectListResp 对应 swagger proto.PlayletChapterCollectListResp
|
||||
type PlayletChapterCollectListResp struct {
|
||||
List []PlayletChapterCollectData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// FetchChapterCollectList 拉取当前用户剧集收藏列表。
|
||||
// 第二个返回值为上游业务 code(网络错误时为 0)。
|
||||
func FetchChapterCollectList(c *gin.Context, q CollectListQuery) (*PlayletChapterCollectListResp, int, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[PlayletChapterCollectListResp]
|
||||
if err := upstream.GetJSON(c, chapterCollectListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("playlet chapter collect list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
|
||||
// FetchChapterCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
|
||||
func FetchChapterCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletChapterCollectListResp {
|
||||
data, code, err := FetchChapterCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
data, _, err = FetchChapterCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
log.Error("fetch playlet chapter collect list failed", log.E(err))
|
||||
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
chapterLikeSetPath = "/api/app/playlet/chapter/like/set"
|
||||
chapterCollectSetPath = "/api/app/playlet/chapter/collect/set"
|
||||
)
|
||||
|
||||
// PlayletChapterLikeSetReq 对应 swagger proto.PlayletChapterLikeSetReq
|
||||
type PlayletChapterLikeSetReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
IsLike bool `json:"isLike"`
|
||||
}
|
||||
|
||||
// PlayletChapterLikeSetResp 对应 swagger proto.PlayletChapterLikeSetResp
|
||||
type PlayletChapterLikeSetResp struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsLike int `json:"isLike"`
|
||||
}
|
||||
|
||||
// ChapterLikeSet 设置剧集点赞/取消点赞。
|
||||
func ChapterLikeSet(c *gin.Context, req PlayletChapterLikeSetReq) (*APIEnvelope[PlayletChapterLikeSetResp], error) {
|
||||
if req.ChapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter like invalid chapterId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterLikeSetResp]
|
||||
if _, err := upstream.PostJSON(c, chapterLikeSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// PlayletChapterCollectSetReq 对应 swagger proto.PlayletChapterCollectSetReq
|
||||
type PlayletChapterCollectSetReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
IsCollect bool `json:"isCollect"`
|
||||
}
|
||||
|
||||
// PlayletChapterCollectSetResp 对应 swagger proto.PlayletChapterCollectSetResp
|
||||
type PlayletChapterCollectSetResp struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
}
|
||||
|
||||
// ChapterCollectSet 设置剧集收藏/取消收藏。
|
||||
func ChapterCollectSet(c *gin.Context, req PlayletChapterCollectSetReq) (*APIEnvelope[PlayletChapterCollectSetResp], error) {
|
||||
if req.ChapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter collect invalid chapterId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterCollectSetResp]
|
||||
if _, err := upstream.PostJSON(c, chapterCollectSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const chapterUnlockPath = "/api/app/playlet/chapter/unlock"
|
||||
|
||||
// PlayletChapterUnlockReq 对应 swagger proto.PlayletChapterUnlockReq
|
||||
// POST /api/app/playlet/chapter/unlock
|
||||
type PlayletChapterUnlockReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
}
|
||||
|
||||
// PlayletChapterUnlockResp 对应 swagger proto.PlayletChapterUnlockResp
|
||||
type PlayletChapterUnlockResp struct {
|
||||
Balance int64 `json:"balance"`
|
||||
CanWatch bool `json:"canWatch"`
|
||||
ChangeCoin int64 `json:"changeCoin"`
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
CostCoins int64 `json:"costCoins"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
Unlocked bool `json:"unlocked"`
|
||||
}
|
||||
|
||||
// ChapterUnlock 使用金币解锁剧集(幂等)。
|
||||
func ChapterUnlock(c *gin.Context, req PlayletChapterUnlockReq) (*APIEnvelope[PlayletChapterUnlockResp], error) {
|
||||
if req.ChapterID <= 0 || req.PlayletID <= 0 {
|
||||
return nil, fmt.Errorf("chapter unlock invalid chapterId/playletId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterUnlockResp]
|
||||
if _, err := upstream.PostJSON(c, chapterUnlockPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const chapterWatchReportPath = "/api/app/playlet/chapter/watch/report"
|
||||
|
||||
// PlayletChapterWatchReportReq 对应 swagger proto.PlayletChapterWatchReportReq
|
||||
type PlayletChapterWatchReportReq struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId,omitempty"`
|
||||
}
|
||||
|
||||
// PlayletChapterWatchReportResp 对应 swagger proto.PlayletChapterWatchReportResp
|
||||
type PlayletChapterWatchReportResp struct {
|
||||
ChapterID int64 `json:"chapterId"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
CurrentEpisode int `json:"currentEpisode"`
|
||||
LastChapterID int64 `json:"lastChapterId"`
|
||||
LastEpisode int `json:"lastEpisode"`
|
||||
WatchedCount int `json:"watchedCount"`
|
||||
UnwatchedCount int `json:"unwatchedCount"`
|
||||
}
|
||||
|
||||
// ChapterWatchReport 上报短剧剧集观看。
|
||||
func ChapterWatchReport(c *gin.Context, req PlayletChapterWatchReportReq) (*APIEnvelope[PlayletChapterWatchReportResp], error) {
|
||||
if req.ChapterID <= 0 {
|
||||
return nil, fmt.Errorf("chapter watch report invalid chapterId")
|
||||
}
|
||||
var env APIEnvelope[PlayletChapterWatchReportResp]
|
||||
if _, err := upstream.PostJSON(c, chapterWatchReportPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
collectSetPath = "/api/app/playlet/collect/set"
|
||||
collectListPath = "/api/app/playlet/collect/list"
|
||||
defaultCollectPageSize = 40
|
||||
|
||||
CollectTypePlaylet = "playlet"
|
||||
CollectTypeChapter = "chapter"
|
||||
)
|
||||
|
||||
// PlayletCollectSetReq 对应 swagger proto.PlayletCollectSetReq
|
||||
type PlayletCollectSetReq struct {
|
||||
ID int64 `json:"id"`
|
||||
IsCollect bool `json:"isCollect"`
|
||||
}
|
||||
|
||||
// PlayletCollectSetResp 对应 swagger proto.PlayletCollectSetResp
|
||||
type PlayletCollectSetResp struct {
|
||||
ID int64 `json:"id"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
}
|
||||
|
||||
// PlayletCollectData 对应 swagger proto.PlayletCollectData
|
||||
type PlayletCollectData struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Cover string `json:"cover"`
|
||||
Desc string `json:"desc"`
|
||||
TagIDs []int64 `json:"tagIds"`
|
||||
TagNames []string `json:"tagNames"`
|
||||
TagSlugs []string `json:"tagSlugs"`
|
||||
ThemeIDs []int64 `json:"themeIds"`
|
||||
ThemeNames []string `json:"themeNames"`
|
||||
ThemeSlugs []string `json:"themeSlugs"`
|
||||
TotalEpisode int `json:"totalEpisode"`
|
||||
UpdateStatus int `json:"updateStatus"`
|
||||
CountBrowse int64 `json:"countBrowse"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsFeatured bool `json:"isFeatured"`
|
||||
IsNew bool `json:"isNew"`
|
||||
IsRecommend bool `json:"isRecommend"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
CollectedAt string `json:"collectedAt"`
|
||||
WatchedCount int `json:"watchedCount"`
|
||||
UnwatchedCount int `json:"unwatchedCount"`
|
||||
LastChapterID int64 `json:"lastChapterId"`
|
||||
LastEpisode int `json:"lastEpisode"`
|
||||
}
|
||||
|
||||
// PlayletCollectListResp 对应 swagger proto.PlayletCollectListResp
|
||||
type PlayletCollectListResp struct {
|
||||
List []PlayletCollectData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// CollectListQuery 收藏列表分页参数。
|
||||
type CollectListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
Type string // playlet | chapter
|
||||
}
|
||||
|
||||
// NormalizeCollectType 归一化收藏类型。
|
||||
func NormalizeCollectType(raw string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(raw)) {
|
||||
case CollectTypeChapter, "2", "episode", "ep":
|
||||
return CollectTypeChapter
|
||||
default:
|
||||
return CollectTypePlaylet
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *CollectListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultCollectPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
q.Type = NormalizeCollectType(q.Type)
|
||||
}
|
||||
|
||||
// CollectSet 设置短剧收藏/取消收藏。
|
||||
func CollectSet(c *gin.Context, req PlayletCollectSetReq) (*APIEnvelope[PlayletCollectSetResp], error) {
|
||||
if req.ID <= 0 {
|
||||
return nil, fmt.Errorf("playlet collect invalid id")
|
||||
}
|
||||
var env APIEnvelope[PlayletCollectSetResp]
|
||||
if _, err := upstream.PostJSON(c, collectSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchCollectList 拉取当前用户短剧收藏列表。
|
||||
// 第二个返回值为上游业务 code(网络错误时为 0)。
|
||||
func FetchCollectList(c *gin.Context, q CollectListQuery) (*PlayletCollectListResp, int, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[PlayletCollectListResp]
|
||||
if err := upstream.GetJSON(c, collectListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, env.Code, fmt.Errorf("playlet collect list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, env.Code, nil
|
||||
}
|
||||
|
||||
// FetchCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
|
||||
func FetchCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletCollectListResp {
|
||||
data, code, err := FetchCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
if retry, _ := authser.OnSessionExpired(c, code); retry {
|
||||
data, _, err = FetchCollectList(c, q)
|
||||
if err == nil {
|
||||
if data == nil {
|
||||
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
log.Error("fetch playlet collect list failed", log.E(err))
|
||||
return &PlayletCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func collectPageURL(pageNum int, collectType string) string {
|
||||
collectType = NormalizeCollectType(collectType)
|
||||
path := "/collect"
|
||||
if collectType != CollectTypePlaylet {
|
||||
path += "/" + url.PathEscape(collectType)
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
func CollectCanonicalPath(q CollectListQuery) string {
|
||||
q.Normalize()
|
||||
return collectPageURL(q.PageNum, q.Type)
|
||||
}
|
||||
|
||||
// BuildCollectPagination 收藏列表分页。
|
||||
func BuildCollectPagination(q CollectListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return collectPageURL(p, q.Type)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// ParseCollectListQuery 从 query 解析分页与类型(playlet 短剧 / chapter 剧集)。
|
||||
func ParseCollectListQuery(pageStr, typeStr string) CollectListQuery {
|
||||
q := CollectListQuery{
|
||||
PageSize: defaultCollectPageSize,
|
||||
Type: NormalizeCollectType(typeStr),
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// collectProgressText 进度文案:优先最近观看集,其次未看/已看完。
|
||||
func collectProgressText(item PlayletCollectData) string {
|
||||
if item.LastEpisode > 0 {
|
||||
return fmt.Sprintf("第%d集", item.LastEpisode)
|
||||
}
|
||||
if item.TotalEpisode > 0 && item.UnwatchedCount <= 0 {
|
||||
return "已看完"
|
||||
}
|
||||
if item.UnwatchedCount > 0 {
|
||||
return fmt.Sprintf("%d集未看", item.UnwatchedCount)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// collectTagText 收藏卡片标签:仅展示 TagNames 第一项,无标签时不显示。
|
||||
func collectTagText(item PlayletCollectData) string {
|
||||
return firstName(item.TagNames)
|
||||
}
|
||||
|
||||
// chapterCollectEpisodeLabel 剧集收藏角标:总集数。
|
||||
func chapterCollectEpisodeLabel(item PlayletChapterCollectData) string {
|
||||
return episodeText(item.TotalEpisode)
|
||||
}
|
||||
|
||||
// chapterCollectDesc 剧集收藏副标题。
|
||||
func chapterCollectDesc(item PlayletChapterCollectData) string {
|
||||
if t := strings.TrimSpace(item.ChapterTitle); t != "" {
|
||||
return t
|
||||
}
|
||||
if item.CurrentEpisode > 0 {
|
||||
return fmt.Sprintf("第%d集", item.CurrentEpisode)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// chapterCollectMeta 剧集收藏右侧文案:第 N 集。
|
||||
func chapterCollectMeta(item PlayletChapterCollectData) string {
|
||||
if item.CurrentEpisode > 0 {
|
||||
return fmt.Sprintf("第%d集", item.CurrentEpisode)
|
||||
}
|
||||
return updateStatusText(item.UpdateStatus)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
detailPath = "/api/app/playlet/detail"
|
||||
defaultEpisodeRange = 30
|
||||
)
|
||||
|
||||
// EpisodeRange 剧集分段(仅 UI 分页展示,非接口字段映射)。
|
||||
type EpisodeRange struct {
|
||||
Start int
|
||||
End int
|
||||
Label string
|
||||
}
|
||||
|
||||
// FetchDetail 根据短剧 ID 查询详情。
|
||||
func FetchDetail(c *gin.Context, id int64) (*PlayletInfoResp, error) {
|
||||
if id <= 0 {
|
||||
return nil, fmt.Errorf("playlet detail invalid id")
|
||||
}
|
||||
return fetchDetail(c, "id", strconv.FormatInt(id, 10))
|
||||
}
|
||||
|
||||
// FetchDetailByKey 优先按 slug 查询;纯数字路由段按 ID 查询。
|
||||
func FetchDetailByKey(c *gin.Context, key string) (*PlayletInfoResp, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("playlet detail missing route key")
|
||||
}
|
||||
if id := ParseDetailID(key); id > 0 {
|
||||
return FetchDetail(c, id)
|
||||
}
|
||||
return fetchDetail(c, "slug", key)
|
||||
}
|
||||
|
||||
func fetchDetail(c *gin.Context, queryKey, value string) (*PlayletInfoResp, error) {
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
env, err := fetchDetailOnce(c, queryKey, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
env, err = fetchDetailOnce(c, queryKey, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet detail code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
env.Data.SeoInfo.Normalize()
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
func fetchDetailOnce(c *gin.Context, queryKey, value string) (*APIEnvelope[PlayletInfoResp], error) {
|
||||
params := url.Values{}
|
||||
params.Set(queryKey, value)
|
||||
var env APIEnvelope[PlayletInfoResp]
|
||||
if err := upstream.GetJSON(c, detailPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchDetailOrNilByKey 失败或无效时返回 nil。
|
||||
func FetchDetailOrNilByKey(c *gin.Context, key string) *PlayletInfoResp {
|
||||
data, err := FetchDetailByKey(c, key)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet detail by route key failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
if data == nil || data.ID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ParseDetailID 解析纯数字路由段。
|
||||
func ParseDetailID(raw string) int64 {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// BuildEpisodeRanges 按集数分段(默认每段 30 集)。
|
||||
func BuildEpisodeRanges(chapters []PlayletChapterData, totalEpisode, size int) []EpisodeRange {
|
||||
if size <= 0 {
|
||||
size = defaultEpisodeRange
|
||||
}
|
||||
maxEp := 0
|
||||
for _, ch := range chapters {
|
||||
if ch.CurrentEpisode > maxEp {
|
||||
maxEp = ch.CurrentEpisode
|
||||
}
|
||||
}
|
||||
if totalEpisode > maxEp {
|
||||
maxEp = totalEpisode
|
||||
}
|
||||
if maxEp <= 0 {
|
||||
maxEp = len(chapters)
|
||||
}
|
||||
if maxEp <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]EpisodeRange, 0, (maxEp+size-1)/size)
|
||||
for start := 1; start <= maxEp; start += size {
|
||||
end := start + size - 1
|
||||
if end > maxEp {
|
||||
end = maxEp
|
||||
}
|
||||
out = append(out, EpisodeRange{
|
||||
Start: start,
|
||||
End: end,
|
||||
Label: fmt.Sprintf("%d–%d", start, end),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnmarshalJSON 兼容上游 data 为对象、null、空字符串,或二次 JSON 编码的字符串。
|
||||
func (e *APIEnvelope[T]) UnmarshalJSON(raw []byte) error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
var aux struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Code = aux.Code
|
||||
e.Msg = aux.Msg
|
||||
e.Tip = aux.Tip
|
||||
data, err := unmarshalEnvelopeData[T](aux.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Data = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmarshalEnvelopeData[T any](raw json.RawMessage) (T, error) {
|
||||
var zero T
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return zero, nil
|
||||
}
|
||||
if raw[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return zero, nil
|
||||
}
|
||||
inner := bytes.TrimSpace([]byte(s))
|
||||
if len(inner) == 0 {
|
||||
return zero, nil
|
||||
}
|
||||
if inner[0] != '{' && inner[0] != '[' {
|
||||
return zero, nil
|
||||
}
|
||||
if err := json.Unmarshal(inner, &zero); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return zero, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &zero); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return zero, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package playletser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFooterLinkURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "关于我们", raw: "/richtext/关于我们", want: "/richtext/关于我们/"},
|
||||
{name: "帮助中心", raw: "/richtext/帮助中心", want: "/richtext/帮助中心/"},
|
||||
{name: "隐私政策", raw: "/richtext/privacy", want: "/richtext/privacy/"},
|
||||
{name: "问题反馈", raw: "/feedback", want: "/feedback/"},
|
||||
{name: "用户中心", raw: "/user-center/", want: "/user-center/"},
|
||||
{name: "合作伙伴", raw: "https://example.com", want: "https://example.com"},
|
||||
{name: "HTTPS外链", raw: "HTTPS://Example.COM", want: "HTTPS://Example.COM"},
|
||||
{name: "关于我们", raw: "", want: ""},
|
||||
{name: "关于我们", raw: "/", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+" "+tt.raw, func(t *testing.T) {
|
||||
if got := footerLinkURL(tt.name, tt.raw); got != tt.want {
|
||||
t.Fatalf("footerLinkURL(%q, %q) = %q, want %q", tt.name, tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterLinkIsExternal(t *testing.T) {
|
||||
if !footerLinkIsExternal("http://a.com") || !footerLinkIsExternal("HTTPS://A.COM") {
|
||||
t.Fatal("http(s) URLs should be external")
|
||||
}
|
||||
if footerLinkIsExternal("/richtext/关于我们/") || footerLinkIsExternal("/feedback/") {
|
||||
t.Fatal("internal paths should not be external")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterContactURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
gmail string
|
||||
telegram string
|
||||
customer string
|
||||
want string
|
||||
}{
|
||||
{name: "E-mail", value: "hello@example.com", want: "mailto:hello@example.com"},
|
||||
{name: "邮箱", gmail: "support@example.com", want: "mailto:support@example.com"},
|
||||
{name: "商务合作", value: "立即联系", customer: "service.example.com", want: "https://service.example.com"},
|
||||
{name: "商务合作", value: "business@example.com", want: "mailto:business@example.com"},
|
||||
{name: "官方群", value: "立即加入", telegram: "https://t.me/example", want: "https://t.me/example"},
|
||||
{name: "官方群", value: "t.me/example", want: "https://t.me/example"},
|
||||
{name: "工作时间", value: "10:00-18:00", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+tt.value, func(t *testing.T) {
|
||||
got := footerContactURL(tt.name, tt.value, tt.gmail, tt.telegram, tt.customer)
|
||||
if got != tt.want {
|
||||
t.Fatalf("footerContactURL(%q, %q) = %q, want %q", tt.name, tt.value, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterContactValue(t *testing.T) {
|
||||
for _, value := range []string{"立即联系", "立即加入", " 立即加入 "} {
|
||||
if got := footerContactValue(value); got != "" {
|
||||
t.Fatalf("footerContactValue(%q) = %q, want empty", value, got)
|
||||
}
|
||||
}
|
||||
if got := footerContactValue("hello@example.com"); got != "hello@example.com" {
|
||||
t.Fatalf("footerContactValue(email) = %q", got)
|
||||
}
|
||||
if got := footerContactValue("立即联系:https://t1t1.com"); got != "https://t1t1.com" {
|
||||
t.Fatalf("footerContactValue(business URL) = %q", got)
|
||||
}
|
||||
if got := footerContactValue("立即加入: https://t.me/fuliqun_91sp"); got != "https://t.me/fuliqun_91sp" {
|
||||
t.Fatalf("footerContactValue(group URL) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterContactName(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"商务合作:立即联系": "商务合作",
|
||||
"官方群:立即加入": "官方群",
|
||||
"立即联系:商务合作": "商务合作",
|
||||
" E-mail ": "E-mail",
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := footerContactName(input); got != want {
|
||||
t.Fatalf("footerContactName(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavDisplayURL(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"https://91lvmao0000.com/": "91lvmao0000.com",
|
||||
"http://example.com/path/": "example.com/path",
|
||||
"example.com": "example.com",
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := navDisplayURL(input); got != want {
|
||||
t.Fatalf("navDisplayURL(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const forwardHeatPath = "/api/app/playlet/forward/heat"
|
||||
|
||||
// PlayletForwardHeatReq 对应 swagger proto.PlayletForwardHeatReq
|
||||
type PlayletForwardHeatReq struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// PlayletForwardHeatResp 对应 swagger proto.PlayletForwardHeatResp
|
||||
type PlayletForwardHeatResp struct {
|
||||
ID int64 `json:"id"`
|
||||
HeatValue int64 `json:"heatValue"`
|
||||
}
|
||||
|
||||
// ForwardHeat 短剧转发增加热度。
|
||||
func ForwardHeat(c *gin.Context, req PlayletForwardHeatReq) (*APIEnvelope[PlayletForwardHeatResp], error) {
|
||||
if req.ID <= 0 {
|
||||
return nil, fmt.Errorf("playlet forward heat invalid id")
|
||||
}
|
||||
var env APIEnvelope[PlayletForwardHeatResp]
|
||||
if _, err := upstream.PostJSON(c, forwardHeatPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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"
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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,无请求体。
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// formatHistoryAt 最近观看时间展示。
|
||||
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 t.Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const homePath = "/api/app/playlet/home"
|
||||
const homeCacheTTL = 30 * time.Second
|
||||
|
||||
var (
|
||||
homeMu sync.Mutex
|
||||
homeCache *HomePlayletResponse
|
||||
homeAt time.Time
|
||||
)
|
||||
|
||||
// FetchHome 拉取首页短剧聚合数据。
|
||||
func FetchHome(c *gin.Context) (*HomePlayletResponse, error) {
|
||||
var env APIEnvelope[HomePlayletResponse]
|
||||
if err := upstream.GetPublicJSON(c, homePath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet home code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchHomeOrEmpty 带短缓存;失败时打日志并返回空结构,保证 SSR 页面可渲染。
|
||||
func FetchHomeOrEmpty(c *gin.Context) *HomePlayletResponse {
|
||||
homeMu.Lock()
|
||||
defer homeMu.Unlock()
|
||||
if homeCache != nil && time.Since(homeAt) < homeCacheTTL {
|
||||
return cloneHome(homeCache)
|
||||
}
|
||||
|
||||
data, err := FetchHome(c)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet home failed", log.E(err))
|
||||
if homeCache != nil {
|
||||
return cloneHome(homeCache)
|
||||
}
|
||||
return &HomePlayletResponse{}
|
||||
}
|
||||
if data == nil {
|
||||
if homeCache != nil {
|
||||
return cloneHome(homeCache)
|
||||
}
|
||||
return &HomePlayletResponse{}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
homeCache = data
|
||||
homeAt = time.Now()
|
||||
return cloneHome(homeCache)
|
||||
}
|
||||
|
||||
func cloneHome(src *HomePlayletResponse) *HomePlayletResponse {
|
||||
if src == nil {
|
||||
return &HomePlayletResponse{}
|
||||
}
|
||||
out := *src
|
||||
if src.Announcements != nil {
|
||||
out.Announcements = append([]AnnouncementItem(nil), src.Announcements...)
|
||||
}
|
||||
if src.HomeBannerData != nil {
|
||||
out.HomeBannerData = append([]HomeBannerData(nil), src.HomeBannerData...)
|
||||
}
|
||||
if src.HomeTodayUpdateData != nil {
|
||||
out.HomeTodayUpdateData = append([]CommonData(nil), src.HomeTodayUpdateData...)
|
||||
}
|
||||
if src.HomeCategoryData != nil {
|
||||
out.HomeCategoryData = make([]HomeCategoryData, len(src.HomeCategoryData))
|
||||
for i, cat := range src.HomeCategoryData {
|
||||
out.HomeCategoryData[i] = cat
|
||||
if cat.List != nil {
|
||||
out.HomeCategoryData[i].List = append([]CommonData(nil), cat.List...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if src.HomeRecommendThemes != nil {
|
||||
out.HomeRecommendThemes = append([]HomeRecommendTheme(nil), src.HomeRecommendThemes...)
|
||||
}
|
||||
if src.SeoInfo.Keywords != nil {
|
||||
out.SeoInfo.Keywords = append([]string(nil), src.SeoInfo.Keywords...)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// ActiveHomeBanners 过滤未启用项,保留接口返回顺序。
|
||||
func ActiveHomeBanners(list []HomeBannerData) []HomeBannerData {
|
||||
if len(list) == 0 {
|
||||
return list
|
||||
}
|
||||
out := make([]HomeBannerData, 0, len(list))
|
||||
for _, item := range list {
|
||||
if !item.Status {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const hotLikePath = "/api/app/playlet/hot-like"
|
||||
|
||||
// PlayletHotSearchItem 对应 swagger proto.PlayletHotSearchItem。
|
||||
type PlayletHotSearchItem struct {
|
||||
Keyword string `json:"keyword"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// PlayletHotLikeResp 对应 swagger proto.PlayletHotLikeResp。
|
||||
type PlayletHotLikeResp struct {
|
||||
HotSearch []PlayletHotSearchItem `json:"hotSearch"`
|
||||
GuessLike []CommonData `json:"guessLike"`
|
||||
}
|
||||
|
||||
// FetchHotLike 获取前 10 个热搜词及按收藏量排序的 15 部猜你喜欢短剧。
|
||||
func FetchHotLike(c *gin.Context) (*PlayletHotLikeResp, error) {
|
||||
var env APIEnvelope[PlayletHotLikeResp]
|
||||
if err := upstream.GetPublicJSON(c, hotLikePath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet hot-like code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlayletHotLikeRespMatchesSwaggerFields(t *testing.T) {
|
||||
const raw = `{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"hotSearch": [{"keyword": "都市", "count": 18}],
|
||||
"guessLike": [{"id": 12, "title": "示例短剧", "slug": "example"}]
|
||||
}
|
||||
}`
|
||||
|
||||
var env APIEnvelope[PlayletHotLikeResp]
|
||||
if err := json.Unmarshal([]byte(raw), &env); err != nil {
|
||||
t.Fatalf("unmarshal hot-like response: %v", err)
|
||||
}
|
||||
if env.Code != 200 || len(env.Data.HotSearch) != 1 || env.Data.HotSearch[0].Keyword != "都市" || env.Data.HotSearch[0].Count != 18 {
|
||||
t.Fatalf("unexpected hot search data: %#v", env)
|
||||
}
|
||||
if len(env.Data.GuessLike) != 1 || env.Data.GuessLike[0].ID != 12 || env.Data.GuessLike[0].Slug != "example" {
|
||||
t.Fatalf("unexpected guess-like data: %#v", env.Data.GuessLike)
|
||||
}
|
||||
if hotLikePath != "/api/app/playlet/hot-like" {
|
||||
t.Fatalf("hotLikePath = %q", hotLikePath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"videoweb/app/service/upstream"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const likeSetPath = "/api/app/playlet/like/set"
|
||||
|
||||
// PlayletLikeSetReq 对应 swagger proto.PlayletLikeSetReq
|
||||
type PlayletLikeSetReq struct {
|
||||
ID int64 `json:"id"`
|
||||
IsLike bool `json:"isLike"`
|
||||
}
|
||||
|
||||
// PlayletLikeSetResp 对应 swagger proto.PlayletLikeSetResp
|
||||
type PlayletLikeSetResp struct {
|
||||
ID int64 `json:"id"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsLike int `json:"isLike"`
|
||||
}
|
||||
|
||||
// LikeSet 设置短剧点赞/取消点赞。
|
||||
func LikeSet(c *gin.Context, req PlayletLikeSetReq) (*APIEnvelope[PlayletLikeSetResp], error) {
|
||||
if req.ID <= 0 {
|
||||
return nil, fmt.Errorf("playlet like invalid id")
|
||||
}
|
||||
var env APIEnvelope[PlayletLikeSetResp]
|
||||
if _, err := upstream.PostJSON(c, likeSetPath, req, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
listPath = "/api/app/playlet/list"
|
||||
themePath = "/api/app/theme/list"
|
||||
|
||||
defaultListPageSize = 25
|
||||
sortHeat = 1 // 热度优先
|
||||
sortTodayUpdate = 2 // 今日更新
|
||||
// 站点统一类型:1-非成人 2-成人(与 themeType 一致)。
|
||||
contentTypeNonAdult = 1
|
||||
contentTypeAdult = 2
|
||||
// playlet/list 上游 type 与站点定义相反:1-成人 2-非成人。
|
||||
upstreamListTypeAdult = 1
|
||||
upstreamListTypeNonAdult = 2
|
||||
)
|
||||
|
||||
// ListQuery 短剧列表查询参数。
|
||||
type ListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortOrder int
|
||||
ThemeID int64
|
||||
ThemeSlug string
|
||||
Type int
|
||||
}
|
||||
|
||||
// Normalize 校正分页与排序默认值。
|
||||
func (q *ListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultListPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.SortOrder != sortTodayUpdate {
|
||||
q.SortOrder = sortHeat
|
||||
}
|
||||
if q.ThemeID < 0 {
|
||||
q.ThemeID = 0
|
||||
}
|
||||
q.Type = normalizeContentType(q.Type)
|
||||
}
|
||||
|
||||
// FetchList 分页查询已上架短剧。
|
||||
func FetchList(c *gin.Context, q ListQuery) (*PlayletVideoResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
|
||||
if strings.TrimSpace(q.ThemeSlug) != "" {
|
||||
params.Set("themeSlug", strings.TrimSpace(q.ThemeSlug))
|
||||
} else if q.ThemeID > 0 {
|
||||
params.Set("themeId", strconv.FormatInt(q.ThemeID, 10))
|
||||
} else {
|
||||
params.Set("type", strconv.Itoa(listTypeForUpstream(q.Type)))
|
||||
}
|
||||
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetPublicJSON(c, listPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchListOrEmpty 失败时返回空列表。
|
||||
func FetchListOrEmpty(c *gin.Context, q ListQuery) *PlayletVideoResp {
|
||||
data, err := FetchList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet list failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchThemes 拉取题材列表。
|
||||
func FetchThemes(c *gin.Context, contentType int) ([]PlayletThemeItem, error) {
|
||||
contentType = normalizeContentType(contentType)
|
||||
params := url.Values{}
|
||||
params.Set("themeType", strconv.Itoa(contentType))
|
||||
var env APIEnvelope[PlayletThemeListResponse]
|
||||
if err := upstream.GetPublicJSON(c, themePath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("theme list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return env.Data.List, nil
|
||||
}
|
||||
|
||||
// FetchThemesOrEmpty 失败时返回空题材。
|
||||
func FetchThemesOrEmpty(c *gin.Context, contentType int) []PlayletThemeItem {
|
||||
list, err := FetchThemes(c, contentType)
|
||||
if err != nil {
|
||||
log.Error("fetch theme list failed", log.E(err))
|
||||
return nil
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func normalizeContentType(contentType int) int {
|
||||
if contentType == contentTypeAdult {
|
||||
return contentTypeAdult
|
||||
}
|
||||
return contentTypeNonAdult
|
||||
}
|
||||
|
||||
// listTypeForUpstream 将站点统一类型转为 playlet/list 的 type(该接口 1=成人 2=非成人)。
|
||||
func listTypeForUpstream(contentType int) int {
|
||||
if normalizeContentType(contentType) == contentTypeAdult {
|
||||
return upstreamListTypeAdult
|
||||
}
|
||||
return upstreamListTypeNonAdult
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PaginationVM 分页条数据(页面 UI,非接口字段映射)。
|
||||
type PaginationVM struct {
|
||||
Visible bool
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevURL string
|
||||
NextURL string
|
||||
Page int
|
||||
TotalPages int
|
||||
Items []PaginationItemVM
|
||||
}
|
||||
|
||||
// PaginationItemVM 页码或省略号。
|
||||
type PaginationItemVM struct {
|
||||
Label string
|
||||
URL string
|
||||
Active bool
|
||||
Dots bool
|
||||
}
|
||||
|
||||
// FilterChipVM 筛选 / Tab UI。
|
||||
type FilterChipVM struct {
|
||||
Label string
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ThemeChipVM 题材筛选芯片(ID/Name 来自接口,URL/Active 为页面状态)。
|
||||
type ThemeChipVM struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// FindThemeSlugByID 查询 ID 对应的 slug;无 slug 时继续使用 ID 路由。
|
||||
func FindThemeSlugByID(themes []PlayletThemeItem, id int64) string {
|
||||
if id <= 0 {
|
||||
return ""
|
||||
}
|
||||
for _, theme := range themes {
|
||||
if theme.ID == id {
|
||||
return strings.TrimSpace(theme.Slug)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func listPageURL(themeID int64, themeSlug string, sortOrder, pageNum, contentType int) string {
|
||||
if sortOrder != sortTodayUpdate {
|
||||
sortOrder = sortHeat
|
||||
}
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cleanSlug := strings.TrimSpace(themeSlug)
|
||||
defaultSort := sortHeat
|
||||
if cleanSlug == "ai-short-play" || cleanSlug == "ai-series" {
|
||||
defaultSort = sortTodayUpdate
|
||||
}
|
||||
if themeID <= 0 && cleanSlug == "" && sortOrder == sortHeat && pageNum == 1 {
|
||||
return "/list/"
|
||||
}
|
||||
path := "/list"
|
||||
segment := pathSegment(themeSlug, themeID)
|
||||
if segment == "" {
|
||||
segment = "all"
|
||||
}
|
||||
path += "/" + segment + "/" + strconv.Itoa(sortOrder)
|
||||
if themeID <= 0 && cleanSlug != "" && sortOrder == defaultSort && pageNum == 1 {
|
||||
return "/list/" + segment + "/"
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
// ListCanonicalPath 返回列表页唯一的无 query 规范路径。
|
||||
func ListCanonicalPath(q ListQuery) string {
|
||||
q.Normalize()
|
||||
return listPageURL(q.ThemeID, q.ThemeSlug, q.SortOrder, q.PageNum, q.Type)
|
||||
}
|
||||
|
||||
// ListResetURL 重置筛选并生成无 query 的默认排序路径。
|
||||
func ListResetURL(q ListQuery) string {
|
||||
q.Normalize()
|
||||
return ListCanonicalPath(ListQuery{Type: q.Type})
|
||||
}
|
||||
|
||||
// BuildListThemes 题材芯片;Name/ID 保持接口字段语义。
|
||||
func BuildListThemes(themes []PlayletThemeItem, q ListQuery) []ThemeChipVM {
|
||||
q.Normalize()
|
||||
out := make([]ThemeChipVM, 0, len(themes)+1)
|
||||
out = append(out, ThemeChipVM{
|
||||
ID: 0,
|
||||
Name: "全部",
|
||||
URL: listPageURL(0, "", q.SortOrder, 1, q.Type),
|
||||
Active: q.ThemeID == 0 && strings.TrimSpace(q.ThemeSlug) == "",
|
||||
})
|
||||
for _, t := range themes {
|
||||
if !t.Status {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(t.Name)
|
||||
if name == "" || t.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, ThemeChipVM{
|
||||
ID: t.ID,
|
||||
Name: name,
|
||||
URL: listPageURL(t.ID, t.Slug, q.SortOrder, 1, q.Type),
|
||||
Active: q.ThemeID == t.ID || (strings.TrimSpace(q.ThemeSlug) != "" && strings.TrimSpace(q.ThemeSlug) == strings.TrimSpace(t.Slug)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildListSortChips 排序芯片。
|
||||
func BuildListSortChips(q ListQuery) (heat, update FilterChipVM) {
|
||||
q.Normalize()
|
||||
heat = FilterChipVM{
|
||||
Label: "热度优先",
|
||||
URL: listPageURL(q.ThemeID, q.ThemeSlug, sortHeat, 1, q.Type),
|
||||
Active: q.SortOrder == sortHeat,
|
||||
}
|
||||
update = FilterChipVM{
|
||||
Label: "今日更新",
|
||||
URL: listPageURL(q.ThemeID, q.ThemeSlug, sortTodayUpdate, 1, q.Type),
|
||||
Active: q.SortOrder == sortTodayUpdate,
|
||||
}
|
||||
return heat, update
|
||||
}
|
||||
|
||||
// BuildListPagination 列表分页。
|
||||
func BuildListPagination(q ListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return listPageURL(q.ThemeID, q.ThemeSlug, q.SortOrder, p, q.Type)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// BuildPagination 生成分页 VM;pageURL 生成指定页链接。
|
||||
func BuildPagination(pageURL func(int) string, pageNum, pageSize int, totalCount int64) PaginationVM {
|
||||
if pageURL == nil {
|
||||
pageURL = func(p int) string {
|
||||
if p <= 1 {
|
||||
return "#"
|
||||
}
|
||||
return "?page=" + strconv.Itoa(p)
|
||||
}
|
||||
}
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultListPageSize
|
||||
}
|
||||
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||
if totalPages <= 1 {
|
||||
return PaginationVM{Visible: false}
|
||||
}
|
||||
if pageNum > totalPages {
|
||||
pageNum = totalPages
|
||||
}
|
||||
|
||||
return PaginationVM{
|
||||
Visible: true,
|
||||
HasPrev: pageNum > 1,
|
||||
HasNext: pageNum < totalPages,
|
||||
PrevURL: pageURL(pageNum - 1),
|
||||
NextURL: pageURL(pageNum + 1),
|
||||
Page: pageNum,
|
||||
TotalPages: totalPages,
|
||||
Items: buildPageItems(pageNum, totalPages, pageURL),
|
||||
}
|
||||
}
|
||||
|
||||
func buildPageItems(current, total int, pageURL func(int) string) []PaginationItemVM {
|
||||
if total <= 0 {
|
||||
return nil
|
||||
}
|
||||
if total <= 7 {
|
||||
out := make([]PaginationItemVM, 0, total)
|
||||
for i := 1; i <= total; i++ {
|
||||
out = append(out, PaginationItemVM{
|
||||
Label: strconv.Itoa(i),
|
||||
URL: pageURL(i),
|
||||
Active: i == current,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
addPage := func(out *[]PaginationItemVM, p int) {
|
||||
*out = append(*out, PaginationItemVM{
|
||||
Label: strconv.Itoa(p),
|
||||
URL: pageURL(p),
|
||||
Active: p == current,
|
||||
})
|
||||
}
|
||||
addDots := func(out *[]PaginationItemVM) {
|
||||
*out = append(*out, PaginationItemVM{Dots: true, Label: "…"})
|
||||
}
|
||||
|
||||
var out []PaginationItemVM
|
||||
addPage(&out, 1)
|
||||
if current > 3 {
|
||||
addDots(&out)
|
||||
}
|
||||
start := current - 1
|
||||
end := current + 1
|
||||
if start < 2 {
|
||||
start = 2
|
||||
}
|
||||
if end > total-1 {
|
||||
end = total - 1
|
||||
}
|
||||
for p := start; p <= end; p++ {
|
||||
addPage(&out, p)
|
||||
}
|
||||
if current < total-2 {
|
||||
addDots(&out)
|
||||
}
|
||||
addPage(&out, total)
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseListQuery 从 gin query 解析列表参数。
|
||||
func ParseListQuery(pageStr, sortStr, typeStr string) ListQuery {
|
||||
q := ListQuery{PageSize: defaultListPageSize, SortOrder: sortHeat, Type: contentTypeNonAdult}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(sortStr)); err == nil {
|
||||
q.SortOrder = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(typeStr)); err == nil {
|
||||
q.Type = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const operateListPath = "/api/app/playlet/operate/list"
|
||||
|
||||
type OperateListQuery struct {
|
||||
OperateType int
|
||||
FilterType int
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (q *OperateListQuery) Normalize() {
|
||||
if q.OperateType < 1 || q.OperateType > 2 {
|
||||
q.OperateType = 1
|
||||
}
|
||||
if q.FilterType < 1 || q.FilterType > 3 {
|
||||
q.FilterType = 1
|
||||
}
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = 40
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
}
|
||||
|
||||
// FetchOperateList 按运营类型和筛选类型查询已上架短剧。
|
||||
func FetchOperateList(c *gin.Context, q OperateListQuery) (*PlayletOperateListResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("operateType", strconv.Itoa(q.OperateType))
|
||||
params.Set("filterType", strconv.Itoa(q.FilterType))
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[PlayletOperateListResp]
|
||||
if err := upstream.GetPublicJSON(c, operateListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet operate list code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchOperateListOrEmpty 为 SSR 页面提供容错,接口异常时仍渲染完整页面骨架。
|
||||
func FetchOperateListOrEmpty(c *gin.Context, q OperateListQuery) *PlayletOperateListResp {
|
||||
q.Normalize()
|
||||
data, err := FetchOperateList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet operate list failed", log.E(err))
|
||||
return &PlayletOperateListResp{
|
||||
OperateType: q.OperateType,
|
||||
FilterType: q.FilterType,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletOperateListResp{
|
||||
OperateType: q.OperateType,
|
||||
FilterType: q.FilterType,
|
||||
PageNum: q.PageNum,
|
||||
PageSize: q.PageSize,
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
rankPath = "/api/app/playlet/rank"
|
||||
|
||||
defaultRankPageSize = 30
|
||||
|
||||
// 排行榜 sortOrder:1-热播榜,2-新剧榜,3-追剧榜
|
||||
rankSortHot = 1
|
||||
rankSortNew = 2
|
||||
rankSortBinge = 3
|
||||
)
|
||||
|
||||
// RankQuery 排行榜查询参数。
|
||||
type RankQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// Normalize 校正分页与榜单类型。
|
||||
func (q *RankQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultRankPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
switch q.SortOrder {
|
||||
case rankSortNew, rankSortBinge:
|
||||
default:
|
||||
q.SortOrder = rankSortHot
|
||||
}
|
||||
}
|
||||
|
||||
// FetchRank 分页查询短剧排行榜。
|
||||
func FetchRank(c *gin.Context, q RankQuery) (*PlayletVideoResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
|
||||
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetPublicJSON(c, rankPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet rank code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchRankOrEmpty 失败时返回空榜单。
|
||||
func FetchRankOrEmpty(c *gin.Context, q RankQuery) *PlayletVideoResp {
|
||||
data, err := FetchRank(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet rank failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func formatCollectHeat(n int64) string {
|
||||
if n <= 0 {
|
||||
return "0 收藏"
|
||||
}
|
||||
if n < 10000 {
|
||||
return fmt.Sprintf("%d 收藏", n)
|
||||
}
|
||||
v := float64(n) / 10000
|
||||
if v >= 100 {
|
||||
return fmt.Sprintf("%.0f万 收藏", v)
|
||||
}
|
||||
return fmt.Sprintf("%.1f万 收藏", v)
|
||||
}
|
||||
|
||||
func rankPageURL(sortOrder, pageNum int) string {
|
||||
switch sortOrder {
|
||||
case rankSortNew, rankSortBinge:
|
||||
default:
|
||||
sortOrder = rankSortHot
|
||||
}
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/rank"
|
||||
if sortOrder != rankSortHot {
|
||||
path += "/" + strconv.Itoa(sortOrder)
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
// BuildRankTabs 排行榜 Tab。
|
||||
func BuildRankTabs(q RankQuery) []FilterChipVM {
|
||||
q.Normalize()
|
||||
return []FilterChipVM{
|
||||
{Label: "热播榜", URL: rankPageURL(rankSortHot, 1), Active: q.SortOrder == rankSortHot},
|
||||
{Label: "新剧榜", URL: rankPageURL(rankSortNew, 1), Active: q.SortOrder == rankSortNew},
|
||||
{Label: "追剧榜", URL: rankPageURL(rankSortBinge, 1), Active: q.SortOrder == rankSortBinge},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildRankPagination 排行榜分页。
|
||||
func BuildRankPagination(q RankQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return rankPageURL(q.SortOrder, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// RankCanonicalPath 返回排行榜筛选和分页对应的唯一规范路径。
|
||||
func RankCanonicalPath(q RankQuery) string {
|
||||
q.Normalize()
|
||||
return rankPageURL(q.SortOrder, q.PageNum)
|
||||
}
|
||||
|
||||
// RankOffset 当前页起始排名偏移(0-based)。
|
||||
func RankOffset(q RankQuery) int {
|
||||
q.Normalize()
|
||||
return (q.PageNum - 1) * q.PageSize
|
||||
}
|
||||
|
||||
// ParseRankQuery 从 query 解析排行榜参数。
|
||||
func ParseRankQuery(pageStr, sortStr string) RankQuery {
|
||||
q := RankQuery{PageSize: defaultRankPageSize, SortOrder: rankSortHot}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(sortStr)); err == nil {
|
||||
q.SortOrder = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// IsRankBinge 是否追剧榜(热度展示用 countCollect)。
|
||||
func IsRankBinge(sortOrder int) bool {
|
||||
return sortOrder == rankSortBinge
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package playletser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRankCanonicalPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
q RankQuery
|
||||
want string
|
||||
}{
|
||||
{name: "default", q: RankQuery{}, want: "/rank/"},
|
||||
{name: "new", q: RankQuery{SortOrder: rankSortNew}, want: "/rank/2/"},
|
||||
{name: "paged", q: RankQuery{SortOrder: rankSortBinge, PageNum: 3}, want: "/rank/3/page/3/"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := RankCanonicalPath(tt.q); got != tt.want {
|
||||
t.Fatalf("RankCanonicalPath(%#v) = %q, want %q", tt.q, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
recommendListPath = "/api/app/playlet/list"
|
||||
defaultRecommendSize = 25
|
||||
)
|
||||
|
||||
// RecommendQuery 专题详情分页参数。
|
||||
type RecommendQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
SortOrder int
|
||||
ThemeID int64
|
||||
ThemeSlug string
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *RecommendQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultRecommendSize
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
if q.SortOrder != sortTodayUpdate {
|
||||
q.SortOrder = sortHeat
|
||||
}
|
||||
}
|
||||
|
||||
func recommendListURL(q RecommendQuery) string {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("sortOrder", strconv.Itoa(q.SortOrder))
|
||||
if slug := strings.TrimSpace(q.ThemeSlug); slug != "" {
|
||||
params.Set("themeSlug", slug)
|
||||
} else if q.ThemeID > 0 {
|
||||
params.Set("themeId", strconv.FormatInt(q.ThemeID, 10))
|
||||
}
|
||||
return recommendListPath + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// FetchRecommendList 按 Swagger 的题材 ID 或 slug 参数分页查询专题短剧。
|
||||
func FetchRecommendList(c *gin.Context, q RecommendQuery) (*PlayletVideoResp, error) {
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetPublicJSON(c, recommendListURL(q), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet recommend list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchRecommendListOrEmpty 失败时返回空列表。
|
||||
func FetchRecommendListOrEmpty(c *gin.Context, q RecommendQuery) *PlayletVideoResp {
|
||||
q.Normalize()
|
||||
data, err := FetchRecommendList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet recommend list failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func recommendPageURL(themeID int64, themeSlug string, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/topic"
|
||||
if segment := pathSegment(themeSlug, themeID); segment != "" {
|
||||
path += "/" + segment
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
func RecommendPagePath(q RecommendQuery) string {
|
||||
q.Normalize()
|
||||
return recommendPageURL(q.ThemeID, q.ThemeSlug, q.PageNum)
|
||||
}
|
||||
|
||||
// RecommendThemeTitle 专题页标题:优先题材名称,其次使用列表接口 SEO 标题。
|
||||
func RecommendThemeTitle(name, seoTitle string, themeID int64) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
seoTitle = strings.TrimSpace(seoTitle)
|
||||
if seoTitle != "" {
|
||||
return seoTitle
|
||||
}
|
||||
if themeID > 0 {
|
||||
return "专题短剧"
|
||||
}
|
||||
return "专题推荐"
|
||||
}
|
||||
|
||||
// RecommendCanonicalPath 推荐页 canonical(不含 page)。
|
||||
func RecommendCanonicalPath(themeID int64, themeSlug string) string {
|
||||
return recommendPageURL(themeID, themeSlug, 1)
|
||||
}
|
||||
|
||||
// BuildRecommendPagination 推荐列表分页。
|
||||
func BuildRecommendPagination(q RecommendQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return recommendPageURL(q.ThemeID, q.ThemeSlug, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// ParseRecommendQuery 从路由解析分页。
|
||||
func ParseRecommendQuery(pageStr string) RecommendQuery {
|
||||
q := RecommendQuery{PageSize: defaultRecommendSize, SortOrder: sortHeat}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/authser"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
searchPath = "/api/app/playlet/search"
|
||||
defaultSearchPageSize = 25
|
||||
)
|
||||
|
||||
// SearchQuery 搜索分页参数(keyword 对应上游 query)。
|
||||
type SearchQuery struct {
|
||||
Keyword string
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *SearchQuery) Normalize() {
|
||||
q.Keyword = strings.TrimSpace(q.Keyword)
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultSearchPageSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
}
|
||||
|
||||
// FetchSearch ES 分页搜索短剧。
|
||||
func FetchSearch(c *gin.Context, q SearchQuery) (*PlayletVideoResp, error) {
|
||||
q.Normalize()
|
||||
if q.Keyword == "" {
|
||||
return nil, fmt.Errorf("playlet search keyword is empty")
|
||||
}
|
||||
authser.EnsureGuestOnEnter(c)
|
||||
env, err := fetchSearchOnce(c, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 && authser.IsSessionExpiredCode(env.Code) {
|
||||
if authser.RecoverContentSession(c, env.Code) {
|
||||
env, err = fetchSearchOnce(c, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet search code=%d msg=%s tip=%s", env.Code, env.Msg, env.Tip)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
func fetchSearchOnce(c *gin.Context, q SearchQuery) (*APIEnvelope[PlayletVideoResp], error) {
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
params.Set("keyword", q.Keyword)
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetJSON(c, searchPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// FetchSearchOrEmpty 失败时返回空列表。
|
||||
func FetchSearchOrEmpty(c *gin.Context, q SearchQuery) *PlayletVideoResp {
|
||||
data, err := FetchSearch(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet search failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func searchPageURL(keyword string, pageNum int) string {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return "/search/"
|
||||
}
|
||||
path := "/search/" + url.PathEscape(keyword)
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
func SearchCanonicalPath(q SearchQuery) string {
|
||||
q.Normalize()
|
||||
return searchPageURL(q.Keyword, q.PageNum)
|
||||
}
|
||||
|
||||
// BuildSearchPagination 搜索分页。
|
||||
func BuildSearchPagination(q SearchQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return searchPageURL(q.Keyword, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// ParseSearchQuery 从站点 query 解析(q → keyword)。
|
||||
func ParseSearchQuery(qStr, pageStr string) SearchQuery {
|
||||
q := SearchQuery{PageSize: defaultSearchPageSize, Keyword: strings.TrimSpace(qStr)}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PageOutOfRange 判断分页是否越界(第 1 页始终合法,即使列表为空)。
|
||||
func PageOutOfRange(pageNum, pageSize int, totalCount int64) bool {
|
||||
if pageNum <= 1 {
|
||||
return false
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultListPageSize
|
||||
}
|
||||
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||
if totalPages < 1 {
|
||||
return true
|
||||
}
|
||||
return pageNum > totalPages
|
||||
}
|
||||
|
||||
// PageTitleSuffix 分页标题后缀:第 1 页为空,其余为「 - 第N页」。
|
||||
func PageTitleSuffix(pageNum int) string {
|
||||
if pageNum <= 1 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" - 第%d页", pageNum)
|
||||
}
|
||||
|
||||
// PaginatedTitle 为第 2 页及以后追加页码,并避免重复追加。
|
||||
func PaginatedTitle(title string, pageNum int) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || pageNum <= 1 {
|
||||
return title
|
||||
}
|
||||
marker := fmt.Sprintf("第%d页", pageNum)
|
||||
if strings.Contains(title, marker) {
|
||||
return title
|
||||
}
|
||||
return title + PageTitleSuffix(pageNum)
|
||||
}
|
||||
|
||||
// PaginateSeoInfo 统一生成分页页的唯一 title 与 description。
|
||||
// fallbackDescription 仅在上游未提供 description 时使用。
|
||||
func PaginateSeoInfo(base SeoInfo, fallbackDescription string, pageNum int) SeoInfo {
|
||||
base.Normalize()
|
||||
if pageNum <= 1 {
|
||||
return base
|
||||
}
|
||||
base.Title = PaginatedTitle(base.Title, pageNum)
|
||||
if base.Description == "" {
|
||||
base.Description = strings.TrimSpace(fallbackDescription)
|
||||
}
|
||||
if base.Description != "" {
|
||||
marker := fmt.Sprintf("第%d页", pageNum)
|
||||
if !strings.Contains(base.Description, marker) {
|
||||
base.Description = strings.TrimRight(base.Description, "。;; ") + " - " + marker
|
||||
}
|
||||
}
|
||||
base.Normalize()
|
||||
return base
|
||||
}
|
||||
|
||||
// UnmarshalJSON 兼容 swagger proto.SeoInfo,并容忍上游别名 / 字符串 keywords。
|
||||
// 主字段:title、description、keywords[]、canonicalUrl。
|
||||
func (s *SeoInfo) UnmarshalJSON(raw []byte) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '"' {
|
||||
var encoded string
|
||||
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded = strings.TrimSpace(encoded)
|
||||
if encoded == "" {
|
||||
return nil
|
||||
}
|
||||
inner := bytes.TrimSpace([]byte(encoded))
|
||||
if len(inner) == 0 || inner[0] != '{' {
|
||||
return nil
|
||||
}
|
||||
return s.UnmarshalJSON(inner)
|
||||
}
|
||||
var aux struct {
|
||||
Title string `json:"title"`
|
||||
WebTitle string `json:"webTitle"`
|
||||
Description string `json:"description"`
|
||||
WebDescription string `json:"webDescription"`
|
||||
CanonicalURL string `json:"canonicalUrl"`
|
||||
Canonical string `json:"canonical"`
|
||||
WebSiteName string `json:"webSiteName"`
|
||||
Keywords json.RawMessage `json:"keywords"`
|
||||
Keyworld json.RawMessage `json:"keyworld"`
|
||||
WebKeyworld json.RawMessage `json:"webKeyworld"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Title = firstNonEmpty(aux.Title, aux.WebTitle)
|
||||
s.Description = firstNonEmpty(aux.Description, aux.WebDescription)
|
||||
s.CanonicalURL = firstNonEmpty(aux.CanonicalURL, aux.Canonical)
|
||||
s.WebSiteName = strings.TrimSpace(aux.WebSiteName)
|
||||
s.Keywords = decodeKeywordList(aux.Keywords, aux.Keyworld, aux.WebKeyworld)
|
||||
s.Normalize()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize 去掉 seoInfo 空白项,保证模板可直接渲染。
|
||||
func (s *SeoInfo) Normalize() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.Title = strings.TrimSpace(s.Title)
|
||||
s.Description = strings.TrimSpace(s.Description)
|
||||
s.CanonicalURL = strings.TrimSpace(s.CanonicalURL)
|
||||
s.WebSiteName = strings.TrimSpace(s.WebSiteName)
|
||||
if len(s.Keywords) == 0 {
|
||||
return
|
||||
}
|
||||
out := make([]string, 0, len(s.Keywords))
|
||||
seen := make(map[string]struct{}, len(s.Keywords))
|
||||
for _, k := range s.Keywords {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, k)
|
||||
}
|
||||
s.Keywords = out
|
||||
}
|
||||
|
||||
func decodeKeywordList(vals ...json.RawMessage) []string {
|
||||
for _, raw := range vals {
|
||||
if items := parseKeywordsRaw(raw); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseKeywordsRaw(raw json.RawMessage) []string {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return nil
|
||||
}
|
||||
switch raw[0] {
|
||||
case '"':
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return nil
|
||||
}
|
||||
return splitKeywordText(s)
|
||||
case '[':
|
||||
var anyItems []any
|
||||
if err := json.Unmarshal(raw, &anyItems); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(anyItems))
|
||||
for _, it := range anyItems {
|
||||
s, ok := it.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func splitKeywordText(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
s = strings.NewReplacer(",", ",", "、", ",", ";", ",", ";", ",").Replace(s)
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinStrings(items []string, sep string) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, s := range items {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, sep)
|
||||
}
|
||||
|
||||
// MergeSeoInfo 按字段合并 SEO:优先 primary,缺失时回退 fallback。
|
||||
func MergeSeoInfo(primary, fallback SeoInfo) SeoInfo {
|
||||
out := SeoInfo{
|
||||
Title: firstNonEmpty(primary.Title, fallback.Title),
|
||||
Description: firstNonEmpty(primary.Description, fallback.Description),
|
||||
CanonicalURL: firstNonEmpty(primary.CanonicalURL, fallback.CanonicalURL),
|
||||
WebSiteName: firstNonEmpty(primary.WebSiteName, fallback.WebSiteName),
|
||||
}
|
||||
if len(primary.Keywords) > 0 {
|
||||
out.Keywords = append([]string(nil), primary.Keywords...)
|
||||
} else {
|
||||
out.Keywords = append([]string(nil), fallback.Keywords...)
|
||||
}
|
||||
out.Normalize()
|
||||
return out
|
||||
}
|
||||
|
||||
// SEOSelection 描述页面当前选中的筛选维度,用于生成差异化 TDK。
|
||||
type SEOSelection struct {
|
||||
Dimension string
|
||||
Value string
|
||||
}
|
||||
|
||||
// DifferentiateSeoInfo 按基础 TDK - 选项1 - 选项2 的顺序拼接筛选条件。
|
||||
func DifferentiateSeoInfo(base SeoInfo, selections []SEOSelection) SeoInfo {
|
||||
base.Normalize()
|
||||
normalized := make([]SEOSelection, 0, len(selections))
|
||||
seen := make(map[string]struct{}, len(selections))
|
||||
for _, selection := range selections {
|
||||
dimension := strings.TrimSpace(selection.Dimension)
|
||||
value := strings.TrimSpace(selection.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
key := dimension + "\x00" + value
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, SEOSelection{Dimension: dimension, Value: value})
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(normalized))
|
||||
keywords := append([]string(nil), base.Keywords...)
|
||||
for _, selection := range normalized {
|
||||
values = append(values, selection.Value)
|
||||
keywords = append(keywords, selection.Value)
|
||||
}
|
||||
base.Title = joinSEOParts(base.Title, values)
|
||||
base.Description = joinSEOParts(base.Description, values)
|
||||
base.Keywords = keywords
|
||||
base.Normalize()
|
||||
return base
|
||||
}
|
||||
|
||||
func joinSEOParts(base string, values []string) string {
|
||||
parts := make([]string, 0, len(values)+1)
|
||||
if base = strings.TrimSpace(base); base != "" {
|
||||
parts = append(parts, base)
|
||||
}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
parts = append(parts, value)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " - ")
|
||||
}
|
||||
|
||||
// ResolvePlayPageSEO 播放页 SEO:章节详情 seoInfo 优先,短剧详情作字段级回退。
|
||||
func ResolvePlayPageSEO(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) SeoInfo {
|
||||
var chapterSEO, playletSEO SeoInfo
|
||||
if chapter != nil {
|
||||
chapterSEO = chapter.SeoInfo
|
||||
}
|
||||
if playlet != nil {
|
||||
playletSEO = playlet.SeoInfo
|
||||
}
|
||||
return MergeSeoInfo(chapterSEO, playletSEO)
|
||||
}
|
||||
|
||||
// ResolvePlayPageTitle 播放页标题:优先章节 seoInfo.title,否则短剧名 + 集数。
|
||||
func ResolvePlayPageTitle(chapter *PlayletChapterInfoResp, playlet *PlayletInfoResp) string {
|
||||
seo := ResolvePlayPageSEO(chapter, playlet)
|
||||
if seo.Title != "" {
|
||||
return seo.Title
|
||||
}
|
||||
title := "播放"
|
||||
if playlet != nil && playlet.Title != "" {
|
||||
title = playlet.Title
|
||||
}
|
||||
if chapter != nil && chapter.CurrentEpisode > 0 {
|
||||
return fmt.Sprintf("%s - 第%d集", title, chapter.CurrentEpisode)
|
||||
}
|
||||
return title
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSeoInfo_UnmarshalJSON_SwaggerShape(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"title":"灵气复苏,我成为了万古医圣",
|
||||
"keywords":["灵气复苏,我成为了万古医圣","职场"],
|
||||
"description":"灵气复苏,我成为了万古医圣",
|
||||
"canonicalUrl":"/detail/example-show"
|
||||
}`)
|
||||
var seo SeoInfo
|
||||
if err := json.Unmarshal(raw, &seo); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if seo.Title != "灵气复苏,我成为了万古医圣" {
|
||||
t.Fatalf("title=%q", seo.Title)
|
||||
}
|
||||
if seo.Description != "灵气复苏,我成为了万古医圣" {
|
||||
t.Fatalf("description=%q", seo.Description)
|
||||
}
|
||||
if seo.CanonicalURL != "/detail/example-show" {
|
||||
t.Fatalf("canonicalUrl=%q", seo.CanonicalURL)
|
||||
}
|
||||
want := []string{"灵气复苏,我成为了万古医圣", "职场"}
|
||||
if !reflect.DeepEqual(seo.Keywords, want) {
|
||||
t.Fatalf("keywords=%v", seo.Keywords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeoInfo_UnmarshalJSON_AliasesAndStringKeywords(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"webTitle":"镇龙",
|
||||
"webDescription":"短剧简介",
|
||||
"keyworld":"镇龙,玄幻",
|
||||
"canonical":"/detail/example-show"
|
||||
}`)
|
||||
var seo SeoInfo
|
||||
if err := json.Unmarshal(raw, &seo); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if seo.Title != "镇龙" || seo.Description != "短剧简介" {
|
||||
t.Fatalf("got title=%q desc=%q", seo.Title, seo.Description)
|
||||
}
|
||||
if seo.CanonicalURL != "/detail/example-show" {
|
||||
t.Fatalf("canonicalUrl=%q", seo.CanonicalURL)
|
||||
}
|
||||
want := []string{"镇龙", "玄幻"}
|
||||
if !reflect.DeepEqual(seo.Keywords, want) {
|
||||
t.Fatalf("keywords=%v", seo.Keywords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEnvelope_UnmarshalJSON_ObjectData(t *testing.T) {
|
||||
raw := []byte(`{"code":200,"msg":"ok","data":{"id":10741,"title":"都市逍遥神医","seoInfo":""}}`)
|
||||
var env APIEnvelope[PlayletInfoResp]
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Code != 200 || env.Data.ID != 10741 || env.Data.Title != "都市逍遥神医" {
|
||||
t.Fatalf("env=%+v data=%+v", env, env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEnvelope_UnmarshalJSON_StringEncodedData(t *testing.T) {
|
||||
raw := []byte(`{"code":200,"msg":"ok","data":"{\"id\":10741,\"title\":\"都市逍遥神医\",\"seoInfo\":\"\"}"}`)
|
||||
var env APIEnvelope[PlayletInfoResp]
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Data.ID != 10741 || env.Data.Title != "都市逍遥神医" {
|
||||
t.Fatalf("data=%+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEnvelope_UnmarshalJSON_EmptyStringData(t *testing.T) {
|
||||
raw := []byte(`{"code":200,"msg":"ok","data":""}`)
|
||||
var env APIEnvelope[PlayletInfoResp]
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if env.Data.ID != 0 {
|
||||
t.Fatalf("data=%+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletInfoResp_SeoInfoEmptyString(t *testing.T) {
|
||||
raw := []byte(`{"id":10741,"title":"都市逍遥神医","seoInfo":""}`)
|
||||
var info PlayletInfoResp
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if info.ID != 10741 || info.Title != "都市逍遥神医" {
|
||||
t.Fatalf("info=%+v", info)
|
||||
}
|
||||
if info.SeoInfo.Title != "" {
|
||||
t.Fatalf("seoInfo=%+v", info.SeoInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletInfoResp_KeepsSeoInfo(t *testing.T) {
|
||||
raw := []byte(`{"id":10619,"slug":"example-show","title":"剧名","seoInfo":{"title":"SEO标题","description":"SEO描述","keywords":["a","b"],"canonicalUrl":"/detail/example-show"}}`)
|
||||
var info PlayletInfoResp
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if info.SeoInfo.Title != "SEO标题" || info.SeoInfo.CanonicalURL != "/detail/example-show" {
|
||||
t.Fatalf("seoInfo=%+v", info.SeoInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletChapterInfoResp_KeepsSeoInfo(t *testing.T) {
|
||||
raw := []byte(`{"id":12743,"playletId":10619,"currentEpisode":1,"title":"1","seoInfo":{"title":"剧名 1","description":"剧名","keywords":["剧名","短剧"],"canonicalUrl":"/play/example-show/12743"}}`)
|
||||
var info PlayletChapterInfoResp
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if info.SeoInfo.Title != "剧名 1" || info.SeoInfo.CanonicalURL != "/play/example-show/12743" {
|
||||
t.Fatalf("seoInfo=%+v", info.SeoInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlayPageSEO_PrefersChapter(t *testing.T) {
|
||||
chapter := &PlayletChapterInfoResp{
|
||||
CurrentEpisode: 1,
|
||||
SeoInfo: SeoInfo{
|
||||
Title: "章节标题",
|
||||
Description: "章节描述",
|
||||
Keywords: []string{"章节"},
|
||||
CanonicalURL: "/play/example-show/2",
|
||||
},
|
||||
}
|
||||
playlet := &PlayletInfoResp{
|
||||
Title: "短剧名",
|
||||
SeoInfo: SeoInfo{
|
||||
Title: "短剧SEO",
|
||||
Description: "短剧描述",
|
||||
Keywords: []string{"短剧"},
|
||||
CanonicalURL: "/detail/example-show",
|
||||
},
|
||||
}
|
||||
seo := ResolvePlayPageSEO(chapter, playlet)
|
||||
if seo.Title != "章节标题" || seo.CanonicalURL != "/play/example-show/2" {
|
||||
t.Fatalf("seo=%+v", seo)
|
||||
}
|
||||
if !reflect.DeepEqual(seo.Keywords, []string{"章节"}) {
|
||||
t.Fatalf("keywords=%v", seo.Keywords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlayPageTitle_UsesChapterSeoTitle(t *testing.T) {
|
||||
chapter := &PlayletChapterInfoResp{
|
||||
CurrentEpisode: 1,
|
||||
SeoInfo: SeoInfo{Title: "灵气复苏,我成为了万古医圣 1"},
|
||||
}
|
||||
playlet := &PlayletInfoResp{Title: "灵气复苏,我成为了万古医圣"}
|
||||
if got := ResolvePlayPageTitle(chapter, playlet); got != "灵气复苏,我成为了万古医圣 1" {
|
||||
t.Fatalf("title=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
seriesOperateType = 2
|
||||
seriesPageSize = 25
|
||||
seriesFilterLatest = 1
|
||||
seriesFilterHot = 2
|
||||
seriesFilterOrigin = 3
|
||||
)
|
||||
|
||||
var seriesFilters = []struct {
|
||||
Type int
|
||||
Slug string
|
||||
Label string
|
||||
}{
|
||||
{Type: seriesFilterLatest, Slug: "latest", Label: "最新更新"},
|
||||
{Type: seriesFilterHot, Slug: "popular", Label: "当前热播"},
|
||||
{Type: seriesFilterOrigin, Slug: "original", Label: "独家原创"},
|
||||
}
|
||||
|
||||
// ParseSeriesQuery 将语义化筛选路由转换为 Swagger 的 operateType/filterType 参数。
|
||||
func ParseSeriesQuery(filterSlug, pageStr string) (OperateListQuery, bool) {
|
||||
filterType, ok := seriesFilterType(filterSlug)
|
||||
if !ok {
|
||||
return OperateListQuery{}, false
|
||||
}
|
||||
q := OperateListQuery{
|
||||
OperateType: seriesOperateType,
|
||||
FilterType: filterType,
|
||||
PageSize: seriesPageSize,
|
||||
}
|
||||
if page, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = page
|
||||
}
|
||||
q.Normalize()
|
||||
return q, true
|
||||
}
|
||||
|
||||
func seriesFilterType(slug string) (int, bool) {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug == "" || slug == "latest" {
|
||||
return seriesFilterLatest, true
|
||||
}
|
||||
for _, filter := range seriesFilters {
|
||||
if filter.Slug == slug {
|
||||
return filter.Type, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func seriesFilterSlug(filterType int) string {
|
||||
for _, filter := range seriesFilters {
|
||||
if filter.Type == filterType {
|
||||
return filter.Slug
|
||||
}
|
||||
}
|
||||
return "latest"
|
||||
}
|
||||
|
||||
func seriesPageURL(filterType, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/series"
|
||||
if filterType != seriesFilterLatest {
|
||||
path += "/" + seriesFilterSlug(filterType)
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
// SeriesCanonicalPath 返回 AI 成人剧集页唯一规范路径。
|
||||
func SeriesCanonicalPath(q OperateListQuery) string {
|
||||
q.Normalize()
|
||||
return seriesPageURL(q.FilterType, q.PageNum)
|
||||
}
|
||||
|
||||
// BuildSeriesFilterTabs 构建 Figma 中的三个运营筛选项。
|
||||
func BuildSeriesFilterTabs(q OperateListQuery) []FilterChipVM {
|
||||
q.Normalize()
|
||||
out := make([]FilterChipVM, 0, len(seriesFilters))
|
||||
for _, filter := range seriesFilters {
|
||||
out = append(out, FilterChipVM{
|
||||
Label: filter.Label,
|
||||
URL: seriesPageURL(filter.Type, 1),
|
||||
Active: q.FilterType == filter.Type,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func SeriesSectionTitle(filterType int) string {
|
||||
for _, filter := range seriesFilters {
|
||||
if filter.Type == filterType {
|
||||
return filter.Label + "的AI成人剧集"
|
||||
}
|
||||
}
|
||||
return "最新更新的AI成人剧集"
|
||||
}
|
||||
|
||||
func BuildSeriesPagination(q OperateListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(page int) string {
|
||||
return seriesPageURL(q.FilterType, page)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// SeriesPathFromLegacyList 将原 /list/ai-series 排序地址映射到新页面。
|
||||
func SeriesPathFromLegacyList(sortOrder, pageNum int) string {
|
||||
filterType := seriesFilterLatest
|
||||
if sortOrder == sortHeat {
|
||||
filterType = seriesFilterHot
|
||||
}
|
||||
return seriesPageURL(filterType, pageNum)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package playletser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseSeriesQueryMapsSwaggerOperateFilters(t *testing.T) {
|
||||
tests := []struct {
|
||||
slug string
|
||||
wantFilter int
|
||||
}{
|
||||
{slug: "", wantFilter: seriesFilterLatest},
|
||||
{slug: "latest", wantFilter: seriesFilterLatest},
|
||||
{slug: "popular", wantFilter: seriesFilterHot},
|
||||
{slug: "original", wantFilter: seriesFilterOrigin},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
q, ok := ParseSeriesQuery(tt.slug, "2")
|
||||
if !ok {
|
||||
t.Fatalf("ParseSeriesQuery(%q) rejected valid filter", tt.slug)
|
||||
}
|
||||
if q.OperateType != 2 || q.FilterType != tt.wantFilter || q.PageNum != 2 || q.PageSize != 25 {
|
||||
t.Fatalf("ParseSeriesQuery(%q) = %#v", tt.slug, q)
|
||||
}
|
||||
}
|
||||
if _, ok := ParseSeriesQuery("unknown", "1"); ok {
|
||||
t.Fatal("unknown filter should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesCanonicalPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
filter int
|
||||
page int
|
||||
want string
|
||||
}{
|
||||
{filter: seriesFilterLatest, page: 1, want: "/series"},
|
||||
{filter: seriesFilterLatest, page: 3, want: "/series/page/3"},
|
||||
{filter: seriesFilterHot, page: 1, want: "/series/popular"},
|
||||
{filter: seriesFilterOrigin, page: 4, want: "/series/original/page/4"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := seriesPageURL(tt.filter, tt.page)
|
||||
if got != tt.want {
|
||||
t.Fatalf("seriesPageURL(%d, %d) = %q, want %q", tt.filter, tt.page, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSeriesFilterTabs(t *testing.T) {
|
||||
tabs := BuildSeriesFilterTabs(OperateListQuery{OperateType: 2, FilterType: 2, PageNum: 1, PageSize: 25})
|
||||
if len(tabs) != 3 || tabs[0].URL != "/series" || tabs[1].URL != "/series/popular" || !tabs[1].Active || tabs[2].URL != "/series/original" {
|
||||
t.Fatalf("unexpected series tabs: %#v", tabs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
shortPlayOperateType = 1
|
||||
shortPlayPageSize = 25
|
||||
)
|
||||
|
||||
var shortPlayFilters = []struct {
|
||||
Type int
|
||||
Slug string
|
||||
Label string
|
||||
}{
|
||||
{Type: 1, Slug: "latest", Label: "最新更新"},
|
||||
{Type: 2, Slug: "popular", Label: "当前热播"},
|
||||
{Type: 3, Slug: "original", Label: "独家原创"},
|
||||
}
|
||||
|
||||
// ParseShortPlayQuery 将短剧页路由转换为 operate/list 的 Swagger 参数。
|
||||
func ParseShortPlayQuery(filter, page string) (OperateListQuery, bool) {
|
||||
filterType, ok := shortPlayFilterType(filter)
|
||||
if !ok {
|
||||
return OperateListQuery{}, false
|
||||
}
|
||||
q := OperateListQuery{
|
||||
OperateType: shortPlayOperateType,
|
||||
FilterType: filterType,
|
||||
PageSize: shortPlayPageSize,
|
||||
}
|
||||
if pageNum, err := strconv.Atoi(strings.TrimSpace(page)); err == nil {
|
||||
q.PageNum = pageNum
|
||||
}
|
||||
q.Normalize()
|
||||
return q, true
|
||||
}
|
||||
|
||||
func shortPlayFilterType(filter string) (int, bool) {
|
||||
filter = strings.TrimSpace(filter)
|
||||
switch filter {
|
||||
case "", "latest", "2":
|
||||
return 1, true
|
||||
case "popular", "1":
|
||||
return 2, true
|
||||
case "original", "3":
|
||||
return 3, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func shortPlayFilterSlug(filterType int) string {
|
||||
for _, filter := range shortPlayFilters {
|
||||
if filter.Type == filterType {
|
||||
return filter.Slug
|
||||
}
|
||||
}
|
||||
return "latest"
|
||||
}
|
||||
|
||||
func shortPlayPageURL(filterType, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/list/ai-short-play"
|
||||
if filterType != 1 || pageNum > 1 {
|
||||
path += "/" + shortPlayFilterSlug(filterType)
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
func ShortPlayCanonicalPath(q OperateListQuery) string {
|
||||
q.Normalize()
|
||||
return shortPlayPageURL(q.FilterType, q.PageNum)
|
||||
}
|
||||
|
||||
func BuildShortPlayFilterTabs(q OperateListQuery) []FilterChipVM {
|
||||
q.Normalize()
|
||||
out := make([]FilterChipVM, 0, len(shortPlayFilters))
|
||||
for _, filter := range shortPlayFilters {
|
||||
out = append(out, FilterChipVM{
|
||||
Label: filter.Label,
|
||||
URL: shortPlayPageURL(filter.Type, 1),
|
||||
Active: q.FilterType == filter.Type,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ShortPlaySectionTitle(filterType int) string {
|
||||
for _, filter := range shortPlayFilters {
|
||||
if filter.Type == filterType {
|
||||
return filter.Label + "的AI成人短剧"
|
||||
}
|
||||
}
|
||||
return "最新更新的AI成人短剧"
|
||||
}
|
||||
|
||||
func BuildShortPlayPagination(q OperateListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(page int) string {
|
||||
return shortPlayPageURL(q.FilterType, page)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHomeResponseDecodesDocumentedFields(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"announcements":[{"id":1,"title":"公告","content":"内容"}],
|
||||
"homeRecommendThemes":[{"id":2,"name":"都市","slug":"urban","playletCount":8}],
|
||||
"homeBannerData":[{"id":3,"name":"轮播A","image":"https://cdn.example.com/a.png","verticalImage":"https://cdn.example.com/a-v.png","url":"https://example.com","rank":1,"status":true}]
|
||||
}`)
|
||||
var got HomePlayletResponse
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Announcements) != 1 || got.Announcements[0].Content != "内容" {
|
||||
t.Fatalf("announcements not decoded: %#v", got.Announcements)
|
||||
}
|
||||
if len(got.HomeRecommendThemes) != 1 || got.HomeRecommendThemes[0].Slug != "urban" {
|
||||
t.Fatalf("recommended themes not decoded: %#v", got.HomeRecommendThemes)
|
||||
}
|
||||
if len(got.HomeBannerData) != 1 || got.HomeBannerData[0].Name != "轮播A" || got.HomeBannerData[0].Image == "" || got.HomeBannerData[0].VerticalImage == "" || !got.HomeBannerData[0].Status {
|
||||
t.Fatalf("banner additions not decoded: %#v", got.HomeBannerData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperateListQueryNormalize(t *testing.T) {
|
||||
q := OperateListQuery{OperateType: 9, FilterType: -1, PageNum: 0, PageSize: 999}
|
||||
q.Normalize()
|
||||
if q.OperateType != 1 || q.FilterType != 1 || q.PageNum != 1 || q.PageSize != 100 {
|
||||
t.Fatalf("unexpected normalized query: %#v", q)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
tagListPath = "/api/app/playlet/tag/list"
|
||||
defaultTagSize = 30
|
||||
)
|
||||
|
||||
type TagListQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
TagID int64
|
||||
TagSlug string
|
||||
}
|
||||
|
||||
func (q *TagListQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultTagSize
|
||||
}
|
||||
if q.PageSize > 100 {
|
||||
q.PageSize = 100
|
||||
}
|
||||
if q.TagID < 0 {
|
||||
q.TagID = 0
|
||||
}
|
||||
}
|
||||
|
||||
func FetchTagList(c *gin.Context, q TagListQuery) (*PlayletVideoResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
if slug := strings.TrimSpace(q.TagSlug); slug != "" {
|
||||
params.Set("tagSlug", slug)
|
||||
} else if q.TagID > 0 {
|
||||
params.Set("tagId", strconv.FormatInt(q.TagID, 10))
|
||||
}
|
||||
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetPublicJSON(c, tagListPath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet tag list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
func FetchTagListOrEmpty(c *gin.Context, q TagListQuery) *PlayletVideoResp {
|
||||
data, err := FetchTagList(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet tag list failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func tagListPageURL(tagID int64, tagSlug string, pageNum int) string {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
path := "/tag"
|
||||
if segment := pathSegment(tagSlug, tagID); segment != "" {
|
||||
path += "/" + segment
|
||||
}
|
||||
if pageNum > 1 {
|
||||
path += "/page/" + strconv.Itoa(pageNum)
|
||||
}
|
||||
return path + "/"
|
||||
}
|
||||
|
||||
func tagListURL(item CommonData) string {
|
||||
return cardTag(item).URL
|
||||
}
|
||||
|
||||
func tagListURLByKey(slug string, id int64) string {
|
||||
if segment := pathSegment(slug, id); segment != "" {
|
||||
return "/tag/" + segment + "/"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func themeListURLByKey(slug string, id int64) string {
|
||||
if segment := pathSegment(slug, id); segment != "" {
|
||||
return "/list/" + segment + "/"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type NamedTag struct {
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
func cardTag(item CommonData) NamedTag {
|
||||
tags := cardTags(item)
|
||||
if len(tags) > 0 {
|
||||
return tags[0]
|
||||
}
|
||||
return NamedTag{}
|
||||
}
|
||||
|
||||
// cardTags returns at most three distinct labels for a poster card. Regular
|
||||
// tags keep their API order, followed by theme labels when space remains.
|
||||
func cardTags(item CommonData) []NamedTag {
|
||||
out := make([]NamedTag, 0, 3)
|
||||
seen := make(map[string]struct{}, 3)
|
||||
appendUnique := func(tags []NamedTag) {
|
||||
for _, tag := range tags {
|
||||
name := strings.TrimSpace(tag.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
tag.Name = name
|
||||
out = append(out, tag)
|
||||
seen[key] = struct{}{}
|
||||
if len(out) == 3 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendUnique(namedTags(item.TagIDs, item.TagNames, item.TagSlugs))
|
||||
if len(out) < 3 {
|
||||
appendUnique(namedThemeTags(item.ThemeIDs, item.ThemeNames, item.ThemeSlugs))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func namedTags(ids []int64, names, slugs []string) []NamedTag {
|
||||
out := make([]NamedTag, 0, len(names))
|
||||
for i, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
item := NamedTag{Name: name}
|
||||
id := int64(0)
|
||||
slug := ""
|
||||
if i < len(ids) {
|
||||
id = ids[i]
|
||||
}
|
||||
if i < len(slugs) {
|
||||
slug = slugs[i]
|
||||
}
|
||||
item.URL = tagListURLByKey(slug, id)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func namedThemeTags(ids []int64, names, slugs []string) []NamedTag {
|
||||
out := make([]NamedTag, 0, len(names))
|
||||
for i, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
item := NamedTag{Name: name}
|
||||
if i < len(ids) {
|
||||
slug := ""
|
||||
if i < len(slugs) {
|
||||
slug = slugs[i]
|
||||
}
|
||||
item.URL = themeListURLByKey(slug, ids[i])
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TagListCanonicalPath(q TagListQuery) string {
|
||||
q.Normalize()
|
||||
return tagListPageURL(q.TagID, q.TagSlug, q.PageNum)
|
||||
}
|
||||
|
||||
func BuildTagListPagination(q TagListQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return tagListPageURL(q.TagID, q.TagSlug, p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
func ParseTagListQuery(pageStr string) TagListQuery {
|
||||
q := TagListQuery{PageSize: defaultTagSize}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
func FindTagNameBySlug(list []CommonData, slug string) string {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug == "" {
|
||||
return ""
|
||||
}
|
||||
for _, item := range list {
|
||||
for i, itemSlug := range item.TagSlugs {
|
||||
if strings.TrimSpace(itemSlug) == slug && i < len(item.TagNames) {
|
||||
return strings.TrimSpace(item.TagNames[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func FindTagName(list []CommonData, tagID int64) string {
|
||||
if tagID <= 0 {
|
||||
return ""
|
||||
}
|
||||
for _, item := range list {
|
||||
for i, id := range item.TagIDs {
|
||||
if id == tagID && i < len(item.TagNames) {
|
||||
return strings.TrimSpace(item.TagNames[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func FindTagSlug(list []CommonData, tagID int64) string {
|
||||
if tagID <= 0 {
|
||||
return ""
|
||||
}
|
||||
for _, item := range list {
|
||||
for i, id := range item.TagIDs {
|
||||
if id == tagID && i < len(item.TagSlugs) {
|
||||
return strings.TrimSpace(item.TagSlugs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TagListPageTitle(tagName string, pageNum int, seo SeoInfo) string {
|
||||
if title := strings.TrimSpace(seo.Title); title != "" {
|
||||
return title
|
||||
}
|
||||
base := "标签短剧"
|
||||
if tagName != "" {
|
||||
base = tagName + "短剧"
|
||||
}
|
||||
return base + PageTitleSuffix(pageNum)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package playletser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTagListPageURLUsesSemanticSlugRoute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id int64
|
||||
slug string
|
||||
page int
|
||||
want string
|
||||
}{
|
||||
{name: "root", page: 1, want: "/tag"},
|
||||
{name: "slug", id: 99, slug: "urban-love", page: 1, want: "/tag/urban-love"},
|
||||
{name: "slug page", id: 99, slug: "urban-love", page: 3, want: "/tag/urban-love/page/3"},
|
||||
{name: "id fallback", id: 99, page: 1, want: "/tag/99"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tagListPageURL(tt.id, tt.slug, tt.page); got != tt.want {
|
||||
t.Fatalf("tagListPageURL(%d, %q, %d) = %q, want %q", tt.id, tt.slug, tt.page, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagListURLByKeyPrefersSlugAndFallsBackToID(t *testing.T) {
|
||||
if got := tagListURLByKey("fantasy", 12); got != "/tag/fantasy" {
|
||||
t.Fatalf("slug URL = %q", got)
|
||||
}
|
||||
if got := tagListURLByKey("", 12); got != "/tag/12" {
|
||||
t.Fatalf("id fallback URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardTagsReturnsAtMostThreeDistinctLabels(t *testing.T) {
|
||||
item := CommonData{
|
||||
TagIDs: []int64{1, 2, 3},
|
||||
TagNames: []string{"都市", "甜宠", "都市"},
|
||||
TagSlugs: []string{"city", "sweet", "city-copy"},
|
||||
ThemeIDs: []int64{4, 5},
|
||||
ThemeNames: []string{"逆袭", "复仇"},
|
||||
ThemeSlugs: []string{"counterattack", "revenge"},
|
||||
}
|
||||
|
||||
got := cardTags(item)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("cardTags length = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].Name != "都市" || got[0].URL != "/tag/city" {
|
||||
t.Fatalf("first tag = %#v", got[0])
|
||||
}
|
||||
if got[1].Name != "甜宠" || got[1].URL != "/tag/sweet" {
|
||||
t.Fatalf("second tag = %#v", got[1])
|
||||
}
|
||||
if got[2].Name != "逆袭" || got[2].URL != "/list/counterattack" {
|
||||
t.Fatalf("third tag = %#v", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedTagsUsesSlugWithoutID(t *testing.T) {
|
||||
got := namedTags(nil, []string{"甜宠"}, []string{"sweet-love"})
|
||||
if len(got) != 1 || got[0].URL != "/tag/sweet-love" {
|
||||
t.Fatalf("namedTags slug-only URL = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultThemeIndexPageSize = 20
|
||||
|
||||
// ThemeIndexQuery 专题列表页分页参数。
|
||||
type ThemeIndexQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (q *ThemeIndexQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultThemeIndexPageSize
|
||||
}
|
||||
}
|
||||
|
||||
// ParseThemeIndexQuery 从语义化分页路由读取页码。
|
||||
func ParseThemeIndexQuery(page string) ThemeIndexQuery {
|
||||
q := ThemeIndexQuery{PageSize: defaultThemeIndexPageSize}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(page)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
// BuildThemeIndexPage 过滤已启用专题,并按设计稿每页 20 项分页。
|
||||
func BuildThemeIndexPage(themes []PlayletThemeItem, q ThemeIndexQuery) ([]PlayletThemeItem, int64) {
|
||||
q.Normalize()
|
||||
visible := make([]PlayletThemeItem, 0, len(themes))
|
||||
for _, theme := range themes {
|
||||
if !theme.Status || theme.ID <= 0 || strings.TrimSpace(theme.Name) == "" {
|
||||
continue
|
||||
}
|
||||
visible = append(visible, theme)
|
||||
}
|
||||
total := int64(len(visible))
|
||||
start := (q.PageNum - 1) * q.PageSize
|
||||
if start >= len(visible) {
|
||||
return nil, total
|
||||
}
|
||||
end := start + q.PageSize
|
||||
if end > len(visible) {
|
||||
end = len(visible)
|
||||
}
|
||||
return visible[start:end], total
|
||||
}
|
||||
|
||||
func themeIndexPageURL(page int) string {
|
||||
if page <= 1 {
|
||||
return "/topic/"
|
||||
}
|
||||
return "/topic/page/" + strconv.Itoa(page) + "/"
|
||||
}
|
||||
|
||||
func ThemeIndexCanonicalPath(q ThemeIndexQuery) string {
|
||||
q.Normalize()
|
||||
return themeIndexPageURL(q.PageNum)
|
||||
}
|
||||
|
||||
func BuildThemeIndexPagination(q ThemeIndexQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(themeIndexPageURL, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package playletser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildThemeIndexPage(t *testing.T) {
|
||||
themes := make([]PlayletThemeItem, 0, 23)
|
||||
for i := 1; i <= 23; i++ {
|
||||
themes = append(themes, PlayletThemeItem{ID: int64(i), Name: "专题", Status: true})
|
||||
}
|
||||
themes[1].Status = false
|
||||
themes[2].Name = ""
|
||||
|
||||
first, total := BuildThemeIndexPage(themes, ParseThemeIndexQuery("1"))
|
||||
if total != 21 || len(first) != 20 {
|
||||
t.Fatalf("first page = (%d, %d), want (20, 21)", len(first), total)
|
||||
}
|
||||
second, _ := BuildThemeIndexPage(themes, ParseThemeIndexQuery("2"))
|
||||
if len(second) != 1 {
|
||||
t.Fatalf("second page length = %d, want 1", len(second))
|
||||
}
|
||||
if got := ThemeIndexCanonicalPath(ParseThemeIndexQuery("2")); got != "/topic/page/2" {
|
||||
t.Fatalf("canonical path = %q, want /topic/page/2", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
todayUpdatePath = "/api/app/playlet/today-update"
|
||||
defaultTodayUpdateSize = 40
|
||||
)
|
||||
|
||||
// TodayUpdateQuery 今日更新分页参数。
|
||||
type TodayUpdateQuery struct {
|
||||
PageNum int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// Normalize 校正分页默认值。
|
||||
func (q *TodayUpdateQuery) Normalize() {
|
||||
if q.PageNum <= 0 {
|
||||
q.PageNum = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = defaultTodayUpdateSize
|
||||
}
|
||||
if q.PageSize > 50 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
}
|
||||
|
||||
// FetchTodayUpdate 分页查询今日更新短剧。
|
||||
func FetchTodayUpdate(c *gin.Context, q TodayUpdateQuery) (*PlayletVideoResp, error) {
|
||||
q.Normalize()
|
||||
params := url.Values{}
|
||||
params.Set("pageNum", strconv.Itoa(q.PageNum))
|
||||
params.Set("pageSize", strconv.Itoa(q.PageSize))
|
||||
|
||||
var env APIEnvelope[PlayletVideoResp]
|
||||
if err := upstream.GetPublicJSON(c, todayUpdatePath+"?"+params.Encode(), &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("playlet today-update code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchTodayUpdateOrEmpty 失败时返回空列表。
|
||||
func FetchTodayUpdateOrEmpty(c *gin.Context, q TodayUpdateQuery) *PlayletVideoResp {
|
||||
data, err := FetchTodayUpdate(c, q)
|
||||
if err != nil {
|
||||
log.Error("fetch playlet today-update failed", log.E(err))
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
if data == nil {
|
||||
return &PlayletVideoResp{PageNum: q.PageNum, PageSize: q.PageSize}
|
||||
}
|
||||
data.SeoInfo.Normalize()
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func todayUpdatePageURL(pageNum int) string {
|
||||
if pageNum <= 1 {
|
||||
return "/today-update/"
|
||||
}
|
||||
return "/today-update/page/" + strconv.Itoa(pageNum) + "/"
|
||||
}
|
||||
|
||||
func TodayUpdateCanonicalPath(q TodayUpdateQuery) string {
|
||||
q.Normalize()
|
||||
return todayUpdatePageURL(q.PageNum)
|
||||
}
|
||||
|
||||
// BuildTodayUpdatePagination 今日更新分页。
|
||||
func BuildTodayUpdatePagination(q TodayUpdateQuery, totalCount int64) PaginationVM {
|
||||
q.Normalize()
|
||||
return BuildPagination(func(p int) string {
|
||||
return todayUpdatePageURL(p)
|
||||
}, q.PageNum, q.PageSize, totalCount)
|
||||
}
|
||||
|
||||
// ParseTodayUpdateQuery 从 query 解析分页。
|
||||
func ParseTodayUpdateQuery(pageStr string) TodayUpdateQuery {
|
||||
q := TodayUpdateQuery{PageSize: defaultTodayUpdateSize}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(pageStr)); err == nil {
|
||||
q.PageNum = n
|
||||
}
|
||||
q.Normalize()
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TemplateFuncs 模板辅助:仅做展示格式化 / URL,不把接口字段映射成另一套名字。
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"detailURL": detailURL,
|
||||
"playURL": playURL,
|
||||
"chapterPlayURL": chapterPlayURL,
|
||||
"categoryURL": categoryURL,
|
||||
"categoryDetailURL": categoryDetailURL,
|
||||
"themeListURL": themeListURLByKey,
|
||||
"coverURL": coverURL,
|
||||
"formatHeat": formatHeat,
|
||||
"formatCollect": formatCollectHeat,
|
||||
"formatLikeCount": formatLikeCount,
|
||||
"episodeLabel": episodeText,
|
||||
"displayTag": displayTag,
|
||||
"firstTagName": firstTagName,
|
||||
"themeTagText": themeTagText,
|
||||
"tagListURL": tagListURL,
|
||||
"cardTag": cardTag,
|
||||
"cardTags": cardTags,
|
||||
"namedTags": namedTags,
|
||||
"namedThemeTags": namedThemeTags,
|
||||
"firstName": firstName,
|
||||
"rankClass": rankClass,
|
||||
"add": addInt,
|
||||
"contains": strings.Contains,
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
"footerLinkURL": footerLinkURL,
|
||||
"footerLinkIsExternal": footerLinkIsExternal,
|
||||
"footerContactURL": footerContactURL,
|
||||
"footerContactName": footerContactName,
|
||||
"footerContactValue": footerContactValue,
|
||||
"navDisplayURL": navDisplayURL,
|
||||
"urlquery": url.QueryEscape,
|
||||
"pathescape": url.PathEscape,
|
||||
"metaLine": metaLine,
|
||||
"updateStatusText": updateStatusText,
|
||||
"browseText": browseText,
|
||||
"usableAnchor": usableAnchor,
|
||||
"playletYearText": playletYearText,
|
||||
"playletDateText": playletDateText,
|
||||
"isPlayletAllFree": isPlayletAllFree,
|
||||
"collectProgressText": collectProgressText,
|
||||
"collectTagText": collectTagText,
|
||||
"chapterCollectEpisodeLabel": chapterCollectEpisodeLabel,
|
||||
"chapterCollectDesc": chapterCollectDesc,
|
||||
"chapterCollectMeta": chapterCollectMeta,
|
||||
"historyProgressText": historyProgressText,
|
||||
"historyWatchLine": historyWatchLine,
|
||||
"historyTagText": historyTagText,
|
||||
"historyPlayURL": historyPlayURL,
|
||||
"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(_, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "/" || raw == "#" {
|
||||
return ""
|
||||
}
|
||||
if footerLinkIsExternal(raw) {
|
||||
return raw
|
||||
}
|
||||
if href := footerRichtextHref(raw); href != "" {
|
||||
return href
|
||||
}
|
||||
return normalizeFooterInternalURL(raw)
|
||||
}
|
||||
|
||||
func footerLinkIsExternal(raw string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "http")
|
||||
}
|
||||
|
||||
func footerRichtextHref(raw string) string {
|
||||
path := raw
|
||||
if i := strings.IndexAny(raw, "?#"); i >= 0 {
|
||||
path = raw[:i]
|
||||
}
|
||||
path = strings.TrimRight(strings.TrimSpace(path), "/")
|
||||
if path == "/richtext" {
|
||||
return "/richtext/"
|
||||
}
|
||||
if strings.HasPrefix(path, "/richtext/") {
|
||||
return path + "/"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeFooterInternalURL(raw string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
if parsed.Scheme != "" || parsed.Host != "" || !strings.HasPrefix(parsed.Path, "/") {
|
||||
return raw
|
||||
}
|
||||
if parsed.Path != "/" && !strings.HasSuffix(parsed.Path, "/") {
|
||||
if parsed.RawQuery == "" && parsed.Fragment == "" {
|
||||
return raw + "/"
|
||||
}
|
||||
parsed.Path += "/"
|
||||
href := parsed.Path
|
||||
if parsed.RawQuery != "" {
|
||||
href += "?" + parsed.RawQuery
|
||||
}
|
||||
if parsed.Fragment != "" {
|
||||
href += "#" + parsed.Fragment
|
||||
}
|
||||
return href
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// footerContactURL 把页脚联系方式转换为可点击地址,并使用全站配置补齐按钮型文案。
|
||||
func footerContactURL(name, value, gmailAddr, tgGroup, customerServiceHost string) string {
|
||||
normalizedName := strings.ToLower(strings.ReplaceAll(footerContactName(name), " ", ""))
|
||||
switch {
|
||||
case strings.Contains(normalizedName, "官方群") || strings.Contains(normalizedName, "telegram") || strings.Contains(normalizedName, "tg群"):
|
||||
return firstContactURL(tgGroup, value)
|
||||
case strings.Contains(normalizedName, "商务") || strings.Contains(normalizedName, "合作"):
|
||||
return firstContactURL(customerServiceHost, value, gmailAddr)
|
||||
case strings.Contains(normalizedName, "邮箱") || strings.Contains(normalizedName, "email") || strings.Contains(normalizedName, "e-mail"):
|
||||
return firstContactURL(value, gmailAddr)
|
||||
default:
|
||||
return firstContactURL(value)
|
||||
}
|
||||
}
|
||||
|
||||
func footerContactName(name string) string {
|
||||
return cleanFooterContactAction(name)
|
||||
}
|
||||
|
||||
func footerContactValue(value string) string {
|
||||
return cleanFooterContactAction(value)
|
||||
}
|
||||
|
||||
func cleanFooterContactAction(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, action := range []string{"立即联系", "立即加入"} {
|
||||
value = strings.ReplaceAll(value, action, "")
|
||||
}
|
||||
for {
|
||||
cleaned := strings.TrimSpace(strings.Trim(value, "::"))
|
||||
if cleaned == value {
|
||||
return cleaned
|
||||
}
|
||||
value = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
func firstContactURL(values ...string) string {
|
||||
for _, value := range values {
|
||||
if href := normalizeContactURL(value); href != "" {
|
||||
return href
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeContactURL(value string) string {
|
||||
value = footerContactValue(value)
|
||||
if value == "" || strings.ContainsAny(value, " \t\r\n") {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(value)
|
||||
if strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "tel:") {
|
||||
return value
|
||||
}
|
||||
if strings.HasPrefix(value, "//") {
|
||||
return "https:" + value
|
||||
}
|
||||
if strings.HasPrefix(value, "/") {
|
||||
return value
|
||||
}
|
||||
if strings.Contains(value, "@") && strings.Contains(value, ".") {
|
||||
return "mailto:" + value
|
||||
}
|
||||
if strings.Contains(value, ".") {
|
||||
return "https://" + value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// absURLTpl 模板内绝对化 URL:absURL foreverUrlAddr path
|
||||
func absURLTpl(siteURL, path string) string {
|
||||
return joinAbs(siteURL, path)
|
||||
}
|
||||
|
||||
// canonicalOr 优先使用接口返回的 canonical,否则用 foreverUrlAddr+fallbackPath。
|
||||
func canonicalOr(siteURL, preferred, fallbackPath string) string {
|
||||
preferred = strings.TrimSpace(preferred)
|
||||
if preferred != "" {
|
||||
if strings.HasPrefix(preferred, "http://") || strings.HasPrefix(preferred, "https://") {
|
||||
return preferred
|
||||
}
|
||||
return joinAbs(siteURL, preferred)
|
||||
}
|
||||
return joinAbs(siteURL, fallbackPath)
|
||||
}
|
||||
|
||||
func joinAbs(siteURL, path string) string {
|
||||
siteURL = strings.TrimRight(strings.TrimSpace(siteURL), "/")
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
if siteURL == "" {
|
||||
return "/"
|
||||
}
|
||||
return siteURL + "/"
|
||||
}
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
if siteURL == "" {
|
||||
return path
|
||||
}
|
||||
return siteURL + path
|
||||
}
|
||||
|
||||
// coverURL 选出封面地址。返回 template.URL,避免 html/template 把 data-src
|
||||
// 当 URL 上下文时把已百分号编码的中文路径再次转成 %25xx(导致封面 404)。
|
||||
func coverURL(primary, fallback string) template.URL {
|
||||
return template.URL(firstNonEmpty(primary, fallback))
|
||||
}
|
||||
|
||||
func displayTag(item CommonData) string {
|
||||
if name := firstName(item.TagNames); name != "" {
|
||||
return name
|
||||
}
|
||||
if name := firstName(item.ThemeNames); name != "" {
|
||||
return name
|
||||
}
|
||||
return "短剧"
|
||||
}
|
||||
|
||||
func firstTagName(item CommonData) string {
|
||||
if len(item.TagNames) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(item.TagNames[0])
|
||||
}
|
||||
|
||||
func firstName(names []string) string {
|
||||
for _, n := range names {
|
||||
n = strings.TrimSpace(n)
|
||||
if n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func themeTagText(themeNames, tagNames []string) string {
|
||||
theme := firstName(themeNames)
|
||||
tag := firstName(tagNames)
|
||||
switch {
|
||||
case theme != "" && tag != "" && theme != tag:
|
||||
return theme + " · " + tag
|
||||
case tag != "":
|
||||
return tag
|
||||
default:
|
||||
return theme
|
||||
}
|
||||
}
|
||||
|
||||
func addInt(a, b int) int { return a + b }
|
||||
|
||||
func absInt64Tpl(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func formatShortDateTime(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "0001") {
|
||||
return ""
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, "T", " ")
|
||||
if i := strings.Index(raw, "."); i > 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
if i := strings.IndexAny(raw, "Z+"); i > 10 {
|
||||
raw = strings.TrimSpace(raw[:i])
|
||||
}
|
||||
if len(raw) >= 16 {
|
||||
raw = raw[:16]
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, "-", "/")
|
||||
return raw
|
||||
}
|
||||
|
||||
func metaLine(item CommonData) string {
|
||||
cat := firstName(item.ThemeNames)
|
||||
if cat == "" {
|
||||
cat = firstName(item.TagNames)
|
||||
}
|
||||
if item.TotalEpisode > 0 {
|
||||
if cat != "" {
|
||||
return fmt.Sprintf("%s·%d集", cat, item.TotalEpisode)
|
||||
}
|
||||
return fmt.Sprintf("%d集", item.TotalEpisode)
|
||||
}
|
||||
return cat
|
||||
}
|
||||
|
||||
// playletYearText 详情 PC 元信息年份;无有效 onlineAt 时展示「年份未知」。
|
||||
func playletYearText(onlineAt string) string {
|
||||
onlineAt = strings.TrimSpace(onlineAt)
|
||||
if len(onlineAt) < 4 {
|
||||
return "年份未知"
|
||||
}
|
||||
year := onlineAt[:4]
|
||||
for _, c := range year {
|
||||
if c < '0' || c > '9' {
|
||||
return "年份未知"
|
||||
}
|
||||
}
|
||||
if year == "0001" || year == "1970" {
|
||||
return "年份未知"
|
||||
}
|
||||
return year
|
||||
}
|
||||
|
||||
// playletDateText formats the first valid detail-page date as YYYY.MM.DD.
|
||||
func playletDateText(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 10 {
|
||||
continue
|
||||
}
|
||||
date := strings.NewReplacer("-", ".", "/", ".").Replace(value[:10])
|
||||
parts := strings.Split(date, ".")
|
||||
if len(parts) != 3 || len(parts[0]) != 4 || len(parts[1]) != 2 || len(parts[2]) != 2 {
|
||||
continue
|
||||
}
|
||||
validDigits := true
|
||||
for _, part := range parts {
|
||||
for _, c := range part {
|
||||
if c < '0' || c > '9' {
|
||||
validDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validDigits {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validDigits || parts[0] == "0001" || parts[0] == "1970" {
|
||||
continue
|
||||
}
|
||||
parsed, err := time.Parse("2006.01.02", date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return parsed.Format("2006.01.02")
|
||||
}
|
||||
return "时间未知"
|
||||
}
|
||||
|
||||
// isPlayletAllFree 全部剧集 PayType==0 时视为全站免费。
|
||||
func isPlayletAllFree(chapters []PlayletChapterData) bool {
|
||||
if len(chapters) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, ch := range chapters {
|
||||
if ch.PayType != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package playletser
|
||||
|
||||
// HomePlayletResponse 对应 swagger proto.HomePlayletResponse
|
||||
// GET /api/app/playlet/home
|
||||
type HomePlayletResponse struct {
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
Announcements []AnnouncementItem `json:"announcements"`
|
||||
HomeBannerData []HomeBannerData `json:"homeBannerData"`
|
||||
HomeTodayUpdateData []CommonData `json:"homeTodayUpdateData"`
|
||||
HomeCategoryData []HomeCategoryData `json:"homeCategoryData"`
|
||||
HomeRecommendThemes []HomeRecommendTheme `json:"homeRecommendThemes"`
|
||||
}
|
||||
|
||||
type AnnouncementItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type HomeRecommendTheme struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Icon string `json:"icon"`
|
||||
Rank int `json:"rank"`
|
||||
PlayletCount int64 `json:"playletCount"`
|
||||
}
|
||||
|
||||
// HomeBannerData 对应 swagger proto.HomeBannerData
|
||||
type HomeBannerData struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
VerticalImage string `json:"verticalImage"`
|
||||
URL string `json:"url"`
|
||||
Rank int `json:"rank"`
|
||||
Status bool `json:"status"`
|
||||
}
|
||||
|
||||
// HomeCategoryData 对应 swagger proto.HomeCategoryData
|
||||
type HomeCategoryData struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Desc string `json:"desc"`
|
||||
Icon string `json:"icon"`
|
||||
Rank int `json:"rank"`
|
||||
List []CommonData `json:"list"`
|
||||
}
|
||||
|
||||
// CommonData 对应 swagger proto.CommonData
|
||||
type CommonData struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
IsFeatured bool `json:"isFeatured"`
|
||||
IsNew bool `json:"isNew"`
|
||||
IsRecommend bool `json:"isRecommend"`
|
||||
OperateType int `json:"operateType"`
|
||||
ShowOnBanner bool `json:"showOnBanner"`
|
||||
Banner string `json:"banner"`
|
||||
OnlineAt string `json:"onlineAt"`
|
||||
MediaCreatedAt string `json:"mediaCreatedAt"`
|
||||
MediaUpdatedAt string `json:"mediaUpdatedAt"`
|
||||
ThemeIDs []int64 `json:"themeIds"`
|
||||
ThemeNames []string `json:"themeNames"`
|
||||
ThemeSlugs []string `json:"themeSlugs"`
|
||||
Title string `json:"title"`
|
||||
PlayName string `json:"playName"`
|
||||
Description string `json:"description"`
|
||||
Slug string `json:"slug"`
|
||||
Desc string `json:"desc"`
|
||||
Anchor string `json:"anchor"`
|
||||
Cover string `json:"cover"`
|
||||
TagIDs []int64 `json:"tagIds"`
|
||||
TagNames []string `json:"tagNames"`
|
||||
TagSlugs []string `json:"tagSlugs"`
|
||||
TotalEpisode int `json:"totalEpisode"`
|
||||
UpdateStatus int `json:"updateStatus"`
|
||||
CountBrowse int64 `json:"countBrowse"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
HeatValue int64 `json:"heatValue"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// PlayletVideoResp 对应 swagger proto.PlayletVideoResp
|
||||
// GET /api/app/playlet/list
|
||||
type PlayletVideoResp struct {
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
List []CommonData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// PlayletRecommendListResp 对应 swagger proto.PlayletRecommendListResp。
|
||||
// GET /api/app/playlet/recommend/list
|
||||
type PlayletRecommendListResp struct {
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
CategorySlug string `json:"categorySlug"`
|
||||
CategoryDesc string `json:"categoryDesc"`
|
||||
List []CommonData `json:"list"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
}
|
||||
|
||||
// PlayletThemeItem 对应 swagger proto.PlayletThemeItem
|
||||
type PlayletThemeItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Icon string `json:"icon"`
|
||||
Rank int `json:"rank"`
|
||||
Status bool `json:"status"`
|
||||
IsRecommend bool `json:"isRecommend"`
|
||||
PlayletCount int64 `json:"playletCount"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// PlayletThemeListResponse 对应 swagger proto.PlayletThemeListResponse
|
||||
// GET /api/app/theme/list
|
||||
type PlayletThemeListResponse struct {
|
||||
List []PlayletThemeItem `json:"list"`
|
||||
}
|
||||
|
||||
// PlayletChapterData 对应 swagger proto.PlayletChapterData
|
||||
// 出现在 GET /api/app/playlet/detail 的 playletChapterInfo。
|
||||
type PlayletChapterData struct {
|
||||
ID int64 `json:"id"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
CurrentEpisode int `json:"currentEpisode"`
|
||||
Title string `json:"title"`
|
||||
MD5 string `json:"md5"`
|
||||
Hash string `json:"hash"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
VideoURL string `json:"videoUrl"`
|
||||
MediaSize int64 `json:"mediaSize"`
|
||||
MediaTime int `json:"mediaTime"`
|
||||
PayType int `json:"payType"`
|
||||
SingleCoins int `json:"singleCoins"`
|
||||
CostCoins int64 `json:"costCoins"`
|
||||
Balance int64 `json:"balance"`
|
||||
CanWatch bool `json:"canWatch"`
|
||||
IsUnlocked bool `json:"isUnlocked"`
|
||||
NeedUnlock bool `json:"needUnlock"`
|
||||
NeedVip bool `json:"needVip"`
|
||||
VipValid bool `json:"vipValid"`
|
||||
}
|
||||
|
||||
// PlayletChapterListResp 对应 swagger proto.PlayletChapterListResp。
|
||||
type PlayletChapterListResp struct {
|
||||
PlayletID int64 `json:"playletId"`
|
||||
List []PlayletChapterData `json:"list"`
|
||||
}
|
||||
|
||||
// PlayletOperateListResp 对应 swagger proto.PlayletOperateListResp。
|
||||
type PlayletOperateListResp struct {
|
||||
OperateType int `json:"operateType"`
|
||||
FilterType int `json:"filterType"`
|
||||
PageNum int `json:"pageNum"`
|
||||
PageSize int `json:"pageSize"`
|
||||
TotalCount int64 `json:"totalCount"`
|
||||
List []CommonData `json:"list"`
|
||||
}
|
||||
|
||||
// PlayletInfoResp 对应 swagger proto.PlayletInfoResp
|
||||
// GET /api/app/playlet/detail
|
||||
type PlayletInfoResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
IsFeatured bool `json:"isFeatured"`
|
||||
IsNew bool `json:"isNew"`
|
||||
IsRecommend bool `json:"isRecommend"`
|
||||
OperateType int `json:"operateType"`
|
||||
ShowOnBanner bool `json:"showOnBanner"`
|
||||
Banner string `json:"banner"`
|
||||
OnlineAt string `json:"onlineAt"`
|
||||
MediaCreatedAt string `json:"mediaCreatedAt"`
|
||||
MediaUpdatedAt string `json:"mediaUpdatedAt"`
|
||||
ThemeIDs []int64 `json:"themeIds"`
|
||||
ThemeNames []string `json:"themeNames"`
|
||||
ThemeSlugs []string `json:"themeSlugs"`
|
||||
Title string `json:"title"`
|
||||
PlayName string `json:"playName"`
|
||||
Description string `json:"description"`
|
||||
Slug string `json:"slug"`
|
||||
Desc string `json:"desc"`
|
||||
Anchor string `json:"anchor"`
|
||||
Cover string `json:"cover"`
|
||||
TagIDs []int64 `json:"tagIds"`
|
||||
TagNames []string `json:"tagNames"`
|
||||
TagSlugs []string `json:"tagSlugs"`
|
||||
TotalEpisode int `json:"totalEpisode"`
|
||||
UpdateStatus int `json:"updateStatus"`
|
||||
CountBrowse int64 `json:"countBrowse"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
HeatValue int64 `json:"heatValue"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
IsLike int `json:"isLike"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
RelatedRecommendData []CommonData `json:"relatedRecommendData"`
|
||||
PlayletChapterInfo []PlayletChapterData `json:"playletChapterInfo"`
|
||||
}
|
||||
|
||||
// SeoInfo 对应 GET /api/app/playlet/detail 的 proto.SeoInfo。
|
||||
// 字段:title / description / keywords / canonicalUrl / webSiteName。
|
||||
type SeoInfo struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Keywords []string `json:"keywords"`
|
||||
CanonicalURL string `json:"canonicalUrl"`
|
||||
WebSiteName string `json:"webSiteName"`
|
||||
}
|
||||
|
||||
// PlayletChapterInfoResp 对应 swagger proto.PlayletChapterInfoResp
|
||||
// GET /api/app/playlet/chapter/detail
|
||||
type PlayletChapterInfoResp struct {
|
||||
ID int64 `json:"id"`
|
||||
PlayletID int64 `json:"playletId"`
|
||||
CurrentEpisode int `json:"currentEpisode"`
|
||||
Title string `json:"title"`
|
||||
MD5 string `json:"md5"`
|
||||
Hash string `json:"hash"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
VideoURL string `json:"videoUrl"`
|
||||
MediaSize int64 `json:"mediaSize"`
|
||||
MediaTime int `json:"mediaTime"`
|
||||
CountComment int64 `json:"countComment"`
|
||||
CountBrowse int64 `json:"countBrowse"`
|
||||
CountCollect int64 `json:"countCollect"`
|
||||
CountLike int64 `json:"countLike"`
|
||||
IsLike int `json:"isLike"`
|
||||
IsCollect int `json:"isCollect"`
|
||||
PayType int `json:"payType"`
|
||||
SingleCoins int `json:"singleCoins"`
|
||||
CostCoins int64 `json:"costCoins"`
|
||||
Balance int64 `json:"balance"`
|
||||
CanWatch bool `json:"canWatch"`
|
||||
IsUnlocked bool `json:"isUnlocked"`
|
||||
NeedUnlock bool `json:"needUnlock"`
|
||||
NeedVip bool `json:"needVip"`
|
||||
VipValid bool `json:"vipValid"`
|
||||
SeoInfo SeoInfo `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func pathSegment(slug string, id int64) string {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug == "" && id > 0 {
|
||||
slug = strconv.FormatInt(id, 10)
|
||||
}
|
||||
if slug == "" {
|
||||
return ""
|
||||
}
|
||||
return url.PathEscape(slug)
|
||||
}
|
||||
|
||||
func detailURL(slug string, id int64) string {
|
||||
if segment := pathSegment(slug, id); segment != "" {
|
||||
return "/detail/" + segment + "/"
|
||||
}
|
||||
return "#"
|
||||
}
|
||||
|
||||
func playURL(slug string, playletID int64) string {
|
||||
if segment := pathSegment(slug, playletID); segment != "" {
|
||||
return "/play/" + segment + "/"
|
||||
}
|
||||
return "#"
|
||||
}
|
||||
|
||||
func chapterPlayURL(slug string, playletID, chapterID int64) string {
|
||||
path := playURL(slug, playletID)
|
||||
if path == "#" || chapterID <= 0 {
|
||||
return path
|
||||
}
|
||||
return fmt.Sprintf("%s%d/", path, chapterID)
|
||||
}
|
||||
|
||||
func categoryURL(slug string, id int64) string {
|
||||
if segment := pathSegment(slug, id); segment != "" {
|
||||
return "/topic/" + segment + "/"
|
||||
}
|
||||
return "/topic/"
|
||||
}
|
||||
|
||||
func DetailPath(slug string, id int64) string {
|
||||
return detailURL(slug, id)
|
||||
}
|
||||
|
||||
func PlayPath(slug string, id int64) string {
|
||||
return playURL(slug, id)
|
||||
}
|
||||
|
||||
func ChapterPlayPath(slug string, playletID, chapterID int64) string {
|
||||
return chapterPlayURL(slug, playletID, chapterID)
|
||||
}
|
||||
|
||||
func updateStatusText(status int) string {
|
||||
if status == 2 {
|
||||
return "已完结"
|
||||
}
|
||||
return "连载中"
|
||||
}
|
||||
|
||||
func browseText(n int64) string {
|
||||
if n <= 0 {
|
||||
return "0次播放"
|
||||
}
|
||||
if n < 10000 {
|
||||
return fmt.Sprintf("%d次播放", n)
|
||||
}
|
||||
v := float64(n) / 10000
|
||||
if v >= 100 {
|
||||
return fmt.Sprintf("%.0f万次播放", v)
|
||||
}
|
||||
return fmt.Sprintf("%.1f万次播放", v)
|
||||
}
|
||||
|
||||
func usableAnchor(anchor string) string {
|
||||
anchor = strings.TrimSpace(anchor)
|
||||
if anchor == "" || anchor == "暂无" {
|
||||
return ""
|
||||
}
|
||||
return anchor
|
||||
}
|
||||
|
||||
func rankClass(rank int) string {
|
||||
switch rank {
|
||||
case 1:
|
||||
return "gold"
|
||||
case 2:
|
||||
return "silver"
|
||||
case 3:
|
||||
return "bronze"
|
||||
default:
|
||||
return "dark"
|
||||
}
|
||||
}
|
||||
|
||||
func episodeText(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d集", n)
|
||||
}
|
||||
|
||||
func formatHeat(n int64) string {
|
||||
if n <= 0 {
|
||||
return "0 热度"
|
||||
}
|
||||
if n < 10000 {
|
||||
return fmt.Sprintf("%d 热度", n)
|
||||
}
|
||||
v := float64(n) / 10000
|
||||
if v >= 100 {
|
||||
return fmt.Sprintf("%.0f万 热度", v)
|
||||
}
|
||||
return fmt.Sprintf("%.1f万 热度", v)
|
||||
}
|
||||
|
||||
// formatLikeCount 封面点赞数展示:不足一万原样,否则 x.x万。
|
||||
func formatLikeCount(n int64) string {
|
||||
if n <= 0 {
|
||||
return "0"
|
||||
}
|
||||
if n < 10000 {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
v := float64(n) / 10000
|
||||
if v >= 100 {
|
||||
return fmt.Sprintf("%.0f万", v)
|
||||
}
|
||||
return fmt.Sprintf("%.1f万", v)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package playletser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPlayletURLsPreferSlugAndFallbackToIDPath(t *testing.T) {
|
||||
if got := detailURL("my-show", 12); got != "/detail/my-show" {
|
||||
t.Fatalf("detailURL with slug = %q", got)
|
||||
}
|
||||
if got := detailURL("", 12); got != "/detail/12" {
|
||||
t.Fatalf("detailURL ID fallback = %q", got)
|
||||
}
|
||||
if got := chapterPlayURL("my-show", 12, 3); got != "/play/my-show/3" {
|
||||
t.Fatalf("chapterPlayURL with slug = %q", got)
|
||||
}
|
||||
if got := chapterPlayURL("", 12, 3); got != "/play/12/3" {
|
||||
t.Fatalf("chapterPlayURL ID fallback = %q", got)
|
||||
}
|
||||
if got := themeListURLByKey("wuxia", 10404); got != "/list/wuxia" {
|
||||
t.Fatalf("themeListURLByKey with slug = %q", got)
|
||||
}
|
||||
if got := themeListURLByKey("", 10404); got != "/list/10404" {
|
||||
t.Fatalf("themeListURLByKey ID fallback = %q", got)
|
||||
}
|
||||
if got := categoryURL("editor-picks", 12); got != "/topic/editor-picks" {
|
||||
t.Fatalf("categoryURL with slug = %q", got)
|
||||
}
|
||||
if got := categoryURL("", 12); got != "/topic/12" {
|
||||
t.Fatalf("categoryURL ID fallback = %q", got)
|
||||
}
|
||||
if got := recommendPageURL(12, "editor-picks", 2); got != "/topic/editor-picks/page/2" {
|
||||
t.Fatalf("recommendPageURL pagination = %q", got)
|
||||
}
|
||||
if got := rankPageURL(rankSortNew, 1); got != "/rank/2" {
|
||||
t.Fatalf("rankPageURL with sort order = %q", got)
|
||||
}
|
||||
if got := rankPageURL(rankSortNew, 2); got != "/rank/2/page/2" {
|
||||
t.Fatalf("rankPageURL pagination = %q", got)
|
||||
}
|
||||
q := ListQuery{ThemeSlug: "jiating", SortOrder: sortTodayUpdate, Type: contentTypeNonAdult}
|
||||
if got := listPageURL(q.ThemeID, q.ThemeSlug, q.SortOrder, 1, q.Type); got != "/list/jiating/2" {
|
||||
t.Fatalf("listPageURL with theme and sort = %q", got)
|
||||
}
|
||||
if got := listPageURL(q.ThemeID, q.ThemeSlug, q.SortOrder, 2, q.Type); got != "/list/jiating/2/page/2" {
|
||||
t.Fatalf("listPageURL pagination = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPageURLUsesSortPath(t *testing.T) {
|
||||
if got := listPageURL(0, "zhichang", sortTodayUpdate, 1, contentTypeNonAdult); got != "/list/zhichang/2" {
|
||||
t.Fatalf("listPageURL with slug and sort = %q", got)
|
||||
}
|
||||
if got := listPageURL(12, "", sortHeat, 1, contentTypeAdult); got != "/list/12/1" {
|
||||
t.Fatalf("listPageURL ID fallback = %q", got)
|
||||
}
|
||||
if got := listPageURL(0, "ai-short-play", sortTodayUpdate, 1, contentTypeAdult); got != "/list/ai-short-play" {
|
||||
t.Fatalf("listPageURL editorial default = %q", got)
|
||||
}
|
||||
if got := listPageURL(0, "ai-short-play", sortHeat, 1, contentTypeAdult); got != "/list/ai-short-play/1" {
|
||||
t.Fatalf("listPageURL editorial hot = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayletDateText(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"2020-09-09 12:30:00": "2020.09.09",
|
||||
"2026/03/18": "2026.03.18",
|
||||
"2026.03.18T08:00:00": "2026.03.18",
|
||||
"": "时间未知",
|
||||
"1970-01-01": "时间未知",
|
||||
"2026-13-40": "时间未知",
|
||||
"invalid-date": "时间未知",
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := playletDateText(input); got != want {
|
||||
t.Fatalf("playletDateText(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
if got := playletDateText("", "2026-04-05 10:20:30", "2025-01-01"); got != "2026.04.05" {
|
||||
t.Fatalf("playletDateText fallback = %q, want %q", got, "2026.04.05")
|
||||
}
|
||||
if got := playletDateText("invalid-date", "", "2025/08/09"); got != "2025.08.09" {
|
||||
t.Fatalf("playletDateText valid fallback = %q, want %q", got, "2025.08.09")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package rechargeser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
full := fmt.Sprintf("%s?page=%d&pageSize=%d", coinTransPath, page, pageSize)
|
||||
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 decodeCoinTransList(env.Data), env.Code, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
var arr []CoinTransListItem
|
||||
if err := json.Unmarshal(raw, &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
var obj struct {
|
||||
List []CoinTransListItem `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return nil
|
||||
}
|
||||
return obj.List
|
||||
}
|
||||
|
||||
func fetchOrders(c *gin.Context, path string, page, pageSize int) ([]VipOrder, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
full := fmt.Sprintf("%s?page=%d&pageSize=%d", path, page, pageSize)
|
||||
var env APIEnvelopeRaw
|
||||
if err := upstream.GetJSON(c, full, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("%s code=%d msg=%s", path, env.Code, env.Msg)
|
||||
}
|
||||
return decodeOrderList(env.Data), nil
|
||||
}
|
||||
|
||||
func decodeOrderList(raw json.RawMessage) []VipOrder {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
var arr []VipOrder
|
||||
if err := json.Unmarshal(raw, &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
var obj struct {
|
||||
List []VipOrder `json:"list"`
|
||||
Orders []VipOrder `json:"orders"`
|
||||
Records []VipOrder `json:"records"`
|
||||
Items []VipOrder `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case len(obj.List) > 0:
|
||||
return obj.List
|
||||
case len(obj.Orders) > 0:
|
||||
return obj.Orders
|
||||
case len(obj.Records) > 0:
|
||||
return obj.Records
|
||||
case len(obj.Items) > 0:
|
||||
return obj.Items
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
const (
|
||||
OrderTypeVip = 1
|
||||
OrderTypeCoin = 2
|
||||
|
||||
OrderStatusPending = 1
|
||||
OrderStatusSuccess = 2
|
||||
OrderStatusFailed = 3
|
||||
|
||||
FlowDirIn = 1
|
||||
FlowDirOut = -1
|
||||
)
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
// APIEnvelopeRaw Data 形态不固定时使用。
|
||||
type APIEnvelopeRaw struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
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
|
||||
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
|
||||
TimeText string
|
||||
AmountText string
|
||||
AmountClass string // deposit-tx-amount--plus / --minus
|
||||
StatusText string
|
||||
}
|
||||
|
||||
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,
|
||||
TimeText: formatTransTime(item.CreatedAt),
|
||||
AmountText: amountText,
|
||||
AmountClass: amountClass,
|
||||
StatusText: strings.TrimSpace(item.FlowDirName),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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),
|
||||
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 buildPayMethodVMs(list []PayMethodInfo, withFallback bool) []PayMethodVM {
|
||||
src := list
|
||||
if len(src) == 0 && withFallback {
|
||||
src = defaultPayMethods
|
||||
}
|
||||
out := make([]PayMethodVM, 0, len(src))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range src {
|
||||
typ := strings.ToLower(strings.TrimSpace(item.Type))
|
||||
name := strings.TrimSpace(item.TypeName)
|
||||
if typ == "" && name == "" {
|
||||
continue
|
||||
}
|
||||
if typ == "" {
|
||||
typ = strings.ToLower(name)
|
||||
}
|
||||
if name == "" {
|
||||
name = defaultPayTypeName(typ)
|
||||
}
|
||||
if _, ok := seen[typ]; ok {
|
||||
continue
|
||||
}
|
||||
seen[typ] = struct{}{}
|
||||
out = append(out, PayMethodVM{Type: typ, TypeName: name})
|
||||
}
|
||||
if len(out) == 0 && withFallback {
|
||||
return buildPayMethodVMs(defaultPayMethods, false)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func defaultPayTypeName(typ string) string {
|
||||
switch typ {
|
||||
case "alipay":
|
||||
return "支付宝"
|
||||
case "wechat", "wx", "weixin":
|
||||
return "微信"
|
||||
case "union", "unionpay":
|
||||
return "银联"
|
||||
case "dcep", "ecny":
|
||||
return "数字人民币"
|
||||
case "usdt":
|
||||
return "USDT"
|
||||
default:
|
||||
if typ == "" {
|
||||
return "支付"
|
||||
}
|
||||
return typ
|
||||
}
|
||||
}
|
||||
|
||||
func marshalPayMethodsJSON(list []PayMethodVM) string {
|
||||
type row struct {
|
||||
Type string `json:"type"`
|
||||
TypeName string `json:"typeName"`
|
||||
}
|
||||
rows := make([]row, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, row{Type: item.Type, TypeName: item.TypeName})
|
||||
}
|
||||
b, err := json.Marshal(rows)
|
||||
if err != nil || len(b) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func packageHint(item RechargePackageItem, isVIP bool) string {
|
||||
// 优先接口 subTitle
|
||||
if s := strings.TrimSpace(item.SubTitle); s != "" {
|
||||
return s
|
||||
}
|
||||
if isVIP {
|
||||
if item.DurationDays > 0 {
|
||||
return fmt.Sprintf("%d天会员", item.DurationDays)
|
||||
}
|
||||
if item.DurationHours > 0 {
|
||||
return fmt.Sprintf("%d小时会员", item.DurationHours)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
base := item.BaseCoin
|
||||
gift := item.GiftCoin
|
||||
total := item.TotalCoin
|
||||
if total <= 0 {
|
||||
total = base + gift
|
||||
}
|
||||
if gift > 0 && base > 0 {
|
||||
return fmt.Sprintf("基础 %s + 赠送 %s", formatCount(base), formatCount(gift))
|
||||
}
|
||||
if total > 0 {
|
||||
return formatCount(total) + " 金币"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FormatYuan 分转元展示(整数不带小数)。
|
||||
func FormatYuan(fen int64) string {
|
||||
if fen < 0 {
|
||||
fen = 0
|
||||
}
|
||||
if fen%100 == 0 {
|
||||
return strconv.FormatInt(fen/100, 10)
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", float64(fen)/100), "0"), ".")
|
||||
}
|
||||
|
||||
func formatCount(n int64) string {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
s := strconv.FormatInt(n, 10)
|
||||
if len(s) <= 3 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
rem := len(s) % 3
|
||||
if rem == 0 {
|
||||
rem = 3
|
||||
}
|
||||
b.WriteString(s[:rem])
|
||||
for i := rem; i < len(s); i += 3 {
|
||||
b.WriteByte(',')
|
||||
b.WriteString(s[i : i+3])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsIndexablePath 是否为需要收录的公开页(与 robots 可抓取、页面 index,follow 对齐)。
|
||||
func IsIndexablePath(raw string) bool {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
return true
|
||||
}
|
||||
if u, err := url.Parse(path); err == nil && u.Path != "" {
|
||||
path = u.Path
|
||||
}
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
if path == "" {
|
||||
return true
|
||||
}
|
||||
switch path {
|
||||
case "/list", "/series", "/rank", "/today-update", "/topic", "/tag", "/category",
|
||||
"/download-app", "/application", "/home-road", "/detail", "/play", "/nav",
|
||||
"/about-us", "/aboutUs", "/help-center", "/helpCenter":
|
||||
return true
|
||||
default:
|
||||
return strings.HasPrefix(path, "/list/") ||
|
||||
strings.HasPrefix(path, "/category/") ||
|
||||
strings.HasPrefix(path, "/series/") ||
|
||||
strings.HasPrefix(path, "/topic/") ||
|
||||
strings.HasPrefix(path, "/tag/") ||
|
||||
strings.HasPrefix(path, "/rank/") ||
|
||||
strings.HasPrefix(path, "/today-update/") ||
|
||||
strings.HasPrefix(path, "/detail/") ||
|
||||
strings.HasPrefix(path, "/play/")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/applistser"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/playletser"
|
||||
)
|
||||
|
||||
const brandName = "黄果短剧"
|
||||
|
||||
type ldThing map[string]any
|
||||
|
||||
// FAQItem 同时驱动页面可见内容与 FAQPage JSON-LD,避免标记内容与页面正文不一致。
|
||||
type FAQItem struct {
|
||||
Question string
|
||||
Answer string
|
||||
}
|
||||
|
||||
// PageJSONLD 按页面数据生成可被 Schema.org / 富媒体测试校验的 JSON-LD。
|
||||
// 仅收录页生成;非收录页返回空。pageCanonical 为当前请求完整 URL。
|
||||
func PageJSONLD(siteURL, pageCanonical, path string, data map[string]any, cfg configser.AppConfigVM) template.JS {
|
||||
siteURL = strings.TrimRight(strings.TrimSpace(siteURL), "/")
|
||||
pageCanonical = strings.TrimSpace(pageCanonical)
|
||||
path = strings.TrimSpace(path)
|
||||
if !IsIndexablePath(path) {
|
||||
return ""
|
||||
}
|
||||
pageURL := pageCanonical
|
||||
if pageURL == "" {
|
||||
pageURL = ensureTrailingSlash(AbsURL(siteURL, path))
|
||||
}
|
||||
graph := make([]any, 0, 8)
|
||||
graph = append(graph, organizationNode(siteURL, cfg), websiteNode(siteURL, cfg))
|
||||
|
||||
if detail, ok := asPlaylet(data["detail"]); ok {
|
||||
graph = append(graph, detailNodes(siteURL, pageURL, detail)...)
|
||||
} else if playlet, hasPlaylet := asPlaylet(data["playlet"]); hasPlaylet || data["chapter"] != nil {
|
||||
chapter, _ := asChapter(data["chapter"])
|
||||
graph = append(graph, playNodes(siteURL, pageURL, playlet, chapter)...)
|
||||
} else {
|
||||
pageNode := webpageNode(siteURL, pageURL, data, cfg)
|
||||
graph = append(graph, pageNode)
|
||||
if crumb := genericBreadcrumb(siteURL, pageURL, data); crumb != nil {
|
||||
pageNode["breadcrumb"] = ldThing{"@id": pageURL + "#breadcrumb"}
|
||||
graph = append(graph, crumb)
|
||||
}
|
||||
if items := itemListNode(siteURL, pageURL, data); items != nil {
|
||||
pageNode["mainEntity"] = ldThing{"@id": pageURL + "#itemlist"}
|
||||
graph = append(graph, items)
|
||||
}
|
||||
if faq := faqPageNode(siteURL, pageURL, data); faq != nil {
|
||||
pageNode["hasPart"] = ldThing{"@id": pageURL + "#faq"}
|
||||
graph = append(graph, faq)
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(ldThing{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": graph,
|
||||
})
|
||||
if err != nil {
|
||||
return template.JS("{}")
|
||||
}
|
||||
return template.JS(raw)
|
||||
}
|
||||
|
||||
func organizationNode(siteURL string, cfg configser.AppConfigVM) ldThing {
|
||||
logo := AbsURL(siteURL, "/favicon.png")
|
||||
node := ldThing{
|
||||
"@type": "Organization",
|
||||
"@id": siteURL + "/#organization",
|
||||
"name": siteName(cfg),
|
||||
"url": AbsURL(siteURL, "/"),
|
||||
"logo": ldThing{
|
||||
"@type": "ImageObject",
|
||||
"@id": siteURL + "/#logo",
|
||||
"url": logo,
|
||||
"contentUrl": logo,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
},
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func websiteNode(siteURL string, cfg configser.AppConfigVM) ldThing {
|
||||
desc := strings.TrimSpace(cfg.SeoInfo.Description)
|
||||
if desc == "" {
|
||||
desc = "黄果短剧汇集热门短剧、今日更新与精选推荐,免费在线观看高清短剧。"
|
||||
}
|
||||
node := ldThing{
|
||||
"@type": "WebSite",
|
||||
"@id": siteURL + "/#website",
|
||||
"name": siteName(cfg),
|
||||
"url": AbsURL(siteURL, "/"),
|
||||
"description": desc,
|
||||
"inLanguage": "zh-CN",
|
||||
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func detailNodes(siteURL, pageURL string, d *playletser.PlayletInfoResp) []any {
|
||||
detailURL := pageURL
|
||||
playURL := AbsURL(siteURL, playletPath("/play", d.Slug, d.ID))
|
||||
name := firstNonEmpty(d.Title, d.SeoInfo.Title, "短剧详情")
|
||||
desc := firstNonEmpty(d.SeoInfo.Description, d.Desc, fmt.Sprintf("在线观看《%s》,高清短剧全集免费看。", d.Title))
|
||||
cover := absMedia(siteURL, firstNonEmpty(d.Cover, d.Banner))
|
||||
seriesID := detailURL + "#tvseries"
|
||||
|
||||
series := ldThing{
|
||||
"@type": "TVSeries",
|
||||
"@id": seriesID,
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"url": detailURL,
|
||||
"inLanguage": "zh-CN",
|
||||
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
||||
"potentialAction": ldThing{
|
||||
"@type": "WatchAction",
|
||||
"target": ldThing{
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": playURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
if cover != "" {
|
||||
series["image"] = []string{cover}
|
||||
series["thumbnailUrl"] = []string{cover}
|
||||
}
|
||||
if d.TotalEpisode > 0 {
|
||||
series["numberOfEpisodes"] = d.TotalEpisode
|
||||
}
|
||||
if published := isoDate(firstNonEmpty(d.OnlineAt, d.CreatedAt)); published != "" {
|
||||
series["datePublished"] = published
|
||||
}
|
||||
if modified := isoDate(firstNonEmpty(d.UpdatedAt, d.MediaUpdatedAt)); modified != "" {
|
||||
series["dateModified"] = modified
|
||||
}
|
||||
if genres := uniqueStrings(append(append([]string{}, d.SeoInfo.Keywords...), append(d.TagNames, d.ThemeNames...)...)); len(genres) > 0 {
|
||||
series["genre"] = genres
|
||||
}
|
||||
if actor := strings.TrimSpace(d.Anchor); actor != "" {
|
||||
series["actor"] = ldThing{"@type": "Person", "name": actor}
|
||||
}
|
||||
|
||||
crumb := breadcrumb(siteURL, [][2]string{
|
||||
{"首页", AbsURL(siteURL, "/")},
|
||||
{"短剧", AbsURL(siteURL, "/list/ai-short-play/")},
|
||||
{name, detailURL},
|
||||
})
|
||||
page := contentWebPageNode(siteURL, detailURL, name, desc, "WebPage")
|
||||
page["mainEntity"] = ldThing{"@id": seriesID}
|
||||
page["breadcrumb"] = ldThing{"@id": detailURL + "#breadcrumb"}
|
||||
return []any{page, crumb, series}
|
||||
}
|
||||
|
||||
func playNodes(siteURL, pageURL string, playlet *playletser.PlayletInfoResp, chapter *playletser.PlayletChapterInfoResp) []any {
|
||||
playletID := int64(0)
|
||||
playletTitle := "短剧"
|
||||
cover := ""
|
||||
desc := "黄果短剧高清在线播放。"
|
||||
published := ""
|
||||
modified := ""
|
||||
contentURL := ""
|
||||
playURL := pageURL
|
||||
detailURL := AbsURL(siteURL, "/")
|
||||
seriesID := ""
|
||||
totalEpisodes := 0
|
||||
genres := []string(nil)
|
||||
|
||||
if playlet != nil {
|
||||
playletID = playlet.ID
|
||||
playletTitle = firstNonEmpty(playlet.Title, playlet.SeoInfo.Title, playletTitle)
|
||||
cover = absMedia(siteURL, firstNonEmpty(playlet.Cover, playlet.Banner))
|
||||
desc = firstNonEmpty(chapterSEODesc(chapter), playlet.SeoInfo.Description, playlet.Desc, fmt.Sprintf("正在播放《%s》", playlet.Title))
|
||||
published = isoDateTime(firstNonEmpty(
|
||||
playlet.OnlineAt,
|
||||
playlet.MediaCreatedAt,
|
||||
playlet.CreatedAt,
|
||||
))
|
||||
modified = isoDateTime(firstNonEmpty(playlet.MediaUpdatedAt, playlet.UpdatedAt))
|
||||
detailURL = AbsURL(siteURL, playletPath("/detail", playlet.Slug, playlet.ID))
|
||||
seriesID = detailURL + "#tvseries"
|
||||
totalEpisodes = playlet.TotalEpisode
|
||||
genres = uniqueStrings(append(append([]string{}, playlet.TagNames...), playlet.ThemeNames...))
|
||||
}
|
||||
episode := 0
|
||||
duration := 0
|
||||
if chapter != nil {
|
||||
episode = chapter.CurrentEpisode
|
||||
duration = chapter.MediaTime
|
||||
contentURL = videoContentURL(siteURL, chapter.VideoURL)
|
||||
if playletID == 0 && chapter.PlayletID > 0 {
|
||||
playletID = chapter.PlayletID
|
||||
detailURL = AbsURL(siteURL, playletPath("/detail", "", playletID))
|
||||
}
|
||||
}
|
||||
|
||||
videoName := playletTitle
|
||||
if episode > 0 {
|
||||
videoName = fmt.Sprintf("%s - 第%d集", playletTitle, episode)
|
||||
}
|
||||
|
||||
crumbs := [][2]string{{"首页", AbsURL(siteURL, "/")}}
|
||||
if playletID > 0 {
|
||||
crumbs = append(crumbs, [2]string{playletTitle, detailURL})
|
||||
}
|
||||
crumbs = append(crumbs, [2]string{videoName, playURL})
|
||||
|
||||
page := contentWebPageNode(siteURL, playURL, videoName, desc, "WebPage")
|
||||
page["breadcrumb"] = ldThing{"@id": playURL + "#breadcrumb"}
|
||||
nodes := []any{page, breadcrumb(siteURL, crumbs)}
|
||||
|
||||
if playlet != nil {
|
||||
series := ldThing{
|
||||
"@type": "TVSeries",
|
||||
"@id": seriesID,
|
||||
"name": playletTitle,
|
||||
"description": firstNonEmpty(playlet.SeoInfo.Description, playlet.Desc, desc),
|
||||
"url": detailURL,
|
||||
"inLanguage": "zh-CN",
|
||||
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
||||
}
|
||||
if cover != "" {
|
||||
series["image"] = []string{cover}
|
||||
series["thumbnailUrl"] = []string{cover}
|
||||
}
|
||||
if totalEpisodes > 0 {
|
||||
series["numberOfEpisodes"] = totalEpisodes
|
||||
}
|
||||
if len(genres) > 0 {
|
||||
series["genre"] = genres
|
||||
}
|
||||
if published != "" {
|
||||
series["datePublished"] = published
|
||||
}
|
||||
if modified != "" {
|
||||
series["dateModified"] = modified
|
||||
}
|
||||
nodes = append(nodes, series)
|
||||
}
|
||||
|
||||
// Google 的 VideoObject 富媒体结果要求 name、thumbnailUrl、uploadDate。
|
||||
// 缺少真实封面或发布日期时只输出 WebPage,避免用虚构值凑齐必填字段。
|
||||
if cover == "" || published == "" {
|
||||
return nodes
|
||||
}
|
||||
|
||||
videoID := playURL + "#video"
|
||||
video := ldThing{
|
||||
"@type": "VideoObject",
|
||||
"@id": videoID,
|
||||
"name": videoName,
|
||||
"description": desc,
|
||||
"url": playURL,
|
||||
"mainEntityOfPage": ldThing{"@id": playURL + "#webpage"},
|
||||
"inLanguage": "zh-CN",
|
||||
"isFamilyFriendly": false,
|
||||
"contentRating": "adult",
|
||||
"isPartOf": ldThing{"@id": siteURL + "/#website"},
|
||||
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
||||
"potentialAction": ldThing{
|
||||
"@type": "WatchAction",
|
||||
"target": ldThing{
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": playURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
video["thumbnailUrl"] = []string{cover}
|
||||
video["image"] = []string{cover}
|
||||
video["uploadDate"] = published
|
||||
if modified != "" {
|
||||
video["dateModified"] = modified
|
||||
}
|
||||
if contentURL != "" {
|
||||
video["contentUrl"] = contentURL
|
||||
}
|
||||
if duration > 0 {
|
||||
video["duration"] = fmt.Sprintf("PT%dS", duration)
|
||||
}
|
||||
if chapter != nil && chapter.CountBrowse > 0 {
|
||||
video["interactionStatistic"] = ldThing{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": ldThing{"@type": "WatchAction"},
|
||||
"userInteractionCount": chapter.CountBrowse,
|
||||
}
|
||||
}
|
||||
if seriesID != "" {
|
||||
video["partOfSeries"] = ldThing{"@id": seriesID}
|
||||
} else if playletID > 0 {
|
||||
video["partOfSeries"] = ldThing{"@type": "TVSeries", "name": playletTitle, "url": detailURL}
|
||||
}
|
||||
if episode > 0 {
|
||||
video["episodeNumber"] = episode
|
||||
}
|
||||
if len(genres) > 0 {
|
||||
video["genre"] = genres
|
||||
video["keywords"] = strings.Join(genres, ",")
|
||||
}
|
||||
if chapter != nil {
|
||||
if chapter.Width > 0 {
|
||||
video["width"] = chapter.Width
|
||||
}
|
||||
if chapter.Height > 0 {
|
||||
video["height"] = chapter.Height
|
||||
}
|
||||
if chapter.MediaSize > 0 {
|
||||
video["contentSize"] = fmt.Sprintf("%d bytes", chapter.MediaSize)
|
||||
}
|
||||
}
|
||||
|
||||
page["mainEntity"] = ldThing{"@id": videoID}
|
||||
nodes = append(nodes, video)
|
||||
if episode > 0 {
|
||||
ep := ldThing{
|
||||
"@type": "TVEpisode",
|
||||
"@id": playURL + "#episode",
|
||||
"name": videoName,
|
||||
"description": desc,
|
||||
"episodeNumber": episode,
|
||||
"url": playURL,
|
||||
"associatedMedia": ldThing{"@id": videoID},
|
||||
}
|
||||
if seriesID != "" {
|
||||
ep["partOfSeries"] = ldThing{"@id": seriesID}
|
||||
} else {
|
||||
ep["partOfSeries"] = ldThing{"@type": "TVSeries", "name": playletTitle, "url": detailURL}
|
||||
}
|
||||
if cover != "" {
|
||||
ep["image"] = []string{cover}
|
||||
}
|
||||
if published != "" {
|
||||
ep["datePublished"] = published
|
||||
}
|
||||
nodes = append(nodes, ep)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func webpageNode(siteURL, pageURL string, data map[string]any, cfg configser.AppConfigVM) ldThing {
|
||||
name := strFrom(data["title"])
|
||||
if name == "" {
|
||||
name = strFrom(data["pageName"])
|
||||
}
|
||||
if name == "" {
|
||||
name = brandName
|
||||
}
|
||||
pathOnly := pageURL
|
||||
if i := strings.IndexByte(pathOnly, '?'); i >= 0 {
|
||||
pathOnly = pathOnly[:i]
|
||||
}
|
||||
if j := strings.Index(pathOnly, "://"); j >= 0 {
|
||||
if k := strings.IndexByte(pathOnly[j+3:], '/'); k >= 0 {
|
||||
pathOnly = pathOnly[j+3+k:]
|
||||
} else {
|
||||
pathOnly = "/"
|
||||
}
|
||||
}
|
||||
pathOnly = strings.TrimSuffix(pathOnly, "/")
|
||||
if pathOnly == "" {
|
||||
pathOnly = "/"
|
||||
}
|
||||
brand := siteName(cfg)
|
||||
if !strings.Contains(name, brand) && pathOnly != "/" {
|
||||
name = name + " - " + brand
|
||||
}
|
||||
desc := pageDescription(data, cfg, pathOnly)
|
||||
if desc == "" {
|
||||
desc = brandName + "短剧在线观看。"
|
||||
}
|
||||
typ := "WebPage"
|
||||
switch {
|
||||
case isCollectionPath(pathOnly):
|
||||
typ = "CollectionPage"
|
||||
case pathOnly == "/about-us" || pathOnly == "/aboutUs":
|
||||
typ = "AboutPage"
|
||||
}
|
||||
return contentWebPageNode(siteURL, pageURL, name, desc, typ)
|
||||
}
|
||||
|
||||
func siteName(cfg configser.AppConfigVM) string {
|
||||
return firstNonEmpty(cfg.SeoInfo.WebSiteName, brandName)
|
||||
}
|
||||
|
||||
func contentWebPageNode(siteURL, pageURL, name, desc, typ string) ldThing {
|
||||
return ldThing{
|
||||
"@type": typ,
|
||||
"@id": pageURL + "#webpage",
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"url": pageURL,
|
||||
"inLanguage": "zh-CN",
|
||||
"isPartOf": ldThing{"@id": siteURL + "/#website"},
|
||||
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
||||
}
|
||||
}
|
||||
|
||||
func isCollectionPath(path string) bool {
|
||||
for _, prefix := range []string{"/list", "/series", "/rank", "/today-update", "/topic", "/tag", "/category"} {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return path == "/application" || path == "/nav"
|
||||
}
|
||||
|
||||
// itemListNode 为首页及列表型页面输出当前 SSR 首屏实际展示的站内详情链接。
|
||||
// Google 的 summary-page ItemList 只需要 position 与唯一的站内详情 URL。
|
||||
func itemListNode(siteURL, pageURL string, data map[string]any) ldThing {
|
||||
urls := make([]string, 0, 24)
|
||||
appendPlaylet := func(slug string, id int64) {
|
||||
path := playletser.DetailPath(slug, id)
|
||||
if path != "#" {
|
||||
urls = append(urls, AbsURL(siteURL, path))
|
||||
}
|
||||
}
|
||||
|
||||
if list, ok := data["list"].([]playletser.CommonData); ok {
|
||||
for _, item := range list {
|
||||
appendPlaylet(item.Slug, item.ID)
|
||||
}
|
||||
}
|
||||
if categories, ok := data["homeCategoryData"].([]playletser.HomeCategoryData); ok {
|
||||
for _, category := range categories {
|
||||
for _, item := range category.List {
|
||||
appendPlaylet(item.Slug, item.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if banners, ok := data["homeBannerData"].([]playletser.HomeBannerData); ok {
|
||||
for _, item := range banners {
|
||||
if itemURL := structuredURL(siteURL, item.URL); itemURL != "" {
|
||||
urls = append(urls, itemURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
if themes, ok := data["list"].([]playletser.PlayletThemeItem); ok {
|
||||
for _, item := range themes {
|
||||
path := playletser.RecommendCanonicalPath(item.ID, item.Slug)
|
||||
urls = append(urls, AbsURL(siteURL, path))
|
||||
}
|
||||
}
|
||||
// 福利应用页是一个可见的应用集合;使用页面实际渲染的链接生成通用
|
||||
// Schema.org ItemList。应用链接为外站落地页,因此不宣称具备 Google
|
||||
// 站内轮播资格,只帮助搜索引擎理解页面集合语义。
|
||||
if categories, ok := data["categories"].([]applistser.CategoryVM); ok {
|
||||
for _, category := range categories {
|
||||
for _, app := range category.Apps {
|
||||
appURL := structuredURL(siteURL, app.URL)
|
||||
if appURL != "" {
|
||||
urls = append(urls, appURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(urls))
|
||||
unique := make([]string, 0, len(urls))
|
||||
for _, itemURL := range urls {
|
||||
itemURL = strings.TrimSpace(itemURL)
|
||||
if itemURL == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[itemURL]; exists {
|
||||
continue
|
||||
}
|
||||
seen[itemURL] = struct{}{}
|
||||
unique = append(unique, itemURL)
|
||||
}
|
||||
if len(unique) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
start := 1
|
||||
offset := intFrom(data["itemListOffset"])
|
||||
if offset == 0 {
|
||||
offset = intFrom(data["rankOffset"])
|
||||
}
|
||||
if offset > 0 {
|
||||
start += offset
|
||||
}
|
||||
items := make([]any, 0, len(unique))
|
||||
for i, itemURL := range unique {
|
||||
items = append(items, ldThing{
|
||||
"@type": "ListItem",
|
||||
"position": start + i,
|
||||
"url": itemURL,
|
||||
})
|
||||
}
|
||||
return ldThing{
|
||||
"@type": "ItemList",
|
||||
"@id": pageURL + "#itemlist",
|
||||
"numberOfItems": len(items),
|
||||
"itemListElement": items,
|
||||
}
|
||||
}
|
||||
|
||||
func faqPageNode(siteURL, pageURL string, data map[string]any) ldThing {
|
||||
faqItems, ok := data["faqItems"].([]FAQItem)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
questions := make([]any, 0, len(faqItems))
|
||||
for _, item := range faqItems {
|
||||
question := strings.TrimSpace(item.Question)
|
||||
answer := strings.TrimSpace(item.Answer)
|
||||
if question == "" || answer == "" {
|
||||
continue
|
||||
}
|
||||
questions = append(questions, ldThing{
|
||||
"@type": "Question",
|
||||
"name": question,
|
||||
"acceptedAnswer": ldThing{
|
||||
"@type": "Answer",
|
||||
"text": answer,
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(questions) == 0 {
|
||||
return nil
|
||||
}
|
||||
return ldThing{
|
||||
"@type": "FAQPage",
|
||||
"@id": pageURL + "#faq",
|
||||
"url": pageURL,
|
||||
"inLanguage": "zh-CN",
|
||||
"isPartOf": ldThing{"@id": pageURL + "#webpage"},
|
||||
"mainEntity": questions,
|
||||
"publisher": ldThing{"@id": siteURL + "/#organization"},
|
||||
}
|
||||
}
|
||||
|
||||
func breadcrumb(siteURL string, items [][2]string) ldThing {
|
||||
list := make([]any, 0, len(items))
|
||||
for i, item := range items {
|
||||
name, loc := strings.TrimSpace(item[0]), strings.TrimSpace(item[1])
|
||||
if name == "" || loc == "" {
|
||||
continue
|
||||
}
|
||||
list = append(list, ldThing{
|
||||
"@type": "ListItem",
|
||||
"position": i + 1,
|
||||
"name": name,
|
||||
"item": loc,
|
||||
})
|
||||
}
|
||||
pageURL := ""
|
||||
if len(items) > 0 {
|
||||
pageURL = strings.TrimSpace(items[len(items)-1][1])
|
||||
}
|
||||
return ldThing{
|
||||
"@type": "BreadcrumbList",
|
||||
"@id": pageURL + "#breadcrumb",
|
||||
"itemListElement": list,
|
||||
}
|
||||
}
|
||||
|
||||
func genericBreadcrumb(siteURL, pageURL string, data map[string]any) ldThing {
|
||||
if pageURL == "" || pageURL == AbsURL(siteURL, "/") {
|
||||
return nil
|
||||
}
|
||||
path := pagePath(pageURL)
|
||||
if !hasVisibleBreadcrumb(path) {
|
||||
return nil
|
||||
}
|
||||
name := firstNonEmpty(strFrom(data["pageTitle"]), strFrom(data["pageName"]), strFrom(data["title"]), "内容页")
|
||||
if strFrom(data["pageKey"]) == "topic" && strings.HasPrefix(path, "/topic/") {
|
||||
return breadcrumb(siteURL, [][2]string{
|
||||
{"首页", AbsURL(siteURL, "/")},
|
||||
{"专题", AbsURL(siteURL, "/topic/")},
|
||||
{name, pageURL},
|
||||
})
|
||||
}
|
||||
return breadcrumb(siteURL, [][2]string{
|
||||
{"首页", AbsURL(siteURL, "/")},
|
||||
{name, pageURL},
|
||||
})
|
||||
}
|
||||
|
||||
func hasVisibleBreadcrumb(path string) bool {
|
||||
for _, prefix := range []string{"/list", "/series", "/rank", "/today-update", "/topic", "/tag", "/category", "/richtext", "/application"} {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
switch path {
|
||||
case "/home-road", "/about-us", "/aboutUs", "/user-agreement", "/userAgreement",
|
||||
"/privacy-policy", "/privacyPolicy", "/help-center", "/helpCenter":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func structuredURL(siteURL, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "#" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "/") {
|
||||
return AbsURL(siteURL, raw)
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return ""
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func pagePath(pageURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(pageURL))
|
||||
if err == nil && u.Path != "" {
|
||||
return strings.TrimSuffix(u.Path, "/")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pageDescription(data map[string]any, cfg configser.AppConfigVM, path string) string {
|
||||
if page, ok := asRichText(data["richtext"]); ok && page.Found {
|
||||
return firstNonEmpty(page.Subtitle, page.Title+" - 黄果短剧官方内容。")
|
||||
}
|
||||
switch seo := data["seoInfo"].(type) {
|
||||
case playletser.SeoInfo:
|
||||
if desc := strings.TrimSpace(seo.Description); desc != "" {
|
||||
return desc
|
||||
}
|
||||
case *playletser.SeoInfo:
|
||||
if seo != nil {
|
||||
if desc := strings.TrimSpace(seo.Description); desc != "" {
|
||||
return desc
|
||||
}
|
||||
}
|
||||
}
|
||||
if desc := firstNonEmpty(
|
||||
strFrom(data["topicDescription"]),
|
||||
strFrom(data["tagDescription"]),
|
||||
strFrom(data["categoryDescription"]),
|
||||
); desc != "" {
|
||||
return desc
|
||||
}
|
||||
if path == "/" {
|
||||
return firstNonEmpty(strings.TrimSpace(cfg.SeoInfo.Description), "黄果短剧汇集热门短剧、今日更新与精选推荐,免费在线观看高清短剧。")
|
||||
}
|
||||
if path == "/nav" {
|
||||
return firstNonEmpty(strings.TrimSpace(cfg.SeoNavInfo.DhDescription), "黄果短剧官方导航页,提供最新访问线路、永久地址、APP 下载与官方联系方式。")
|
||||
}
|
||||
for prefix, desc := range map[string]string{
|
||||
"/list": "浏览黄果短剧全部短剧列表,按分类、标签筛选海量高清短剧,发现更多精彩剧集。",
|
||||
"/series": "黄果短剧精选AI成人剧集,持续更新热门漫剧、独家原创剧集,支持免费在线观看。",
|
||||
"/rank": "黄果短剧排行榜实时更新热播短剧,查看热门榜、飙升榜。",
|
||||
"/today-update": "黄果短剧今日更新,第一时间追更最新上线短剧与连载剧集。",
|
||||
"/topic": "黄果短剧精选短剧专题与专题作品,方便按主题发现内容。",
|
||||
"/tag": "黄果短剧标签内容页,按题材标签浏览相关短剧。",
|
||||
"/category": "黄果短剧分类内容页,按内容分类浏览最新上架短剧。",
|
||||
"/richtext": "黄果短剧官方说明与帮助内容。",
|
||||
} {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+"/") {
|
||||
return desc
|
||||
}
|
||||
}
|
||||
switch path {
|
||||
case "/application":
|
||||
return "黄果短剧福利应用中心,精选实用应用与福利入口。"
|
||||
case "/download-app":
|
||||
return "下载黄果短剧APP,随时随地追看热门短剧。"
|
||||
case "/home-road":
|
||||
return "黄果短剧官方最新地址发布页,实时同步国内可用线路、永久入口与防失联联系方式。"
|
||||
case "/about-us", "/aboutUs":
|
||||
return "了解黄果短剧的品牌、产品与服务。"
|
||||
case "/user-agreement", "/userAgreement":
|
||||
return "黄果短剧用户协议,说明使用平台服务的权利与义务。"
|
||||
case "/privacy-policy", "/privacyPolicy":
|
||||
return "黄果短剧隐私政策,说明个人信息的收集、使用与保护方式。"
|
||||
case "/help-center", "/helpCenter":
|
||||
return "黄果短剧帮助中心,提供账号、观影、充值与常见问题解答。"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func intFrom(v any) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int32:
|
||||
return int(n)
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func playletPath(route, slug string, id int64) string {
|
||||
key := strings.TrimSpace(slug)
|
||||
if key == "" && id > 0 {
|
||||
key = strconv.FormatInt(id, 10)
|
||||
}
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(route, "/") + "/" + url.PathEscape(key) + "/"
|
||||
}
|
||||
|
||||
func chapterSEODesc(ch *playletser.PlayletChapterInfoResp) string {
|
||||
if ch == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(ch.SeoInfo.Description)
|
||||
}
|
||||
|
||||
func asPlaylet(v any) (*playletser.PlayletInfoResp, bool) {
|
||||
switch t := v.(type) {
|
||||
case *playletser.PlayletInfoResp:
|
||||
if t != nil {
|
||||
return t, true
|
||||
}
|
||||
case playletser.PlayletInfoResp:
|
||||
return &t, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func asChapter(v any) (*playletser.PlayletChapterInfoResp, bool) {
|
||||
switch t := v.(type) {
|
||||
case *playletser.PlayletChapterInfoResp:
|
||||
if t != nil {
|
||||
return t, true
|
||||
}
|
||||
case playletser.PlayletChapterInfoResp:
|
||||
return &t, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func asRichText(v any) (configser.RichTextPageVM, bool) {
|
||||
switch t := v.(type) {
|
||||
case configser.RichTextPageVM:
|
||||
return t, true
|
||||
case *configser.RichTextPageVM:
|
||||
if t != nil {
|
||||
return *t, true
|
||||
}
|
||||
}
|
||||
return configser.RichTextPageVM{}, false
|
||||
}
|
||||
|
||||
func absMedia(siteURL, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
return AbsURL(siteURL, raw)
|
||||
}
|
||||
|
||||
// videoContentURL 仅接受可定位真实媒体字节的 HTTP(S) 或站内绝对路径。
|
||||
// 诸如哈希、播放器状态值等上游字段不能误标为 Google VideoObject.contentUrl。
|
||||
func videoContentURL(siteURL, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "/") {
|
||||
return AbsURL(siteURL, raw)
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func isoDate(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "0001") || strings.HasPrefix(raw, "1970-01-01") {
|
||||
return ""
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02",
|
||||
"2006/01/02 15:04:05",
|
||||
"2006/01/02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
if len(raw) >= 10 {
|
||||
return raw[:10]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isoDateTime 为 Google VideoObject.uploadDate 生成带时区的 ISO 8601 DateTime。
|
||||
// 上游无时区的中文站点时间按 UTC+8 解释,避免让 Google 自行猜测时区。
|
||||
func isoDateTime(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "0001") || strings.HasPrefix(raw, "1970-01-01") {
|
||||
return ""
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return parsed.Format(time.RFC3339)
|
||||
}
|
||||
chinaTime := time.FixedZone("UTC+8", 8*60*60)
|
||||
for _, layout := range []string{
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006/01/02 15:04:05",
|
||||
"2006-01-02",
|
||||
"2006/01/02",
|
||||
} {
|
||||
if parsed, err := time.ParseInLocation(layout, raw, chinaTime); err == nil {
|
||||
return parsed.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func uniqueStrings(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func strFrom(v any) string {
|
||||
s, _ := v.(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"testing"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/playletser"
|
||||
)
|
||||
|
||||
func TestPageJSONLD_DetailIsValidJSON(t *testing.T) {
|
||||
detail := &playletser.PlayletInfoResp{
|
||||
ID: 12,
|
||||
Slug: "test-show",
|
||||
Title: "测试短剧",
|
||||
Desc: "剧情简介",
|
||||
Cover: "https://cdn.example.com/cover.jpg",
|
||||
OnlineAt: "2026-01-02 15:04:05",
|
||||
TotalEpisode: 30,
|
||||
TagNames: []string{"都市"},
|
||||
}
|
||||
raw := PageJSONLD("https://www.example.com", "https://www.example.com/detail/test-show", "/detail/test-show", map[string]any{"detail": detail}, configser.AppConfigVM{})
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("json-ld invalid: %v\n%s", err, raw)
|
||||
}
|
||||
graph, _ := parsed["@graph"].([]any)
|
||||
if len(graph) < 3 {
|
||||
t.Fatalf("expected organization + website + detail nodes, got %d", len(graph))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageJSONLD_PlayHasVideoObject(t *testing.T) {
|
||||
playlet := &playletser.PlayletInfoResp{
|
||||
ID: 1,
|
||||
Slug: "example-show",
|
||||
Title: "剧",
|
||||
Cover: "https://cdn.example.com/a.jpg",
|
||||
MediaCreatedAt: "2026-03-01 08:30:00",
|
||||
MediaUpdatedAt: "2026-03-02 09:45:00",
|
||||
TotalEpisode: 12,
|
||||
TagNames: []string{"都市", "甜宠"},
|
||||
}
|
||||
chapter := &playletser.PlayletChapterInfoResp{
|
||||
ID: 9,
|
||||
PlayletID: 1,
|
||||
CurrentEpisode: 2,
|
||||
MediaTime: 90,
|
||||
MediaSize: 2048,
|
||||
Width: 1080,
|
||||
Height: 1920,
|
||||
VideoURL: "https://cdn.example.com/episode-2.m3u8",
|
||||
CountBrowse: 321,
|
||||
}
|
||||
raw := PageJSONLD("https://www.example.com", "https://www.example.com/play/example-show/9", "/play/example-show/9", map[string]any{
|
||||
"playlet": playlet,
|
||||
"chapter": chapter,
|
||||
}, configser.AppConfigVM{})
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("json-ld invalid: %v", err)
|
||||
}
|
||||
found := false
|
||||
foundSeries := false
|
||||
foundEpisode := false
|
||||
for _, node := range parsed["@graph"].([]any) {
|
||||
m, _ := node.(map[string]any)
|
||||
if m["@type"] == "TVSeries" {
|
||||
foundSeries = true
|
||||
if m["numberOfEpisodes"] != float64(12) || m["datePublished"] == nil {
|
||||
t.Fatalf("TVSeries missing episode/date fields: %v", m)
|
||||
}
|
||||
}
|
||||
if m["@type"] == "TVEpisode" {
|
||||
foundEpisode = true
|
||||
if m["episodeNumber"] != float64(2) || m["associatedMedia"] == nil {
|
||||
t.Fatalf("TVEpisode missing episode/media relation: %v", m)
|
||||
}
|
||||
}
|
||||
if m["@type"] == "VideoObject" {
|
||||
found = true
|
||||
if m["uploadDate"] == nil || m["thumbnailUrl"] == nil {
|
||||
t.Fatalf("VideoObject missing required fields: %v", m)
|
||||
}
|
||||
if m["uploadDate"] != "2026-03-01T08:30:00+08:00" {
|
||||
t.Fatalf("VideoObject uploadDate = %v", m["uploadDate"])
|
||||
}
|
||||
if m["dateModified"] != "2026-03-02T09:45:00+08:00" {
|
||||
t.Fatalf("VideoObject dateModified = %v", m["dateModified"])
|
||||
}
|
||||
if m["contentUrl"] != "https://cdn.example.com/episode-2.m3u8" {
|
||||
t.Fatalf("VideoObject contentUrl = %v", m["contentUrl"])
|
||||
}
|
||||
if m["duration"] != "PT90S" || m["episodeNumber"] != float64(2) {
|
||||
t.Fatalf("VideoObject missing duration/episode: %v", m)
|
||||
}
|
||||
if m["contentRating"] != "adult" || m["isFamilyFriendly"] != false {
|
||||
t.Fatalf("VideoObject missing adult flags: %v", m)
|
||||
}
|
||||
if m["partOfSeries"] == nil || m["genre"] == nil {
|
||||
t.Fatalf("VideoObject missing series/genre relation: %v", m)
|
||||
}
|
||||
if _, exists := m["embedUrl"]; exists {
|
||||
t.Fatalf("landing page must not be emitted as embedUrl: %v", m["embedUrl"])
|
||||
}
|
||||
if m["interactionStatistic"] == nil {
|
||||
t.Fatalf("VideoObject missing interactionStatistic: %v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing VideoObject")
|
||||
}
|
||||
if !foundSeries || !foundEpisode {
|
||||
t.Fatalf("missing TVSeries/TVEpisode: series=%v episode=%v", foundSeries, foundEpisode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageJSONLD_PaginatedCollectionHasItemList(t *testing.T) {
|
||||
raw := PageJSONLD("https://www.example.com", "https://www.example.com/list/ai-short-play/page/2", "/list/ai-short-play/page/2", map[string]any{
|
||||
"title": "短剧第2页",
|
||||
"rankOffset": 20,
|
||||
"list": []playletser.CommonData{
|
||||
{ID: 1, Slug: "first"},
|
||||
{ID: 2, Slug: "second"},
|
||||
},
|
||||
}, configser.AppConfigVM{})
|
||||
graph := parseGraph(t, raw)
|
||||
page := findNodeByType(t, graph, "CollectionPage")
|
||||
if page["mainEntity"] == nil {
|
||||
t.Fatalf("CollectionPage missing ItemList reference: %v", page)
|
||||
}
|
||||
items := findNodeByType(t, graph, "ItemList")
|
||||
entries, _ := items["itemListElement"].([]any)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("ItemList entries = %d", len(entries))
|
||||
}
|
||||
first, _ := entries[0].(map[string]any)
|
||||
if first["position"] != float64(21) || first["url"] != "https://www.example.com/detail/first" {
|
||||
t.Fatalf("first ItemList entry = %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageJSONLD_FAQMatchesVisibleItems(t *testing.T) {
|
||||
raw := PageJSONLD("https://www.example.com", "https://www.example.com/home-road", "/home-road", map[string]any{
|
||||
"title": "回家的路",
|
||||
"faqItems": []FAQItem{
|
||||
{Question: "入口打不开怎么办?", Answer: "请切换备用入口。"},
|
||||
{Question: "如何确认安全?", Answer: "仅使用官方发布的地址。"},
|
||||
},
|
||||
}, configser.AppConfigVM{})
|
||||
graph := parseGraph(t, raw)
|
||||
faq := findNodeByType(t, graph, "FAQPage")
|
||||
questions, _ := faq["mainEntity"].([]any)
|
||||
if len(questions) != 2 {
|
||||
t.Fatalf("FAQ questions = %d", len(questions))
|
||||
}
|
||||
question, _ := questions[0].(map[string]any)
|
||||
if question["name"] != "入口打不开怎么办?" {
|
||||
t.Fatalf("FAQ question = %v", question)
|
||||
}
|
||||
findNodeByType(t, graph, "BreadcrumbList")
|
||||
}
|
||||
|
||||
func TestPageJSONLD_DetailBreadcrumbMatchesVisibleHierarchy(t *testing.T) {
|
||||
detail := &playletser.PlayletInfoResp{ID: 12, Slug: "test-show", Title: "测试短剧"}
|
||||
raw := PageJSONLD("https://www.example.com", "https://www.example.com/detail/test-show", "/detail/test-show", map[string]any{"detail": detail}, configser.AppConfigVM{})
|
||||
crumb := findNodeByType(t, parseGraph(t, raw), "BreadcrumbList")
|
||||
items, _ := crumb["itemListElement"].([]any)
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("detail breadcrumb items = %d", len(items))
|
||||
}
|
||||
middle, _ := items[1].(map[string]any)
|
||||
if middle["name"] != "短剧" || middle["item"] != "https://www.example.com/list/ai-short-play" {
|
||||
t.Fatalf("detail breadcrumb middle item = %v", middle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageJSONLD_SkipNoindex(t *testing.T) {
|
||||
raw := PageJSONLD("https://www.example.com", "", "/setting", map[string]any{"title": "账号设置"}, configser.AppConfigVM{})
|
||||
if string(raw) != "" {
|
||||
t.Fatalf("noindex page should not emit json-ld, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIndexablePath(t *testing.T) {
|
||||
if !IsIndexablePath("/") || !IsIndexablePath("/detail/example-show") || !IsIndexablePath("/topic/editor-picks") || !IsIndexablePath("/help-center") {
|
||||
t.Fatal("public pages should be indexable")
|
||||
}
|
||||
if IsIndexablePath("/setting") || IsIndexablePath("/user-center") || IsIndexablePath("/search") || IsIndexablePath("/richtext/custom") || IsIndexablePath("/privacy-policy") || IsIndexablePath("/user-agreement") {
|
||||
t.Fatal("private pages should not be indexable")
|
||||
}
|
||||
}
|
||||
|
||||
func parseGraph(t *testing.T, raw template.JS) []any {
|
||||
t.Helper()
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
t.Fatalf("json-ld invalid: %v\n%s", err, raw)
|
||||
}
|
||||
graph, _ := parsed["@graph"].([]any)
|
||||
return graph
|
||||
}
|
||||
|
||||
func findNodeByType(t *testing.T, graph []any, typ string) map[string]any {
|
||||
t.Helper()
|
||||
for _, node := range graph {
|
||||
m, _ := node.(map[string]any)
|
||||
if m["@type"] == typ {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing %s node in %v", typ, graph)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildRobotsTXT 动态 robots.txt:优先读取 templates/page/robots.txt,并把 Sitemap 写成绝对地址。
|
||||
func BuildRobotsTXT(siteURL string) string {
|
||||
sitemap := AbsURL(siteURL, "/sitemap.xml")
|
||||
body := defaultRobotsTXT
|
||||
if raw, err := os.ReadFile("templates/page/robots.txt"); err == nil {
|
||||
if t := strings.TrimSpace(string(raw)); t != "" {
|
||||
body = t
|
||||
}
|
||||
}
|
||||
body = strings.ReplaceAll(body, "\r\n", "\n")
|
||||
body = sitemapLineRe.ReplaceAllString(body, "Sitemap: "+sitemap)
|
||||
if !strings.Contains(body, "Sitemap:") {
|
||||
body = strings.TrimRight(body, "\n") + "\n\nSitemap: " + sitemap + "\n"
|
||||
} else if !strings.HasSuffix(body, "\n") {
|
||||
body += "\n"
|
||||
}
|
||||
if host := hostFromSiteURL(siteURL); host != "" && !strings.Contains(body, "Host:") {
|
||||
body = strings.TrimRight(body, "\n") + "\nHost: " + host + "\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func hostFromSiteURL(siteURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(siteURL))
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
var sitemapLineRe = regexp.MustCompile(`(?m)^Sitemap:\s*\S+`)
|
||||
|
||||
const defaultRobotsTXT = `User-agent: *
|
||||
Allow: /
|
||||
|
||||
# 非 HTML 接口与工具路由不抓取
|
||||
Disallow: /api/
|
||||
Disallow: /ui/
|
||||
Disallow: /healthz
|
||||
Disallow: /h5/
|
||||
Disallow: /enkey
|
||||
Disallow: /newenkey
|
||||
|
||||
# 登录、搜索和账户 HTML 页面允许抓取,以便爬虫读取页面内 noindex。
|
||||
|
||||
Sitemap: /sitemap.xml
|
||||
`
|
||||
@@ -0,0 +1,29 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildRobotsTXTUsesSingleCrawlerGroup(t *testing.T) {
|
||||
body := BuildRobotsTXT("https://www.example.com")
|
||||
if got := strings.Count(body, "User-agent:"); got != 1 {
|
||||
t.Fatalf("User-agent group count = %d, want 1\n%s", got, body)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Disallow: /api/",
|
||||
"Disallow: /ui/",
|
||||
"Disallow: /healthz",
|
||||
"Sitemap: https://www.example.com/sitemap.xml",
|
||||
"Host: www.example.com",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("robots.txt missing %q\n%s", want, body)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"Disallow: /login", "Disallow: /search", "Disallow: /user-center"} {
|
||||
if strings.Contains(body, unwanted) {
|
||||
t.Fatalf("robots.txt should allow HTML page for noindex discovery: %q", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/service/configser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ResolveSiteURL 解析对外站点根地址(无尾斜杠)。
|
||||
// 优先有效正式域名;跳过占位域名后回退到当前请求 Host。
|
||||
func ResolveSiteURL(c *gin.Context, cfg configser.AppConfigVM) string {
|
||||
candidates := []string{
|
||||
cfg.SeoInfo.CanonicalURL,
|
||||
cfg.ForeverURLAddr,
|
||||
cfg.NewURLAddr,
|
||||
}
|
||||
for _, raw := range candidates {
|
||||
if u := usableSiteURL(raw); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
if c != nil && c.Request != nil {
|
||||
return requestOrigin(c.Request)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func usableSiteURL(raw string) string {
|
||||
u := sanitizeBase(raw)
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
host := strings.ToLower(originHost(u))
|
||||
if isPlaceholderHost(host) {
|
||||
return ""
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func originHost(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
func isPlaceholderHost(host string) bool {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
switch host {
|
||||
case "", "localhost", "127.0.0.1", "0.0.0.0", "www.xx.xxx", "xx.xxx", "example.com", "www.example.com":
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(host, ".example.com") || strings.HasSuffix(host, ".local")
|
||||
}
|
||||
|
||||
// AbsURL 将 path 或完整 URL 转为绝对地址。
|
||||
func AbsURL(siteURL, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
if siteURL == "" {
|
||||
return "/"
|
||||
}
|
||||
return siteURL + "/"
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
||||
return raw
|
||||
}
|
||||
if siteURL == "" {
|
||||
if strings.HasPrefix(raw, "/") {
|
||||
return raw
|
||||
}
|
||||
return "/" + raw
|
||||
}
|
||||
if strings.HasPrefix(raw, "/") {
|
||||
return siteURL + raw
|
||||
}
|
||||
return siteURL + "/" + raw
|
||||
}
|
||||
|
||||
// PageCanonicalURL 首页用 siteURL 根路径;其他页用 siteURL + 当前 path + query。
|
||||
// siteURL 为空时回退 foreverURLAddr。
|
||||
func PageCanonicalURL(siteURL, foreverURLAddr string, c *gin.Context) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(siteURL), "/")
|
||||
if base == "" {
|
||||
base = strings.TrimRight(strings.TrimSpace(usableSiteURL(foreverURLAddr)), "/")
|
||||
}
|
||||
if c == nil || c.Request == nil || c.Request.URL == nil {
|
||||
return AbsURL(base, "/")
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if path == "" || path == "/" {
|
||||
return AbsURL(base, "/")
|
||||
}
|
||||
return ensureTrailingSlash(AbsURL(base, relativeRequestURI(c.Request.URL)))
|
||||
}
|
||||
|
||||
// CanonicalWithoutQuery 移除 canonical 中的查询参数与片段,避免参数变体形成重复 URL。
|
||||
func CanonicalWithoutQuery(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
u.RawQuery = ""
|
||||
u.ForceQuery = false
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// ContentCanonicalURL 由实际内容生成 slug 路由。
|
||||
func ContentCanonicalURL(siteURL, fallback string, data map[string]any) string {
|
||||
if detail, ok := asPlaylet(data["detail"]); ok {
|
||||
if path := playletPath("/detail", detail.Slug, detail.ID); path != "" {
|
||||
return AbsURL(siteURL, path)
|
||||
}
|
||||
} else if chapter, ok := asChapter(data["chapter"]); ok {
|
||||
playlet, _ := asPlaylet(data["playlet"])
|
||||
playletID := chapter.PlayletID
|
||||
playletSlug := ""
|
||||
if playlet != nil {
|
||||
playletID = playlet.ID
|
||||
playletSlug = playlet.Slug
|
||||
}
|
||||
path := playletPath("/play", playletSlug, playletID)
|
||||
if path == "" {
|
||||
return fallback
|
||||
}
|
||||
if chapter.ID > 0 {
|
||||
path += strconv.FormatInt(chapter.ID, 10) + "/"
|
||||
}
|
||||
return AbsURL(siteURL, path)
|
||||
}
|
||||
return ensureTrailingSlash(fallback)
|
||||
}
|
||||
|
||||
// ensureTrailingSlash 将 HTML 页面 URL 统一为目录式尾斜杠形式,并保留查询参数。
|
||||
func ensureTrailingSlash(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
if u.Path == "" {
|
||||
u.Path = "/"
|
||||
}
|
||||
if u.Path != "/" && !strings.HasSuffix(u.Path, "/") {
|
||||
u.Path += "/"
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func relativeRequestURI(u *url.URL) string {
|
||||
if u == nil {
|
||||
return "/"
|
||||
}
|
||||
path := u.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return path + "?" + u.RawQuery
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func sanitizeBase(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
if !strings.Contains(raw, "://") && strings.Contains(raw, ".") {
|
||||
return sanitizeBase("https://" + strings.TrimPrefix(raw, "/"))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(u.Scheme+"://"+u.Host, "/")
|
||||
}
|
||||
|
||||
func requestOrigin(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
||||
if proto == "" {
|
||||
if r.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
if i := strings.IndexByte(proto, ','); i >= 0 {
|
||||
proto = strings.TrimSpace(proto[:i])
|
||||
}
|
||||
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(r.Host)
|
||||
}
|
||||
if i := strings.IndexByte(host, ','); i >= 0 {
|
||||
host = strings.TrimSpace(host[:i])
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(proto+"://"+host, "/")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"videoweb/app/service/configser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestResolveSiteURLUsesForeverURLAddr(t *testing.T) {
|
||||
cfg := configser.AppConfigVM{
|
||||
ForeverURLAddr: "https://forever.prod.com/path",
|
||||
}
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "https://temp.example.com/", nil)
|
||||
|
||||
got := ResolveSiteURL(c, cfg)
|
||||
want := "https://forever.prod.com"
|
||||
if got != want {
|
||||
t.Fatalf("ResolveSiteURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSiteURLFallbackRequestHost(t *testing.T) {
|
||||
cfg := configser.AppConfigVM{}
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "https://temp.example.com/", nil)
|
||||
|
||||
got := ResolveSiteURL(c, cfg)
|
||||
want := "https://temp.example.com"
|
||||
if got != want {
|
||||
t.Fatalf("ResolveSiteURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageCanonicalURLHomepageUsesSiteURL(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "https://mirror.example.com/", nil)
|
||||
|
||||
got := PageCanonicalURL("https://www.prod.com", "", c)
|
||||
want := "https://www.prod.com/"
|
||||
if got != want {
|
||||
t.Fatalf("PageCanonicalURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageCanonicalURLSubpageUsesSiteURLAndPath(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "https://mirror.example.com/list?theme=1&page=2", nil)
|
||||
|
||||
got := PageCanonicalURL("https://www.prod.com", "", c)
|
||||
want := "https://www.prod.com/list?theme=1&page=2"
|
||||
if got != want {
|
||||
t.Fatalf("PageCanonicalURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageCanonicalURLFallbackForeverURLAddr(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/detail/example-show", nil)
|
||||
|
||||
got := PageCanonicalURL("", "https://forever.prod.com/app", c)
|
||||
want := "https://forever.prod.com/detail/example-show"
|
||||
if got != want {
|
||||
t.Fatalf("PageCanonicalURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package seoser
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
"videoweb/app/appg"
|
||||
)
|
||||
|
||||
// VerifyCodes 站长平台所有权验证(meta + 根路径文件)。
|
||||
type VerifyCodes struct {
|
||||
Baidu string
|
||||
BaiduHTML string
|
||||
IndexNowKey string
|
||||
}
|
||||
|
||||
// VerifyFromConfig 读取 yaml 中的验证码与验证文件名。
|
||||
func VerifyFromConfig() VerifyCodes {
|
||||
seo := appg.Conf.SEO
|
||||
return VerifyCodes{
|
||||
Baidu: strings.TrimSpace(seo.BaiduSiteVerification),
|
||||
BaiduHTML: sanitizeVerifyFile(seo.BaiduHTMLFile),
|
||||
IndexNowKey: sanitizeIndexNowKey(seo.IndexNowKey),
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeVerifyFile(raw string) string {
|
||||
raw = strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/"))
|
||||
raw = path.Base(raw)
|
||||
if raw == "" || raw == "." || raw == "/" || strings.Contains(raw, "..") {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.HasSuffix(lower, ".html") || strings.HasSuffix(lower, ".htm") || strings.HasSuffix(lower, ".xml") {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeIndexNowKey(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range raw {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package translationser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"videoweb/app/service/upstream"
|
||||
"videoweb/pub/common/log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
categoryListPath = "/api/app/translation/category/list"
|
||||
categoryListCacheTTL = 60 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
navMu sync.Mutex
|
||||
navCache []HeaderNavItemVM
|
||||
navAt time.Time
|
||||
)
|
||||
|
||||
// FetchCategoryList 拉取导航分类列表。
|
||||
func FetchCategoryList(c *gin.Context) (*AppTranslationInfo, error) {
|
||||
var env APIEnvelope[AppTranslationInfo]
|
||||
if err := upstream.GetPublicJSON(c, categoryListPath, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Code != 200 {
|
||||
return nil, fmt.Errorf("translation category list code=%d msg=%s", env.Code, env.Msg)
|
||||
}
|
||||
return &env.Data, nil
|
||||
}
|
||||
|
||||
// FetchHeaderNavOrDefault 带短缓存;失败或空列表时返回默认导航。
|
||||
func FetchHeaderNavOrDefault(c *gin.Context) []HeaderNavItemVM {
|
||||
navMu.Lock()
|
||||
defer navMu.Unlock()
|
||||
if navCache != nil && time.Since(navAt) < categoryListCacheTTL {
|
||||
return append([]HeaderNavItemVM(nil), navCache...)
|
||||
}
|
||||
|
||||
info, err := FetchCategoryList(c)
|
||||
if err != nil {
|
||||
log.Error("fetch translation category list failed", log.E(err))
|
||||
return DefaultHeaderNav()
|
||||
}
|
||||
vm := BuildHeaderNav(info)
|
||||
if len(vm) == 0 {
|
||||
return DefaultHeaderNav()
|
||||
}
|
||||
navCache = vm
|
||||
navAt = time.Now()
|
||||
return append([]HeaderNavItemVM(nil), vm...)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package translationser
|
||||
|
||||
// AppTranslationInfo 对应 swagger proto.AppTranslationInfo
|
||||
// GET /api/app/translation/category/list
|
||||
type AppTranslationInfo struct {
|
||||
CategoryList []TranslationCategory `json:"categoryList"`
|
||||
}
|
||||
|
||||
// TranslationCategory 对应 swagger proto.TranslationCategory
|
||||
type TranslationCategory struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
URLValue string `json:"urlValue"`
|
||||
CategoryType int `json:"categoryType"` // 1 URL 2 二级 3 底部
|
||||
TranslationList []TranslationInfo `json:"translationList"`
|
||||
SeoInfo SeoValue `json:"seoInfo"`
|
||||
}
|
||||
|
||||
// SeoValue 对应 swagger pubdef.SeoValue(导航分类 seoInfo)
|
||||
type SeoValue struct {
|
||||
Title string `json:"title"`
|
||||
Keyworld []string `json:"keyworld"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// TranslationInfo 对应 swagger proto.TranslationInfo
|
||||
type TranslationInfo struct {
|
||||
Name string `json:"name"`
|
||||
URLValue string `json:"urlValue"`
|
||||
IsFriendAds bool `json:"isFriendAds"`
|
||||
}
|
||||
|
||||
// APIEnvelope 上游统一响应壳
|
||||
type APIEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Tip string `json:"tip"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
const (
|
||||
TranslationTypeURL = 1 // URL 模式
|
||||
TranslationTypeSub = 2 // 二级导航
|
||||
TranslationTypeBottom = 3 // 底部导航
|
||||
)
|
||||
@@ -0,0 +1,290 @@
|
||||
package translationser
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HeaderNavItemVM 顶部 / 抽屉导航项。
|
||||
type HeaderNavItemVM struct {
|
||||
ID int64
|
||||
Name string
|
||||
Href string
|
||||
Icon string
|
||||
Key string // 与页面 navActive 对齐
|
||||
IsActive bool
|
||||
SeoInfo SeoValue
|
||||
IsDownload bool
|
||||
IsExternal bool
|
||||
Children []HeaderNavChildVM
|
||||
}
|
||||
|
||||
// ResolveNavigationActiveKey 将当前路由归一到 PC 顶部导航和移动侧栏共用的栏目键。
|
||||
func ResolveNavigationActiveKey(activeKey, requestPath string) string {
|
||||
path := normalizeNavPath(requestPath)
|
||||
switch {
|
||||
case path == "/":
|
||||
return "home"
|
||||
case path == "/list/ai-short-play" || strings.HasPrefix(path, "/list/ai-short-play/"):
|
||||
return "aiShortPlay"
|
||||
case path == "/series" || strings.HasPrefix(path, "/series/") || path == "/list/ai-series" || strings.HasPrefix(path, "/list/ai-series/"):
|
||||
return "aiSeries"
|
||||
case path == "/tag" || strings.HasPrefix(path, "/tag/") || path == "/tag-list" || strings.HasPrefix(path, "/tag-list/"):
|
||||
return "tagList"
|
||||
case path == "/topic" || strings.HasPrefix(path, "/topic/"):
|
||||
return "topic"
|
||||
case path == "/rank" || strings.HasPrefix(path, "/rank/"):
|
||||
return "rank"
|
||||
case path == "/today-update" || strings.HasPrefix(path, "/today-update/"):
|
||||
return "todayUpdate"
|
||||
case strings.HasPrefix(path, "/category/"):
|
||||
return "category"
|
||||
case path == "/application" || strings.HasPrefix(path, "/application/"):
|
||||
return "application"
|
||||
case path == "/home-road" || strings.HasPrefix(path, "/home-road/"):
|
||||
return "homeRoad"
|
||||
case path == "/list" || strings.HasPrefix(path, "/detail/") || strings.HasPrefix(path, "/play/") || path == "/more" || strings.HasPrefix(path, "/more/") || path == "/search" || strings.HasPrefix(path, "/search/"):
|
||||
return "list"
|
||||
}
|
||||
if key := strings.TrimSpace(activeKey); key != "" {
|
||||
return key
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// MarkHeaderNavActive 返回带当前页选中态的导航副本。
|
||||
// PC 顶部导航仅在当前路径与导航路径完全一致时高亮,避免搜索、详情和分页页误亮栏目。
|
||||
func MarkHeaderNavActive(items []HeaderNavItemVM, requestPath string) []HeaderNavItemVM {
|
||||
currentPath := normalizeNavPath(requestPath)
|
||||
out := append([]HeaderNavItemVM(nil), items...)
|
||||
for i := range out {
|
||||
out[i].IsActive = headerNavItemMatchesPath(out[i], currentPath)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func headerNavItemMatchesPath(item HeaderNavItemVM, currentPath string) bool {
|
||||
if item.IsExternal || item.IsDownload {
|
||||
return false
|
||||
}
|
||||
href := strings.TrimSpace(item.Href)
|
||||
if href == "" || href == "#" || strings.HasPrefix(strings.ToLower(href), "javascript:") {
|
||||
return false
|
||||
}
|
||||
return normalizeNavPath(href) == currentPath
|
||||
}
|
||||
|
||||
// HeaderNavChildVM 二级导航。
|
||||
type HeaderNavChildVM struct {
|
||||
Name string
|
||||
Href string
|
||||
IsExternal bool
|
||||
IsFriendAds bool
|
||||
}
|
||||
|
||||
// DefaultHeaderNav 接口失败时的兜底导航。
|
||||
func DefaultHeaderNav() []HeaderNavItemVM {
|
||||
return []HeaderNavItemVM{
|
||||
{Name: "首页", Href: "/", Key: "home"},
|
||||
{Name: "短剧", Href: "/list/", Key: "list"},
|
||||
{Name: "排行榜", Href: "/rank/", Key: "rank"},
|
||||
{Name: "回家的路", Href: "/home-road/", Key: "homeRoad"},
|
||||
{Name: "今日更新", Href: "/today-update/", Key: "todayUpdate"},
|
||||
{Name: "下载APP", Href: "javascript:;", Key: "downloadApp", IsDownload: true},
|
||||
{Name: "福利应用", Href: "/application/", Key: "application"},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildHeaderNav 将上游分类转为模板可用导航(顶部 URL / 二级;底部类型留给抽屉扩展)。
|
||||
func BuildHeaderNav(info *AppTranslationInfo) []HeaderNavItemVM {
|
||||
if info == nil || len(info.CategoryList) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]HeaderNavItemVM, 0, len(info.CategoryList))
|
||||
for _, cat := range info.CategoryList {
|
||||
name := strings.TrimSpace(cat.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// 底部导航不进顶部栏(仍可由抽屉单独渲染时可再扩展)
|
||||
if cat.CategoryType == TranslationTypeBottom {
|
||||
continue
|
||||
}
|
||||
item := HeaderNavItemVM{
|
||||
ID: cat.ID,
|
||||
Name: name,
|
||||
Icon: strings.TrimSpace(cat.Icon),
|
||||
SeoInfo: normalizeNavigationSEO(cat.SeoInfo),
|
||||
}
|
||||
href, external, download := normalizeNavURL(cat.URLValue, name)
|
||||
item.Href = href
|
||||
item.IsExternal = external
|
||||
item.IsDownload = download
|
||||
item.Key = navActiveKey(href, name, download)
|
||||
|
||||
if cat.CategoryType == TranslationTypeSub || len(cat.TranslationList) > 0 {
|
||||
for _, child := range cat.TranslationList {
|
||||
cname := strings.TrimSpace(child.Name)
|
||||
if cname == "" {
|
||||
continue
|
||||
}
|
||||
ch, cex, _ := normalizeNavURL(child.URLValue, cname)
|
||||
if ch == "" {
|
||||
continue
|
||||
}
|
||||
item.Children = append(item.Children, HeaderNavChildVM{
|
||||
Name: cname,
|
||||
Href: ch,
|
||||
IsExternal: cex,
|
||||
IsFriendAds: child.IsFriendAds,
|
||||
})
|
||||
}
|
||||
}
|
||||
if item.Href == "" && len(item.Children) == 0 && !item.IsDownload {
|
||||
continue
|
||||
}
|
||||
if item.Href == "" && !item.IsDownload {
|
||||
item.Href = "javascript:;"
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FindHeaderNavSEO 优先匹配当前请求的最长导航路径,navActive 用于无实际 URL 的导航兜底。
|
||||
func FindHeaderNavSEO(items []HeaderNavItemVM, activeKey, requestPath string) (SeoValue, bool) {
|
||||
requestPath = normalizeNavPath(requestPath)
|
||||
bestLen := -1
|
||||
var best SeoValue
|
||||
for _, item := range items {
|
||||
if item.IsExternal || item.IsDownload || !hasNavigationSEO(item.SeoInfo) {
|
||||
continue
|
||||
}
|
||||
target := normalizeNavPath(item.Href)
|
||||
matched := requestPath == target
|
||||
if target != "/" && strings.HasPrefix(requestPath, target+"/") {
|
||||
matched = true
|
||||
}
|
||||
if matched && len(target) > bestLen {
|
||||
bestLen = len(target)
|
||||
best = item.SeoInfo
|
||||
}
|
||||
}
|
||||
if bestLen >= 0 {
|
||||
return best, true
|
||||
}
|
||||
|
||||
activeKey = strings.TrimSpace(activeKey)
|
||||
if activeKey != "" {
|
||||
for _, item := range items {
|
||||
if item.Key == activeKey && hasNavigationSEO(item.SeoInfo) {
|
||||
return item.SeoInfo, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return SeoValue{}, false
|
||||
}
|
||||
|
||||
func normalizeNavigationSEO(seo SeoValue) SeoValue {
|
||||
seo.Title = strings.TrimSpace(seo.Title)
|
||||
seo.Description = strings.TrimSpace(seo.Description)
|
||||
keywords := make([]string, 0, len(seo.Keyworld))
|
||||
seen := make(map[string]struct{}, len(seo.Keyworld))
|
||||
for _, keyword := range seo.Keyworld {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[keyword]; ok {
|
||||
continue
|
||||
}
|
||||
seen[keyword] = struct{}{}
|
||||
keywords = append(keywords, keyword)
|
||||
}
|
||||
seo.Keyworld = keywords
|
||||
return seo
|
||||
}
|
||||
|
||||
func hasNavigationSEO(seo SeoValue) bool {
|
||||
return seo.Title != "" || seo.Description != "" || len(seo.Keyworld) > 0
|
||||
}
|
||||
|
||||
func normalizeNavPath(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if parsed, err := url.Parse(raw); err == nil && parsed.Path != "" {
|
||||
raw = parsed.Path
|
||||
}
|
||||
if raw == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(raw, "/") {
|
||||
raw = "/" + raw
|
||||
}
|
||||
raw = strings.TrimRight(raw, "/")
|
||||
if raw == "" {
|
||||
return "/"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func normalizeNavURL(raw, _ string) (href string, external, download bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", false, false
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(raw, "//") {
|
||||
return raw, true, false
|
||||
}
|
||||
if raw == "#" || strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "tel:") {
|
||||
return raw, false, false
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Path == "" {
|
||||
return raw, false, false
|
||||
}
|
||||
if parsed.Path != "/" && !strings.HasSuffix(parsed.Path, "/") {
|
||||
parsed.Path += "/"
|
||||
}
|
||||
return parsed.String(), false, false
|
||||
}
|
||||
|
||||
func navActiveKey(href, name string, download bool) string {
|
||||
if download {
|
||||
return "downloadApp"
|
||||
}
|
||||
path := href
|
||||
if i := strings.Index(path, "?"); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
path = strings.TrimRight(path, "/")
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
switch path {
|
||||
case "/":
|
||||
return "home"
|
||||
case "/list":
|
||||
return "list"
|
||||
case "/rank":
|
||||
return "rank"
|
||||
case "/today-update":
|
||||
return "todayUpdate"
|
||||
case "/application":
|
||||
return "application"
|
||||
case "/tag":
|
||||
return "tagList"
|
||||
case "/topic":
|
||||
return "topic"
|
||||
case "/series":
|
||||
return "aiSeries"
|
||||
case "/home-road":
|
||||
return "homeRoad"
|
||||
case "/user-center":
|
||||
return "userCenter"
|
||||
case "/download-app":
|
||||
return "downloadApp"
|
||||
default:
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package translationser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveNavigationActiveKey(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"/": "home",
|
||||
"/list/ai-short-play": "aiShortPlay",
|
||||
"/list/ai-series/2/page/3": "aiSeries",
|
||||
"/series/original/page/3": "aiSeries",
|
||||
"/tag/urban/page/2": "tagList",
|
||||
"/tag-list/urban/page/2": "tagList",
|
||||
"/topic/editor-picks/page/2": "topic",
|
||||
"/rank/2/page/3": "rank",
|
||||
"/category/latest-video/": "category",
|
||||
"/application": "application",
|
||||
"/home-road": "homeRoad",
|
||||
"/detail/example-playlet": "list",
|
||||
"/play/example-playlet/chapter-1": "list",
|
||||
}
|
||||
for path, want := range tests {
|
||||
if got := ResolveNavigationActiveKey("", path); got != want {
|
||||
t.Fatalf("ResolveNavigationActiveKey(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkHeaderNavActiveRequiresExactPath(t *testing.T) {
|
||||
items := []HeaderNavItemVM{
|
||||
{Name: "首页", Href: "/", Key: "home"},
|
||||
{Name: "AI成人短剧", Href: "/category/today", Key: "list"},
|
||||
{Name: "专题", Href: "/topic", Key: "topic"},
|
||||
{Name: "排行榜", Href: "/rank", Key: "rank"},
|
||||
}
|
||||
|
||||
marked := MarkHeaderNavActive(items, "/category/today/")
|
||||
for i, item := range marked {
|
||||
want := i == 1
|
||||
if item.IsActive != want {
|
||||
t.Fatalf("item %q active = %v, want %v", item.Name, item.IsActive, want)
|
||||
}
|
||||
}
|
||||
|
||||
marked = MarkHeaderNavActive(items, "/search/keyword")
|
||||
for _, item := range marked {
|
||||
if item.IsActive {
|
||||
t.Fatalf("non-navigation path unexpectedly activated %q", item.Name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.IsActive {
|
||||
t.Fatalf("source navigation cache was mutated: %#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNavURLKeepsConfiguredURL(t *testing.T) {
|
||||
href, external, download := normalizeNavURL("/tag-list/fantasy/page/2", "专题")
|
||||
if href != "/tag-list/fantasy/page/2" || external || download {
|
||||
t.Fatalf("normalizeNavURL configured tag path = (%q, %v, %v)", href, external, download)
|
||||
}
|
||||
href, external, download = normalizeNavURL("/", "专题")
|
||||
if href != "/" || external || download {
|
||||
t.Fatalf("normalizeNavURL configured root = (%q, %v, %v)", href, external, download)
|
||||
}
|
||||
href, external, download = normalizeNavURL("/tag", "专题")
|
||||
if href != "/tag" || external || download {
|
||||
t.Fatalf("normalizeNavURL configured topic nav = (%q, %v, %v)", href, external, download)
|
||||
}
|
||||
href, external, download = normalizeNavURL("series", "AI成人剧集")
|
||||
if href != "series" || external || download {
|
||||
t.Fatalf("normalizeNavURL configured relative path = (%q, %v, %v)", href, external, download)
|
||||
}
|
||||
href, external, download = normalizeNavURL("https://old.example.com/home.pdf", "回家的路")
|
||||
if href != "https://old.example.com/home.pdf" || !external || download {
|
||||
t.Fatalf("normalizeNavURL configured external URL = (%q, %v, %v)", href, external, download)
|
||||
}
|
||||
href, external, download = normalizeNavURL("/download-app", "下载APP")
|
||||
if href != "/download-app" || external || download {
|
||||
t.Fatalf("normalizeNavURL configured download URL = (%q, %v, %v)", href, external, download)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxBody = 4 << 20 // 4MB
|
||||
|
||||
var client = &http.Client{Timeout: 20 * time.Second}
|
||||
|
||||
// BaseURL 上游 API 根地址,来自 conf/app.yaml 的 serverAddr。
|
||||
func BaseURL() string {
|
||||
return strings.TrimRight(strings.TrimSpace(appg.Conf.ServerAddr), "/")
|
||||
}
|
||||
|
||||
// GetJSON 带上 X-Real-IP / User-Agent / X-User-Agent,并从 cookie 透传 Authorization。
|
||||
func GetJSON(c *gin.Context, path string, out any) error {
|
||||
_, err := doJSON(c, http.MethodGet, path, nil, out)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPublicJSON 请求无需登录的公共数据,不透传客户端 Authorization。
|
||||
// 公共配置接口会在收到过期 token 时返回会话失效,因此必须显式与登录态隔离。
|
||||
func GetPublicJSON(c *gin.Context, path string, out any) error {
|
||||
_, err := doJSONWithAuth(c, http.MethodGet, path, nil, out, false)
|
||||
return err
|
||||
}
|
||||
|
||||
// PostJSON 向上游 POST JSON,解码到 out;返回 HTTP 状态码。
|
||||
// 注意:上游业务错误常见为 HTTP 200 + code!=200,此时仍会成功解码。
|
||||
func PostJSON(c *gin.Context, path string, body any, out any) (int, error) {
|
||||
return doJSON(c, http.MethodPost, path, body, out)
|
||||
}
|
||||
|
||||
// MultipartFile 上游 multipart 文件字段。
|
||||
type MultipartFile struct {
|
||||
FieldName string
|
||||
FileName string
|
||||
Reader io.Reader
|
||||
}
|
||||
|
||||
// PostMultipart 向上游 POST multipart/form-data。
|
||||
func PostMultipart(c *gin.Context, path string, fields map[string]string, file *MultipartFile, out any) (int, error) {
|
||||
base := BaseURL()
|
||||
if base == "" {
|
||||
return 0, fmt.Errorf("upstream serverAddr is empty")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
if err := w.WriteField(k, v); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if file != nil && file.Reader != nil {
|
||||
part, err := w.CreateFormFile(file.FieldName, file.FileName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := io.Copy(part, io.LimitReader(file.Reader, maxBody)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, base+path, &buf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applyCommonHeaders(c, req)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return resp.StatusCode, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return resp.StatusCode, fmt.Errorf("upstream %s status %d: %s", path, resp.StatusCode, truncate(string(raw), 200))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return resp.StatusCode, fmt.Errorf("decode %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func doJSON(c *gin.Context, method, path string, body any, out any) (int, error) {
|
||||
return doJSONWithAuth(c, method, path, body, out, true)
|
||||
}
|
||||
|
||||
func doJSONWithAuth(c *gin.Context, method, path string, body any, out any, forwardAuth bool) (int, error) {
|
||||
base := BaseURL()
|
||||
if base == "" {
|
||||
return 0, fmt.Errorf("upstream serverAddr is empty")
|
||||
}
|
||||
fullURL := base + path
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
bodyReader = strings.NewReader(string(b))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applyCommonHeaders(c, req)
|
||||
if !forwardAuth {
|
||||
req.Header.Del("Authorization")
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return resp.StatusCode, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return resp.StatusCode, fmt.Errorf("upstream %s status %d: %s", path, resp.StatusCode, truncate(string(raw), 200))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return resp.StatusCode, fmt.Errorf("decode %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func applyCommonHeaders(c *gin.Context, req *http.Request) {
|
||||
headers := useragent.BuildUpstreamHeader(c, useragent.DefaultVer)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if token := useragent.TokenFromRequest(c.Request); token != "" {
|
||||
req.Header.Set("Authorization", token)
|
||||
} else if c != nil && c.Request != nil {
|
||||
if auth := strings.TrimSpace(c.GetHeader("Authorization")); auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"videoweb/app/appg"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestGetPublicJSONDoesNotForwardAuthorization(t *testing.T) {
|
||||
var receivedAuthorization string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuthorization = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
previousServerAddr := appg.Conf.ServerAddr
|
||||
appg.Conf.ServerAddr = server.URL
|
||||
t.Cleanup(func() { appg.Conf.ServerAddr = previousServerAddr })
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.AddCookie(&http.Cookie{Name: "token", Value: "expired-token"})
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = request
|
||||
|
||||
var response map[string]any
|
||||
if err := GetPublicJSON(context, "/public-config", &response); err != nil {
|
||||
t.Fatalf("GetPublicJSON() error = %v", err)
|
||||
}
|
||||
if receivedAuthorization != "" {
|
||||
t.Fatalf("Authorization = %q, want empty", receivedAuthorization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJSONStillForwardsAuthorization(t *testing.T) {
|
||||
var receivedAuthorization string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuthorization = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
previousServerAddr := appg.Conf.ServerAddr
|
||||
appg.Conf.ServerAddr = server.URL
|
||||
t.Cleanup(func() { appg.Conf.ServerAddr = previousServerAddr })
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.AddCookie(&http.Cookie{Name: "token", Value: "valid-token"})
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = request
|
||||
|
||||
var response map[string]any
|
||||
if err := GetJSON(context, "/private-data", &response); err != nil {
|
||||
t.Fatalf("GetJSON() error = %v", err)
|
||||
}
|
||||
if receivedAuthorization != "valid-token" {
|
||||
t.Fatalf("Authorization = %q, want %q", receivedAuthorization, "valid-token")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user