Initial commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:57:10 +08:00
co-authored by Claude Opus 5
commit 8679200f41
1897 changed files with 257900 additions and 0 deletions
@@ -0,0 +1,52 @@
package mediacontentser
import (
"bytes"
"encoding/json"
"testing"
"91porn-server/models/v/mediacontentmod"
"91porn-server/models/v/mediamod"
)
func TestDramaResponsesDoNotExposeServerProgress(t *testing.T) {
tests := map[string]any{
"media": mediamod.AppMediaBase{},
"episode list": AppMediaContent{},
"episode info": MediaContentInfo{},
}
for name, response := range tests {
data, err := json.Marshal(response)
if err != nil {
t.Fatalf("marshal %s response: %v", name, err)
}
if bytes.Contains(data, []byte(`"resume"`)) || bytes.Contains(data, []byte(`"progressSeconds"`)) {
t.Fatalf("%s response still exposes server progress: %s", name, data)
}
}
}
func TestResolveDramaAccess(t *testing.T) {
media := mediamod.Media{FreeEpisode: 1}
tests := []struct {
name string
content mediacontentmod.MediaContent
hasBuy bool
hasCard bool
wantCan bool
wantAccess string
}{
{name: "free episode", content: mediacontentmod.MediaContent{EpisodeNumber: 1, Price: 30, ListenPermission: 1}, wantCan: true, wantAccess: dramaAccessFree},
{name: "bought episode", content: mediacontentmod.MediaContent{EpisodeNumber: 2, Price: 30, ListenPermission: 1}, hasBuy: true, wantCan: true, wantAccess: dramaAccessBought},
{name: "card episode", content: mediacontentmod.MediaContent{EpisodeNumber: 2, Price: 30, ListenPermission: 1}, hasCard: true, wantCan: true, wantAccess: dramaAccessCard},
{name: "locked episode", content: mediacontentmod.MediaContent{EpisodeNumber: 2, Price: 30, ListenPermission: 1}, wantCan: false, wantAccess: dramaAccessCoin},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, canPlay, accessType := ResolveDramaAccess(tt.content, media, tt.hasBuy, tt.hasCard)
if canPlay != tt.wantCan || accessType != tt.wantAccess {
t.Fatalf("ResolveDramaAccess() = (%v, %q), want (%v, %q)", canPlay, accessType, tt.wantCan, tt.wantAccess)
}
})
}
}
+412
View File
@@ -0,0 +1,412 @@
package mediacontentser
import (
"91porn-server/app/appg"
"91porn-server/common"
"91porn-server/common/log"
topser "91porn-server/common/top"
topasist "91porn-server/common/top/asistant"
"91porn-server/models/v/media_buy_record_mod"
"91porn-server/models/v/mediamod"
"91porn-server/models/v/usermod"
"91porn-server/models/v/walletmod"
"fmt"
"strings"
"time"
"91porn-server/models/cache/mediacontentdata"
"91porn-server/models/v/mediacontentmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// MediaContentInfo 移动端返回内容
type MediaContentInfo struct {
ID primitive.ObjectID `json:"id"` // 文档id
MediaID primitive.ObjectID `json:"mediaId"` // 媒体资源ID
MediaType string `json:"mediaType"` // 媒体类型
EpisodeNumber int `json:"episodeNumber"` // 第几集
ListenPermission int `json:"listenPermission"` // 收听权限 0:会员 1:金币购买 2:免费
Price int64 `json:"price"` // 购买价格
Name string `json:"name"` // 章节名
Cover string `json:"cover"` // 封面
Text string `json:"text"` // 内容(小说专用)
Md5 string `json:"md5"` // 媒体md5
VideoUrl string `json:"videoUrl"` // 视频地址
H265Url string `json:"h265Url"` // H.265 视频地址
AudioUrl string `json:"audioUrl"` // 音频地址
UrlSet []string `json:"urlSet"` // 地址集(多个资源地址用 例如漫画)
Height int `json:"height"` // 高
Weight int `json:"weight"` // 宽
MediaSize int64 `json:"mediaSize"` // 资源大小
PlayTime uint `json:"playTime"` // 影片长度
IsActive bool `json:"isActive"` // 是否激活
Ratio float64 `json:"ratio"` // 宽高比
CreatedAt time.Time `json:"createdAt"` // 文档创建时间
UpdateTime time.Time `json:"updateTime"` // 文档更新时间
HasBuy bool `json:"hasBuy"` // 是否已购买
IsFree bool `json:"isFree"`
HasDramaCard bool `json:"hasDramaCard"`
CanPlay bool `json:"canPlay"`
AccessType string `json:"accessType"`
PreviewEnabled bool `json:"previewEnabled"`
PreviewStart int `json:"previewStart"`
PreviewSeconds int `json:"previewSeconds"`
PreviewVideoUrl string `json:"previewVideoUrl"`
PreviewH265Url string `json:"previewH265Url"`
Prev *OtherContent `json:"prev"` // 上一集
Next *OtherContent `json:"next"` // 下一集
Paywall *PaywallInfo `json:"paywall"`
}
type OtherContent struct {
Id primitive.ObjectID `json:"id"`
EpisodeNumber int `json:"episodeNumber"`
Name string `json:"name"`
Cover string `json:"cover"`
}
type PaywallInfo struct {
CheckoutContextID string `json:"checkoutContextId"`
Title string `json:"title"`
CoinBalance int64 `json:"coinBalance"`
UnlockCoin int64 `json:"unlockCoin"`
CoinButtonText string `json:"coinButtonText"`
CardButtonText string `json:"cardButtonText"`
}
type AppQueryListReq struct {
PageNumber uint64 `form:"pageNumber" json:"pageNumber" binding:"required,min=1"`
PageSize uint64 `form:"pageSize" json:"pageSize" binding:"required,min=1,max=200"`
MediaId string `json:"mediaId" form:"mediaId" binding:"required"`
SortType int `json:"sortType" form:"sortType" binding:"omitempty,oneof=0 1"` // 排序类型 0-正序 1-倒序
}
func (p *AppQueryListReq) Skip() uint64 { return (p.PageNumber - 1) * p.PageSize }
func (p *AppQueryListReq) Limit() uint64 { return p.PageSize }
type AppListRes struct {
Total int64 `json:"total"`
HasNext bool `json:"hasNext"`
List []AppMediaContent `json:"list"`
}
type AppMediaContent struct {
ID primitive.ObjectID `json:"id"` // 文档id
MediaID primitive.ObjectID `json:"mediaId"` // 媒体资源ID
MediaType string `json:"mediaType"` // 媒体类型
EpisodeNumber int `json:"episodeNumber"` // 第几集
ListenPermission int `json:"listenPermission"` // 收听权限 0:会员 1:金币购买 2:免费
VideoUrl string `json:"videoUrl"` // 视频地址
H265Url string `json:"h265Url"` // H.265 视频地址
AudioUrl string `json:"audioUrl"` // 音频地址
PlayTime uint `json:"playTime"` // 播放时长
Price int64 `json:"price"` // 购买价格
Name string `json:"name"` // 章节名
Cover string `json:"cover"` // 封面
HasBuy bool `json:"hasBuy"` // 是否已购买
IsFree bool `json:"isFree"`
HasDramaCard bool `json:"hasDramaCard"`
CanPlay bool `json:"canPlay"`
AccessType string `json:"accessType"`
PreviewEnabled bool `json:"previewEnabled"`
PreviewStart int `json:"previewStart"`
PreviewSeconds int `json:"previewSeconds"`
PreviewVideoUrl string `json:"previewVideoUrl"`
PreviewH265Url string `json:"previewH265Url"`
Ratio float64 `json:"ratio"`
CreatedAt time.Time `json:"createdAt"` // 创建时间
UpdateTime time.Time `json:"updateTime"`
}
const (
dramaAccessFree = "free"
dramaAccessBought = "bought"
dramaAccessCard = "card"
dramaAccessCoin = "coin"
)
func ActiveDramaCard(uid uint64) bool {
ok, err := ActiveDramaCardStatus(uid)
return err == nil && ok
}
func ActiveDramaCardStatus(uid uint64) (bool, error) {
user, err := usermod.FindUserByUID(uid)
if err != nil {
return false, err
}
return user.DramaExpire.After(time.Now()), nil
}
func ResolveDramaAccess(
item mediacontentmod.MediaContent,
media mediamod.Media,
hasBuy, hasCard bool,
) (isFree, canPlay bool, accessType string) {
isFree = item.ListenPermission == 2 || item.Price <= 0 ||
(media.FreeEpisode > 0 && item.EpisodeNumber <= media.FreeEpisode)
switch {
case isFree:
return true, true, dramaAccessFree
case hasBuy:
return false, true, dramaAccessBought
case hasCard:
return false, true, dramaAccessCard
default:
return false, false, dramaAccessCoin
}
}
func newPaywall(uid uint64, price int64) *PaywallInfo {
balance := int64(0)
if wallet, err := walletmod.GetWallet(uid); err == nil && wallet != nil {
balance = wallet.Amount + wallet.Income
}
return &PaywallInfo{
CheckoutContextID: "drama-checkout-" + primitive.NewObjectID().Hex(),
Title: "更多精彩解锁即享",
CoinBalance: balance,
UnlockCoin: price,
CoinButtonText: fmt.Sprintf("%d金币解锁", price),
CardButtonText: "开通短剧卡免费看",
}
}
// GetList 获取列表
func (p *AppQueryListReq) GetList(uid uint64) (res AppListRes, err error) {
mediaId, err := primitive.ObjectIDFromHex(p.MediaId)
if err != nil {
return res, err
}
media, err := mediamod.GetInfo(mediaId)
if err != nil {
return res, err
}
filter := bson.M{"mediaId": mediaId, "isActive": true, "isDelete": false}
if media.MediaType == mediamod.MediaTypeDrama {
filter["mediaType"] = mediamod.MediaTypeDrama
if media.Status != 1 || media.IsDelete {
return res, fmt.Errorf("drama is inactive")
}
}
sort := bson.D{{Key: "episodeNumber", Value: 1}}
if p.SortType == 1 {
sort = bson.D{{Key: "episodeNumber", Value: -1}}
}
// 获取列表
var data []mediacontentmod.MediaContent
data, res.Total, res.HasNext, err = mediacontentdata.GetListFromCache(filter, int64(p.Skip()), int64(p.Limit()), sort)
if err != nil {
log.Error("AppQueryListReq.GetList mediacontentdata.GetListFromCache fail", log.Any("req", p), log.E(err))
return res, err
}
contentIds := []primitive.ObjectID{}
// 获取是否已经购买
for _, item := range data {
contentIds = append(contentIds, item.ID)
}
buyMap, isWholeBuy, err := media_buy_record_mod.IsBuyBatch(uid, mediaId, contentIds)
if err != nil {
log.Error("AppQueryListReq.GetList media_buy_record_mod.IsBuyBatch fail", log.Any("req", p), log.E(err))
return res, err
}
hasCard := false
if media.MediaType == mediamod.MediaTypeDrama {
hasCard, err = ActiveDramaCardStatus(uid)
if err != nil {
return res, err
}
}
// 组装返回数据
for _, item := range data {
tmp := AppMediaContent{
ID: item.ID,
MediaID: item.MediaID,
MediaType: item.MediaType,
EpisodeNumber: item.EpisodeNumber,
ListenPermission: item.ListenPermission,
Price: item.Price,
Name: item.Name,
PlayTime: item.PlayTime,
Cover: item.Cover,
AudioUrl: item.AudioUrl,
VideoUrl: item.VideoUrl,
H265Url: appg.H265URLForApp(item.H265Url),
HasBuy: buyMap[item.ID],
Ratio: item.Ratio,
CreatedAt: item.CreatedAt,
UpdateTime: item.UpdateTime,
}
if strings.TrimSpace(tmp.Name) == "" {
tmp.Name = fmt.Sprintf("第%v集", item.EpisodeNumber)
}
// 是否整本购买
if isWholeBuy {
tmp.HasBuy = true
}
if item.MediaType == mediamod.MediaTypeDrama {
tmp.HasDramaCard = hasCard
tmp.IsFree, tmp.CanPlay, tmp.AccessType = ResolveDramaAccess(item, media, tmp.HasBuy, hasCard)
tmp.PreviewEnabled = item.PreviewEnabled
tmp.PreviewStart = item.PreviewStart
tmp.PreviewSeconds = item.PreviewSeconds
if !tmp.CanPlay {
if tmp.PreviewEnabled {
tmp.PreviewVideoUrl = tmp.VideoUrl
tmp.PreviewH265Url = tmp.H265Url
}
tmp.AudioUrl = ""
tmp.VideoUrl = ""
tmp.H265Url = ""
}
}
if tmp.Cover == "" && len(item.UrlSet) > 0 {
tmp.Cover = item.UrlSet[0]
}
res.List = append(res.List, tmp)
}
return res, nil
}
type AppQueryInfoReq struct {
ID string `json:"id" form:"id"` // id
}
// GetInfo 获取详情
func (p *AppQueryInfoReq) GetInfo(uid uint64) (res MediaContentInfo, err error) {
oid, err := primitive.ObjectIDFromHex(p.ID)
if err != nil {
return res, err
}
var item mediacontentmod.MediaContent
item, err = mediacontentdata.GetInfoFromCache(oid)
if err != nil {
log.Error("AppQueryInfoReq.GetInfo mediacontentdata.GetInfoFromCache fail", log.Any("mediaContentId", p.ID), log.E(err))
return
}
var media mediamod.Media
if item.MediaType == mediamod.MediaTypeDrama {
media, err = mediamod.GetInfo(item.MediaID)
if err != nil {
return res, err
}
if media.MediaType != mediamod.MediaTypeDrama || media.Status != 1 || media.IsDelete || !item.IsActive || item.IsDelete {
return res, fmt.Errorf("drama episode is inactive")
}
}
// 判断是否已经购买
hasBuy, err := media_buy_record_mod.IsBuy(uid, item.MediaID, item.ID)
if err != nil {
log.Error("AppQueryInfoReq.GetInfo media_buy_record_mod.IsBuy fail", log.Any("mediaContentId", p.ID), log.E(err))
return
}
res = MediaContentInfo{
ID: item.ID,
MediaID: item.MediaID,
MediaType: item.MediaType,
EpisodeNumber: item.EpisodeNumber,
ListenPermission: item.ListenPermission,
Price: item.Price,
Name: item.Name,
Cover: item.Cover,
Text: item.Text,
Md5: item.Md5,
VideoUrl: item.VideoUrl,
H265Url: appg.H265URLForApp(item.H265Url),
AudioUrl: item.AudioUrl,
UrlSet: item.UrlSet,
Height: item.Height,
Weight: item.Weight,
MediaSize: item.MediaSize,
PlayTime: item.PlayTime,
IsActive: item.IsActive,
//Ratio: item.Ratio,
Ratio: 1,
CreatedAt: item.CreatedAt,
UpdateTime: item.UpdateTime,
HasBuy: hasBuy,
PreviewEnabled: item.PreviewEnabled,
PreviewStart: item.PreviewStart,
PreviewSeconds: item.PreviewSeconds,
}
if item.MediaType == mediamod.MediaTypeDrama {
res.HasDramaCard, err = ActiveDramaCardStatus(uid)
if err != nil {
return res, err
}
res.IsFree, res.CanPlay, res.AccessType = ResolveDramaAccess(item, media, hasBuy, res.HasDramaCard)
res.Ratio = item.Ratio
if !res.CanPlay {
if res.PreviewEnabled {
res.PreviewVideoUrl = res.VideoUrl
res.PreviewH265Url = res.H265Url
}
res.VideoUrl = ""
res.H265Url = ""
res.AudioUrl = ""
res.UrlSet = []string{}
res.Text = ""
res.Md5 = ""
res.Paywall = newPaywall(uid, item.Price)
}
}
if strings.TrimSpace(res.Name) == "" {
res.Name = fmt.Sprintf("第%v集", item.EpisodeNumber)
}
if res.Cover == "" && len(item.UrlSet) > 0 {
res.Cover = item.UrlSet[0]
}
EpisodeNumbers := []int{}
if item.EpisodeNumber-1 > 0 {
EpisodeNumbers = append(EpisodeNumbers, item.EpisodeNumber-1)
}
EpisodeNumbers = append(EpisodeNumbers, item.EpisodeNumber+1)
// 获取上一集下一集
otherFilter := bson.M{"mediaId": item.MediaID, "episodeNumber": bson.M{"$in": EpisodeNumbers}, "isActive": true, "isDelete": false}
if item.MediaType == mediamod.MediaTypeDrama {
otherFilter["mediaType"] = mediamod.MediaTypeDrama
}
otherContents, _, _, err := mediacontentdata.GetListFromCache(otherFilter, 0, 2, bson.D{})
if err != nil {
log.Error("AppQueryInfoReq.GetInfo mediacontentdata.GetListData fail", log.Any("mediaContentId", p.ID), log.E(err))
return
}
for _, v := range otherContents {
if v.Cover == "" && len(v.UrlSet) > 0 {
v.Cover = v.UrlSet[0]
}
if v.EpisodeNumber == item.EpisodeNumber-1 {
// 上一集
res.Prev = &OtherContent{
Id: v.ID,
EpisodeNumber: v.EpisodeNumber,
Name: v.Name,
Cover: v.Cover,
}
}
if v.EpisodeNumber == item.EpisodeNumber+1 {
// 下一集
res.Next = &OtherContent{
Id: v.ID,
EpisodeNumber: v.EpisodeNumber,
Name: v.Name,
Cover: v.Cover,
}
}
}
// 累加浏览数
common.Go(func() {
// 增加父级的浏览数量
mediamod.IncBrowseCount(item.MediaID, 1)
topasist.Incr(topser.TypeMedia(item.MediaType), item.ID.Hex(), 1)
})
return
}
@@ -0,0 +1,68 @@
package mediacontentser
import (
"encoding/json"
"testing"
)
func TestMediaContentInfoMarshalH265URL(t *testing.T) {
tests := []struct {
name string
h265URL string
want string
}{
{
name: "resource exists",
h265URL: "https://cdn.example.com/h265/index.m3u8",
want: "https://cdn.example.com/h265/index.m3u8",
},
{
name: "resource missing",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := json.Marshal(MediaContentInfo{
VideoUrl: "https://cdn.example.com/h264/index.m3u8",
H265Url: tt.h265URL,
})
if err != nil {
t.Fatalf("marshal media content info: %v", err)
}
var response map[string]interface{}
if err := json.Unmarshal(data, &response); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if response["videoUrl"] != "https://cdn.example.com/h264/index.m3u8" {
t.Fatalf("unexpected H.264 URL: %v", response["videoUrl"])
}
if response["h265Url"] != tt.want {
t.Fatalf("unexpected H.265 URL: %v", response["h265Url"])
}
})
}
}
func TestAppMediaContentMarshalH265URL(t *testing.T) {
data, err := json.Marshal(AppMediaContent{
VideoUrl: "https://cdn.example.com/h264/index.m3u8",
H265Url: "https://cdn.example.com/h265/index.m3u8",
})
if err != nil {
t.Fatalf("marshal app media content: %v", err)
}
var response map[string]interface{}
if err := json.Unmarshal(data, &response); err != nil {
t.Fatalf("unmarshal app media content: %v", err)
}
if response["videoUrl"] != "https://cdn.example.com/h264/index.m3u8" {
t.Fatalf("unexpected H.264 URL: %v", response["videoUrl"])
}
if response["h265Url"] != "https://cdn.example.com/h265/index.m3u8" {
t.Fatalf("unexpected H.265 URL: %v", response["h265Url"])
}
}