logo和配置修改

This commit is contained in:
谢宇宁
2026-09-23 16:50:34 +07:00
parent bef9f8b4ed
commit 0db8087765
163 changed files with 393 additions and 20366 deletions
+5 -153
View File
@@ -3,10 +3,8 @@ package authser
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"videoweb/app/service/upstream"
@@ -16,58 +14,11 @@ import (
)
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 创建或找回游客账号)。
// GuestLogin 调用上游游客登录,站点不提供注册/登录,上游内容接口依赖游客会话。
func GuestLogin(c *gin.Context, req GuestLoginReq) (*APIEnvelope, *LoginResp, error) {
var env APIEnvelope
if _, err := upstream.PostJSON(c, guestPath, req, &env); err != nil {
@@ -83,47 +34,6 @@ func GuestLogin(c *gin.Context, req GuestLoginReq) (*APIEnvelope, *LoginResp, er
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")
@@ -135,7 +45,7 @@ func decodeLoginData(raw json.RawMessage) (*LoginResp, error) {
return &user, nil
}
// SetAuthCookies 将登录/注册返回写入 cookie。
// SetAuthCookies 写入游客会话 cookie。
func SetAuthCookies(c *gin.Context, user *LoginResp) {
if c == nil || user == nil {
return
@@ -144,33 +54,13 @@ func SetAuthCookies(c *gin.Context, user *LoginResp) {
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 会留下字面量 "+"。
// Cookie Value 仅允许 ASCII;使用 PathEscape(空格→%20),与前端 encodeURIComponent 一致。
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: url.PathEscape(value),
@@ -199,7 +89,7 @@ func cookieValue(c *gin.Context, name string) string {
return raw
}
// ClearAuthCookies 清除登录相关 cookie。
// ClearAuthCookies 清除会话 cookie,同时清理旧版账号体系遗留的用户信息 cookie。
func ClearAuthCookies(c *gin.Context) {
for _, name := range authCookieNames() {
http.SetCookie(c.Writer, &http.Cookie{
@@ -218,44 +108,6 @@ func authCookieNames() []string {
"token", "_token_", "sid", "_s_token_",
"userId", "accountNo", "nickName", "introduction", "avatar",
"email", "inviteCode", "recharge", "vipType", "currentVipExpire", "showCardId", "showCardName",
"userType", "isRegister",
"userType", "isRegister", "guest_skip_auto",
}
}
// 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 ""
}
+4 -89
View File
@@ -4,7 +4,6 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
@@ -16,24 +15,17 @@ import (
)
const guestDevIDCookie = "guest_dev_id"
const guestSkipAutoCookie = "guest_skip_auto"
// EnsureGuestOnEnter 页面进入时:无 token(未自己登录)则走游客登录并写 cookie。
// 对应上游 POST /api/app/auth/guest。成功后会把会话补进当前 Request,便于同一次 SSR 读到登录态。
// 手动退出后带 guest_skip_auto 时跳过本次;清掉标记后,下次进页再自动游客。
// EnsureGuestOnEnter 页面进入时:无 token 则走游客登录并写 cookie。
// 对应上游 POST /api/app/auth/guest。成功后会把会话补进当前 Request,便于同一次 SSR 直接使用。
func EnsureGuestOnEnter(c *gin.Context) *LoginResp {
if c == nil || c.Request == nil {
return nil
}
// 已有任意会话(游客或正式账号)则跳过
// 已有会话则跳过
if useragent.TokenFromRequest(c.Request) != "" {
return nil
}
// 手动退出:本次进页不自动游客,并清除标记,供下次进页恢复
if IsGuestSkipAuto(c) {
ClearGuestSkipAuto(c)
return nil
}
devID := ensureGuestDevID(c)
env, user, err := GuestLogin(c, GuestLoginReq{
@@ -55,25 +47,10 @@ func EnsureGuestOnEnter(c *gin.Context) *LoginResp {
}
SetAuthCookies(c, user)
ClearGuestSkipAuto(c)
patchAuthCookiesToRequest(c, user)
patchRequestCookie(c, "token", user.Token)
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
@@ -88,43 +65,6 @@ func dropRequestCookie(c *gin.Context, name string) {
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
@@ -164,31 +104,6 @@ func guestChannel(c *gin.Context) string {
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
+2 -28
View File
@@ -2,7 +2,7 @@ package authser
import "github.com/gin-gonic/gin"
// TokenExpiredCode 上游约定:token 失效,需重新登录。
// TokenExpiredCode 上游约定:token 失效。
const TokenExpiredCode = 1000
// IsSessionExpiredCode 是否为会话失效类业务码。
@@ -15,31 +15,7 @@ func IsSessionExpiredCode(code int) bool {
}
}
// 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,避免把用户操作静默降级为游客操作。
// RecoverContentSession 会话失效后清理旧 cookie 并重新建立游客会话,调用方随后重试一次原请求。
func RecoverContentSession(c *gin.Context, code int) bool {
if c == nil || !IsSessionExpiredCode(code) {
return false
@@ -48,7 +24,5 @@ func RecoverContentSession(c *gin.Context, code int) bool {
for _, name := range authCookieNames() {
dropRequestCookie(c, name)
}
dropRequestCookie(c, guestSkipAutoCookie)
ClearGuestSkipAuto(c)
return EnsureGuestOnEnter(c) != nil
}
+3 -71
View File
@@ -2,31 +2,6 @@ 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 {
@@ -36,39 +11,10 @@ type GuestLoginReq struct {
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。
// LoginResp 游客登录成功 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"` // 金币余额
Token string `json:"token"`
UserID int64 `json:"userId"`
}
// APIEnvelope 上游统一响应壳。Data 在失败时可能是 string/null。
@@ -78,17 +24,3 @@ type APIEnvelope struct {
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"`
}
-87
View File
@@ -1,87 +0,0 @@
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)
}
-187
View File
@@ -1,187 +0,0 @@
package authser
import (
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func parseVipExpireMs(raw string) int64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
if n > 0 && n < 1e12 {
return n * 1000
}
return n
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, raw); err == nil {
return t.UnixMilli()
}
}
return 0
}
// UserIDFromCookies 读取 cookie userId。
func UserIDFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "userId")), 10, 64)
if n < 0 {
return 0
}
return n
}
// VipTypeFromCookies 读取 cookie vipType。
func VipTypeFromCookies(c *gin.Context) int {
if c == nil {
return 0
}
n, _ := strconv.Atoi(strings.TrimSpace(cookieValue(c, "vipType")))
return n
}
// VipTypeLabel vipType 文案:0普通 1月卡 2年卡(与 91ly / 编辑资料注释一致)。
func VipTypeLabel(vipType int) string {
switch vipType {
case 1:
return "月卡会员"
case 2:
return "年卡会员"
default:
return "普通会员"
}
}
// ShowCardNameFromCookies 读取 cookie showCardName。
func ShowCardNameFromCookies(c *gin.Context) string {
if c == nil {
return ""
}
return strings.TrimSpace(cookieValue(c, "showCardName"))
}
// ShowCardIDFromCookies 读取 cookie showCardId。
func ShowCardIDFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "showCardId")), 10, 64)
if n < 0 {
return 0
}
return n
}
// VipDisplayLabel 优先使用 getInfo.showCardName,否则回退 vipType 文案。
func VipDisplayLabel(showCardName string, vipType int) string {
if s := strings.TrimSpace(showCardName); s != "" {
return s
}
return VipTypeLabel(vipType)
}
// VipDisplayLabelFromCookies 从 cookie 组装会员展示名。
func VipDisplayLabelFromCookies(c *gin.Context) string {
return VipDisplayLabel(ShowCardNameFromCookies(c), VipTypeFromCookies(c))
}
// IsActiveVipUser 根据 vipType + currentVipExpire 判断是否有效会员。
func IsActiveVipUser(vipType int, currentVipExpire string) bool {
if vipType == 0 {
return false
}
expireMs := parseVipExpireMs(currentVipExpire)
if expireMs <= 0 {
return false
}
return expireMs > time.Now().UnixMilli()
}
// FormatVipExpireText 格式化到期时间展示。
func FormatVipExpireText(currentVipExpire string) string {
ms := parseVipExpireMs(currentVipExpire)
if ms <= 0 {
return ""
}
return time.UnixMilli(ms).In(time.Local).Format("有效期至 2006/01/02")
}
// VipExpireTextFromCookies 格式化到期时间展示。
func VipExpireTextFromCookies(c *gin.Context) string {
if c == nil {
return ""
}
return FormatVipExpireText(cookieValue(c, "currentVipExpire"))
}
// IsActiveVipFromCookies vipType≠0 且 currentVipExpire 未过期(与 91ly 一致)。
func IsActiveVipFromCookies(c *gin.Context) bool {
if c == nil {
return false
}
return IsActiveVipUser(VipTypeFromCookies(c), cookieValue(c, "currentVipExpire"))
}
// RechargeFromCookies 读取 cookie recharge(金币余额)。
func RechargeFromCookies(c *gin.Context) int64 {
if c == nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(cookieValue(c, "recharge")), 10, 64)
if n < 0 {
return 0
}
return n
}
// SetRechargeCookie 更新金币余额 cookie(解锁/充值后同步)。
func SetRechargeCookie(c *gin.Context, balance int64) {
if c == nil {
return
}
if balance < 0 {
balance = 0
}
secure := c.Request != nil && c.Request.TLS != nil
setCookie(c, "recharge", strconv.FormatInt(balance, 10), secure)
}
// FormatRecharge 金币余额千分位展示。
func FormatRecharge(n int64) string {
if n < 0 {
n = 0
}
s := strconv.FormatInt(n, 10)
if len(s) <= 3 {
return s
}
var b strings.Builder
rem := len(s) % 3
if rem > 0 {
b.WriteString(s[:rem])
if len(s) > rem {
b.WriteByte(',')
}
}
for i := rem; i < len(s); i += 3 {
b.WriteString(s[i : i+3])
if i+3 < len(s) {
b.WriteByte(',')
}
}
return b.String()
}
-22
View File
@@ -1,22 +0,0 @@
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
}
-45
View File
@@ -1,45 +0,0 @@
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
}
-29
View File
@@ -1,29 +0,0 @@
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
}
+6 -8
View File
@@ -52,16 +52,14 @@ type FooterContact struct {
// 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"`
NewAddressInfo NewAddressInfo `json:"newAddressInfo"`
Config ConfigInfo `json:"config"`
Advitise AdvitiseData `json:"advitise"`
SeoInfo SeoInfo `json:"seoInfo"`
SeoNavInfo SeoNavInfo `json:"seoNavInfo"`
NavPageList []NavPageItem `json:"navPageList"`
}
// ConfigInfo 对应上游 data.config(proto.ConfigInfo 子集,按页面需要取用)。
-2
View File
@@ -24,7 +24,6 @@ type AppConfigVM struct {
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"`
@@ -54,7 +53,6 @@ func BuildAppConfigVM(info *ConfigInfoResp) AppConfigVM {
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),
-44
View File
@@ -139,20 +139,6 @@ func (c *PlayletChapterInfoResp) AllowWatch() bool {
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 {
@@ -166,33 +152,3 @@ func (c PlayletChapterData) AllowWatch() bool {
}
return c.PayType == 0
}
// NeedCoinGate 是否弹出金币解锁。
func (c PlayletChapterData) NeedCoinGate() bool {
if c.AllowWatch() {
return false
}
if c.NeedUnlock {
return true
}
return c.PayType == 1
}
// NeedVipGate 是否弹出 VIP 开通。
func (c PlayletChapterData) NeedVipGate() bool {
if c.AllowWatch() || c.NeedCoinGate() {
return false
}
if c.NeedVip {
return true
}
return c.PayType == 2
}
// UnlockCost 金币解锁消耗:优先 costCoins。
func (c PlayletChapterData) UnlockCost() int {
if c.CostCoins > 0 {
return int(c.CostCoins)
}
return c.SingleCoins
}
-84
View File
@@ -1,84 +0,0 @@
package playletser
import (
"fmt"
"net/url"
"strconv"
"videoweb/app/service/authser"
"videoweb/app/service/upstream"
"videoweb/pub/common/log"
"github.com/gin-gonic/gin"
)
const chapterCollectListPath = "/api/app/playlet/chapter/collect/list"
// PlayletChapterCollectData 对应 swagger proto.PlayletChapterCollectData
type PlayletChapterCollectData struct {
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
PlayletID int64 `json:"playletId"`
PlayletSlug string `json:"playletSlug"`
PlayletTitle string `json:"playletTitle"`
PlayletCover string `json:"playletCover"`
CurrentEpisode int `json:"currentEpisode"`
TotalEpisode int `json:"totalEpisode"`
UpdateStatus int `json:"updateStatus"`
CountBrowse int64 `json:"countBrowse"`
CountCollect int64 `json:"countCollect"`
CountLike int64 `json:"countLike"`
IsCollect int `json:"isCollect"`
CollectedAt string `json:"collectedAt"`
MediaTime int `json:"mediaTime"`
PayType int `json:"payType"`
SingleCoins int `json:"singleCoins"`
VideoURL string `json:"videoUrl"`
}
// PlayletChapterCollectListResp 对应 swagger proto.PlayletChapterCollectListResp
type PlayletChapterCollectListResp struct {
List []PlayletChapterCollectData `json:"list"`
PageNum int `json:"pageNum"`
PageSize int `json:"pageSize"`
TotalCount int64 `json:"totalCount"`
}
// FetchChapterCollectList 拉取当前用户剧集收藏列表。
// 第二个返回值为上游业务 code(网络错误时为 0)。
func FetchChapterCollectList(c *gin.Context, q CollectListQuery) (*PlayletChapterCollectListResp, int, error) {
q.Normalize()
params := url.Values{}
params.Set("pageNum", strconv.Itoa(q.PageNum))
params.Set("pageSize", strconv.Itoa(q.PageSize))
var env APIEnvelope[PlayletChapterCollectListResp]
if err := upstream.GetJSON(c, chapterCollectListPath+"?"+params.Encode(), &env); err != nil {
return nil, 0, err
}
if env.Code != 200 {
return nil, env.Code, fmt.Errorf("playlet chapter collect list code=%d msg=%s", env.Code, env.Msg)
}
return &env.Data, env.Code, nil
}
// FetchChapterCollectListOrEmpty 失败时返回空列表;游客会话失效时尝试重登后重试一次。
func FetchChapterCollectListOrEmpty(c *gin.Context, q CollectListQuery) *PlayletChapterCollectListResp {
data, code, err := FetchChapterCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
if retry, _ := authser.OnSessionExpired(c, code); retry {
data, _, err = FetchChapterCollectList(c, q)
if err == nil {
if data == nil {
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
return data
}
}
log.Error("fetch playlet chapter collect list failed", log.E(err))
return &PlayletChapterCollectListResp{PageNum: q.PageNum, PageSize: q.PageSize}
}
-63
View File
@@ -1,63 +0,0 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const (
chapterLikeSetPath = "/api/app/playlet/chapter/like/set"
chapterCollectSetPath = "/api/app/playlet/chapter/collect/set"
)
// PlayletChapterLikeSetReq 对应 swagger proto.PlayletChapterLikeSetReq
type PlayletChapterLikeSetReq struct {
ChapterID int64 `json:"chapterId"`
IsLike bool `json:"isLike"`
}
// PlayletChapterLikeSetResp 对应 swagger proto.PlayletChapterLikeSetResp
type PlayletChapterLikeSetResp struct {
ChapterID int64 `json:"chapterId"`
CountLike int64 `json:"countLike"`
IsLike int `json:"isLike"`
}
// ChapterLikeSet 设置剧集点赞/取消点赞。
func ChapterLikeSet(c *gin.Context, req PlayletChapterLikeSetReq) (*APIEnvelope[PlayletChapterLikeSetResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter like invalid chapterId")
}
var env APIEnvelope[PlayletChapterLikeSetResp]
if _, err := upstream.PostJSON(c, chapterLikeSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
// PlayletChapterCollectSetReq 对应 swagger proto.PlayletChapterCollectSetReq
type PlayletChapterCollectSetReq struct {
ChapterID int64 `json:"chapterId"`
IsCollect bool `json:"isCollect"`
}
// PlayletChapterCollectSetResp 对应 swagger proto.PlayletChapterCollectSetResp
type PlayletChapterCollectSetResp struct {
ChapterID int64 `json:"chapterId"`
CountCollect int64 `json:"countCollect"`
IsCollect int `json:"isCollect"`
}
// ChapterCollectSet 设置剧集收藏/取消收藏。
func ChapterCollectSet(c *gin.Context, req PlayletChapterCollectSetReq) (*APIEnvelope[PlayletChapterCollectSetResp], error) {
if req.ChapterID <= 0 {
return nil, fmt.Errorf("chapter collect invalid chapterId")
}
var env APIEnvelope[PlayletChapterCollectSetResp]
if _, err := upstream.PostJSON(c, chapterCollectSetPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
-40
View File
@@ -1,40 +0,0 @@
package playletser
import (
"fmt"
"videoweb/app/service/upstream"
"github.com/gin-gonic/gin"
)
const chapterUnlockPath = "/api/app/playlet/chapter/unlock"
// PlayletChapterUnlockReq 对应 swagger proto.PlayletChapterUnlockReq
// POST /api/app/playlet/chapter/unlock
type PlayletChapterUnlockReq struct {
ChapterID int64 `json:"chapterId"`
PlayletID int64 `json:"playletId"`
}
// PlayletChapterUnlockResp 对应 swagger proto.PlayletChapterUnlockResp
type PlayletChapterUnlockResp struct {
Balance int64 `json:"balance"`
CanWatch bool `json:"canWatch"`
ChangeCoin int64 `json:"changeCoin"`
ChapterID int64 `json:"chapterId"`
CostCoins int64 `json:"costCoins"`
PlayletID int64 `json:"playletId"`
Unlocked bool `json:"unlocked"`
}
// ChapterUnlock 使用金币解锁剧集(幂等)。
func ChapterUnlock(c *gin.Context, req PlayletChapterUnlockReq) (*APIEnvelope[PlayletChapterUnlockResp], error) {
if req.ChapterID <= 0 || req.PlayletID <= 0 {
return nil, fmt.Errorf("chapter unlock invalid chapterId/playletId")
}
var env APIEnvelope[PlayletChapterUnlockResp]
if _, err := upstream.PostJSON(c, chapterUnlockPath, req, &env); err != nil {
return nil, err
}
return &env, nil
}
-39
View File
@@ -1,39 +0,0 @@
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
}
-155
View File
@@ -1,155 +0,0 @@
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}
}
-89
View File
@@ -1,89 +0,0 @@
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)
}
-139
View File
@@ -1,139 +0,0 @@
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
}
-147
View File
@@ -1,147 +0,0 @@
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
}
-35
View File
@@ -1,35 +0,0 @@
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
}
+44 -75
View File
@@ -11,62 +11,50 @@ import (
// 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,
"detailURL": detailURL,
"playURL": playURL,
"chapterPlayURL": chapterPlayURL,
"categoryURL": categoryURL,
"categoryDetailURL": categoryDetailURL,
"themeListURL": themeListURLByKey,
"coverURL": coverURL,
"formatHeat": formatHeat,
"formatCollect": formatCollectHeat,
"formatLikeCount": formatLikeCount,
"episodeLabel": episodeText,
"displayTag": displayTag,
"firstTagName": firstTagName,
"themeTagText": themeTagText,
"tagListURL": tagListURL,
"cardTag": cardTag,
"cardTags": cardTags,
"namedTags": namedTags,
"namedThemeTags": namedThemeTags,
"firstName": firstName,
"rankClass": rankClass,
"add": addInt,
"contains": strings.Contains,
"hasPrefix": strings.HasPrefix,
"footerLinkURL": footerLinkURL,
"footerLinkIsExternal": footerLinkIsExternal,
"footerContactURL": footerContactURL,
"footerContactName": footerContactName,
"footerContactValue": footerContactValue,
"navDisplayURL": navDisplayURL,
"urlquery": url.QueryEscape,
"pathescape": url.PathEscape,
"metaLine": metaLine,
"updateStatusText": updateStatusText,
"browseText": browseText,
"usableAnchor": usableAnchor,
"playletYearText": playletYearText,
"playletDateText": playletDateText,
"isPlayletAllFree": isPlayletAllFree,
"absInt64": absInt64Tpl,
"absURL": absURLTpl,
"canonicalOr": canonicalOr,
"joinStrings": joinStrings,
"dict": dict,
}
}
@@ -315,25 +303,6 @@ func absInt64Tpl(n int64) int64 {
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 == "" {
-212
View File
@@ -1,212 +0,0 @@
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
}
}
-117
View File
@@ -1,117 +0,0 @@
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"`
}
-439
View File
@@ -1,439 +0,0 @@
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()
}
-2
View File
@@ -280,8 +280,6 @@ func navActiveKey(href, name string, download bool) string {
return "aiSeries"
case "/home-road":
return "homeRoad"
case "/user-center":
return "userCenter"
case "/download-app":
return "downloadApp"
default: