init: 初始化 web_hgdjb_server 项目

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 10:47:32 +08:00
co-authored by Claude Opus 4.7
commit f13d33af4d
707 changed files with 147705 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
package appser
import (
"context"
"videoweb/app/api/proto"
"videoweb/pub/common/srvdef"
)
// AppCategoryData 查询应用分类列表及当前分类数据
func AppCategoryData(ctx context.Context, categoryId int64) ([]proto.AppCategoryInfo, proto.AppCategoryInfo, *srvdef.SrvErr) {
return GetAppListCache(), GetCategoryCache(categoryId), nil
}
+196
View File
@@ -0,0 +1,196 @@
package appser
import (
"bytes"
"context"
"encoding/json"
"net/url"
"strings"
"sync"
"videoweb/app/api/proto"
"videoweb/app/service/configser"
"videoweb/pub/common/http"
"videoweb/pub/common/log"
"videoweb/pub/common/netutil"
"videoweb/pub/pubdef"
"videoweb/pub/storage/proxy"
"github.com/robfig/cron/v3"
)
const (
appListAPIPath = "/media/web/appinfo/applist"
)
var jsonHeader = map[string]string{"Content-Type": "application/json"}
var (
appListCron *cron.Cron
appListCache struct {
lock sync.RWMutex
appList []proto.AppCategoryInfo
categoryMaps map[int64]proto.AppCategoryInfo
}
)
type remoteAppListResp struct {
Code int `json:"code"`
Data struct {
List []proto.AppCategoryInfo `json:"list"`
} `json:"data"`
}
// Start 启动定时刷新应用列表缓存(每10分钟一次)
func Start() {
appListCron = cron.New(cron.WithSeconds())
UpdateAppListCache(context.Background())
_, _ = appListCron.AddFunc("0 */10 * * * ?", func() {
UpdateAppListCache(context.Background())
})
appListCron.Start()
}
// GetAppListCache 返回全部应用分类列表(带各分类下应用)
func GetAppListCache() []proto.AppCategoryInfo {
appListCache.lock.RLock()
defer appListCache.lock.RUnlock()
if len(appListCache.appList) == 0 {
return make([]proto.AppCategoryInfo, 0)
}
result := make([]proto.AppCategoryInfo, len(appListCache.appList))
copy(result, appListCache.appList)
return result
}
// GetCategoryCache 按分类ID返回单个分类(含应用列表);categoryId=0 返回第一个分类
func GetCategoryCache(categoryId int64) proto.AppCategoryInfo {
appListCache.lock.RLock()
defer appListCache.lock.RUnlock()
if categoryId == 0 && len(appListCache.appList) > 0 {
return appListCache.appList[0]
}
if v, ok := appListCache.categoryMaps[categoryId]; ok {
return v
}
return proto.AppCategoryInfo{AppList: make([]proto.AppInfoData, 0)}
}
// UpdateAppListCache 从远端拉取应用列表并刷新内存缓存
func UpdateAppListCache(ctx context.Context) {
domain := proxy.DomainAddressRepair("", proxy.BucketVideoAddressV2)
apiURL := strings.TrimSuffix(domain, "/") + appListAPIPath
reqBody, _ := json.Marshal(map[string]string{"dc": "dc"})
resp, err := http.Post(apiURL, jsonHeader, bytes.NewReader(reqBody))
if err != nil {
log.Warn("UpdateAppListCache http.Post failed", log.Any("url", apiURL), log.E(err))
return
}
var remote remoteAppListResp
if err = json.Unmarshal(resp.Data, &remote); err != nil {
log.Warn("UpdateAppListCache unmarshal failed", log.Any("url", apiURL), log.E(err))
return
}
if remote.Code != 200 {
log.Warn("UpdateAppListCache bad code", log.Any("url", apiURL), log.Any("code", remote.Code))
return
}
config := configser.GetConfigInfo()
advitise := configser.GetAdvitise()
channelCode := config.ChannelCode
selfAppName := strings.TrimSpace(config.WebSiteName)
categoryMaps := make(map[int64]proto.AppCategoryInfo)
var appList []proto.AppCategoryInfo
for _, category := range remote.Data.List {
var apps []proto.AppInfoData
for _, app := range category.AppList {
if selfAppName != "" && strings.HasPrefix(app.Name, selfAppName) {
continue
}
apps = append(apps, proto.AppInfoData{
Id: app.Id,
CategoryId: app.CategoryId,
Name: app.Name,
Intro: app.Intro,
Description: app.Description,
Logo: netutil.MergeURL(config.ImgCdn, app.Logo),
Url: appendChannelCode(app.Url, channelCode),
Sort: app.Sort,
})
}
// 广告位 A(code=12)
if category.Code == 12 {
for _, ad := range advitise.AList {
apps = append(apps, proto.AppInfoData{
AdvitiseId: ad.Id,
CategoryId: category.Id,
Name: ad.Title,
Logo: netutil.MergeURL(config.ImgCdn, ad.Images),
Url: ad.Link,
Location: ad.Location,
})
}
}
// 广告位 B(code=13)
if category.Code == 13 {
for _, ad := range advitise.BList {
apps = append(apps, proto.AppInfoData{
AdvitiseId: ad.Id,
CategoryId: category.Id,
Name: ad.Title,
Logo: netutil.MergeURL(config.ImgCdn, ad.Images),
Url: ad.Link,
Location: ad.Location,
})
}
}
if len(apps) == 0 {
continue
}
category.AppList = apps
categoryMaps[category.Id] = proto.AppCategoryInfo{
Id: category.Id,
Title: category.Title,
Intro: category.Intro,
Sort: category.Sort,
Code: category.Code,
}
appList = append(appList, category)
}
appListCache.lock.Lock()
appListCache.appList = appList
appListCache.categoryMaps = categoryMaps
appListCache.lock.Unlock()
}
// appendChannelCode 为应用 URL 拼接渠道码参数 dc=xxx;已含 dc 参数则不再拼接
func appendChannelCode(rawURL, channelCode string) string {
if len(channelCode) == 0 {
channelCode = pubdef.DefaultChannelCode
}
rawURL = strings.TrimSpace(rawURL)
channelCode = strings.TrimSpace(channelCode)
if rawURL == "" || channelCode == "" {
return rawURL
}
parsed, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
query := parsed.Query()
if query.Has("dc") {
return parsed.String()
}
query.Set("dc", channelCode)
parsed.RawQuery = query.Encode()
return parsed.String()
}
+52
View File
@@ -0,0 +1,52 @@
package appser
import (
"strings"
"testing"
"videoweb/pub/pubdef"
)
func TestAppendChannelCode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawURL string
channelCode string
wantContain string
wantSkip bool
}{
{
name: "append custom channel",
rawURL: "https://example.com/app",
channelCode: "custom",
wantContain: "dc=custom",
},
{
name: "fallback default channel",
rawURL: "https://example.com/app",
channelCode: "",
wantContain: "dc=" + pubdef.DefaultChannelCode,
},
{
name: "skip existing dc",
rawURL: "https://example.com/app?dc=exists",
channelCode: "custom",
wantContain: "dc=exists",
wantSkip: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := appendChannelCode(tt.rawURL, tt.channelCode)
if !strings.Contains(got, tt.wantContain) {
t.Fatalf("appendChannelCode() = %q, want contain %q", got, tt.wantContain)
}
if tt.wantSkip && strings.Contains(got, "dc=custom") {
t.Fatalf("appendChannelCode() should keep existing dc: %q", got)
}
})
}
}
+486
View File
@@ -0,0 +1,486 @@
package authser
import (
"context"
"strings"
"time"
"videoweb/app/appg"
"videoweb/app/service/reportser"
"videoweb/pub/common/enum/consts"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/useragent"
"videoweb/pub/common/utils"
"videoweb/pub/models/usermod"
"videoweb/pub/mongos"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"golang.org/x/crypto/bcrypt"
)
var tokener = utils.NewTokenRedis()
var defaultAvatars = []string{
"v3/image/bo/ty/1016/girl1.jpeg",
"v3/image/re/9y/1016/girl2.jpeg",
"v3/image/5f/k4/1016/girl3.jpeg",
"v3/image/ej/ss/1016/girl4.jpeg",
"v3/image/yq/ys/1016/girl5.jpeg",
"v3/image/9e/d3/1016/girl6.jpeg",
}
func randomAvatar() string {
return defaultAvatars[time.Now().UnixNano()%int64(len(defaultAvatars))]
}
// generateUniqueInviteCode 生成 8 位唯一邀请码(纯大写字母),最多重试 10 次
func generateUniqueInviteCode(ctx context.Context) string {
for i := 0; i < 10; i++ {
code := strings.ToUpper(utils.GetRandomLetters(8))
exist, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.InviteCode: code})
if exist == nil {
return code
}
log.Warn("authser:generateUniqueInviteCode collision, retrying", log.Any("code", code))
}
// 极小概率走到这里:用更长的随机串兜底
return strings.ToUpper(utils.GetRandomLetters(12))
}
// LoginResp 登录/注册成功后的响应数据
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"` // 简介
VipType pubdef.VipType `json:"vipType"`
CurrentVipExpire time.Time `json:"currentVipExpire"` // VIP 过期时间,客户端需结合 vipType 判断是否真正有效
ShowCardId int64 `json:"showCardId"` // 最新会员卡id
ShowCardName string `json:"showCardName"` // 最新会员卡名称
Status pubdef.UserStatus `json:"status"`
Avatar string `json:"avatar"` // 头像
InviteCode string `json:"inviteCode"` // 邀请码
Type pubdef.UserType `json:"type"` // 身份类型:0游客 1普通用户
LoginType pubdef.LoginType `json:"loginType"` // 登陆方式
IsRegister bool `json:"isRegister"` // 是否已注册
IsNewUser bool `json:"isNewUser"` // 本次是否新建游客
PrivacyMode bool `json:"privacyMode"` // 隐私模式
HasPassword bool `json:"hasPassword"` // 是否已设置密码
Score int64 `json:"score"` // 积分
Recharge int64 `json:"recharge"` // 金币余额
}
// hashPassword 使用 bcrypt 对明文密码进行哈希
func hashPassword(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// checkPassword 验证明文密码与 bcrypt 哈希是否匹配
func checkPassword(plain, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
// generateAndSaveToken 生成 JWT 并存入 Redis
func generateAndSaveToken(userId int64) (string, *srvdef.SrvErr) {
token, err := utils.GenerateUserToken(utils.UserClaims{
UserID: userId,
Expire: consts.UserJwtTokenExpire,
}, appg.Conf.JwtKey)
if err != nil {
return "", srvdef.New(srvdef.GenerateTokenFailed, srvdef.GenerateTokenFailed.Msg(), "")
}
if err = tokener.Save(userId, token); err != nil {
return "", srvdef.New(srvdef.StoreLevel, "token存储失败", "")
}
return token, nil
}
// generateCrawlerToken 签发爬虫观看 JWT,不写入 Redis,避免占用真实用户槽位。
func generateCrawlerToken() (string, *srvdef.SrvErr) {
token, err := utils.GenerateUserToken(utils.UserClaims{
UserID: pubdef.CrawlerUserId,
Expire: consts.UserJwtTokenExpire,
}, appg.Conf.JwtKey)
if err != nil {
return "", srvdef.New(srvdef.GenerateTokenFailed, srvdef.GenerateTokenFailed.Msg(), "")
}
return token, nil
}
// CrawlerLoginResp 爬虫虚拟游客资料,不对应任何数据库用户。
func CrawlerLoginResp(token string) *LoginResp {
return &LoginResp{
Token: token,
UserId: pubdef.CrawlerUserId,
NickName: "游客",
Status: pubdef.UserStatusNormal,
Type: pubdef.UserTypeGuest,
LoginType: pubdef.LoginTypeDevice,
IsRegister: false,
IsNewUser: false,
}
}
// Register 用户注册
// guestUserId:请求头 Authorization 解析出的用户ID;若为游客则升级该用户,否则新建
// agentInviteCode:邀请人的邀请码,可为空
func Register(ctx context.Context, guestUserId int64, accountNo, email, password, agentInviteCode string, ua useragent.UA, ip, channel string) (*LoginResp, *srvdef.SrvErr) {
accountNo = strings.TrimSpace(accountNo)
email = strings.TrimSpace(email)
password = strings.TrimSpace(password)
// 检查账号是否已存在
exist, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.AccountNo: accountNo})
if exist != nil {
return nil, srvdef.New(srvdef.ErrDbInputExist, "账号已存在", "")
}
// 填写邮箱时检查是否已存在
if email != "" {
exist, _ = usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Email: email})
if exist != nil {
return nil, srvdef.New(srvdef.ErrDbInputExist, "邮箱已被注册", "")
}
}
hashed, err := hashPassword(password)
if err != nil {
return nil, srvdef.New(srvdef.Failure, "密码处理失败", "")
}
now := time.Now()
// 请求头带游客 token:升级当前游客为普通用户
if guestUserId > 0 {
guest, serr := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: guestUserId})
if serr == nil && isGuestUser(guest) {
setDoc := bson.M{
usermod.FN.User.AccountNo: accountNo,
usermod.FN.User.Email: email,
usermod.FN.User.Password: hashed,
usermod.FN.User.Type: pubdef.UserTypeNormal,
usermod.FN.User.IsRegister: true,
usermod.FN.User.LoginType: pubdef.LoginTypeAccount,
usermod.FN.User.RegisterTime: now,
usermod.FN.User.LastLoginAt: now,
usermod.FN.User.UpdatedAt: now,
usermod.FN.User.LastIp: ip,
}
if agentInviteCode != "" && guest.AgentInviteCode == "" {
setDoc[usermod.FN.User.AgentInviteCode] = agentInviteCode
setDoc[usermod.FN.User.IsDirect] = false
guest.IsDirect = false
}
if v := strings.TrimSpace(ua.DevID); v != "" {
setDoc[usermod.FN.User.DevID] = v
}
if v := strings.TrimSpace(ua.DevType); v != "" {
setDoc[usermod.FN.User.Device] = v
}
if v := strings.TrimSpace(ua.SysType); v != "" {
setDoc[usermod.FN.User.SysType] = v
}
if v := strings.TrimSpace(ua.Ver); v != "" {
setDoc[usermod.FN.User.AppVer] = v
}
mergeUAExtFieldsToSet(setDoc, guest, ua)
if serr = usermod.UpdateOneUser(ctx, bson.M{usermod.FN.User.Id: guest.Id}, bson.M{"$set": setDoc}); serr != nil {
return nil, serr
}
if agentInviteCode != "" && guest.AgentInviteCode == "" {
_ = usermod.UpdateOneUser(ctx,
bson.M{usermod.FN.User.InviteCode: agentInviteCode},
bson.M{
"$inc": bson.M{usermod.FN.User.Invites: 1},
"$set": bson.M{usermod.FN.User.UpdatedAt: now},
},
)
}
guest.AccountNo = accountNo
guest.Email = email
guest.Type = pubdef.UserTypeNormal
guest.IsRegister = true
guest.LoginType = pubdef.LoginTypeAccount
guest.RegisterTime = now
token, serr := generateAndSaveToken(guest.Id)
if serr != nil {
return nil, serr
}
reportser.ReportUserRegister(guest, ua, ip, "username")
reportser.ReportUserLogin(guest, ua, ip, pubdef.LoginTypeAccount)
return toLoginResp(ctx, guest, token, true), nil
}
}
user := &usermod.User{
Id: mongos.Lid.User.Get(),
AccountNo: accountNo,
Email: email,
Password: hashed,
NickName: appg.GenerateNickname(),
Avatar: randomAvatar(),
InviteCode: generateUniqueInviteCode(ctx),
AgentInviteCode: agentInviteCode,
Type: pubdef.UserTypeNormal,
Status: pubdef.UserStatusNormal,
IsRegister: true,
LoginType: pubdef.LoginTypeAccount,
RegIp: ip,
LastIp: ip,
DevID: ua.DevID,
Device: ua.DevType,
SysType: ua.SysType,
AppVer: ua.Ver,
OriVer: ua.Ver,
SessionID: ua.SessionID,
DeviceBrand: ua.DeviceBrand,
DeviceModel: ua.DeviceModel,
SystemName: ua.SystemName,
SystemVersion: ua.SystemVersion,
DistrictCode: normalizeChannel(channel),
IsDirect: resolveUserDistrict(channel, agentInviteCode, ip, ua.SysType).IsDirect,
LastLoginAt: now,
RegisterTime: now,
CreatedAt: now,
UpdatedAt: now,
}
if serr := usermod.InsertOneUser(ctx, user); serr != nil {
return nil, serr
}
// 若填写了邀请码,给对应用户的 invites +1
if agentInviteCode != "" {
_ = usermod.UpdateOneUser(ctx,
bson.M{usermod.FN.User.InviteCode: agentInviteCode},
bson.M{
"$inc": bson.M{usermod.FN.User.Invites: 1},
"$set": bson.M{usermod.FN.User.UpdatedAt: time.Now()},
},
)
}
token, serr := generateAndSaveToken(user.Id)
if serr != nil {
return nil, serr
}
reportser.ReportUserRegister(user, ua, ip, "username")
reportser.ReportUserLogin(user, ua, ip, pubdef.LoginTypeAccount)
return toLoginResp(ctx, user, token, false), nil
}
// Login 用户登录(账号或邮箱均可)
func Login(ctx context.Context, account, password string, ua useragent.UA, ip, channel string) (*LoginResp, *srvdef.SrvErr) {
// 账号或邮箱均可登录
user, _ := usermod.FindOneUser(ctx, bson.M{
"$or": []bson.M{
{usermod.FN.User.AccountNo: account},
{usermod.FN.User.Email: account},
},
})
if user == nil {
return nil, srvdef.New(srvdef.AccountIsNotExists, srvdef.AccountIsNotExists.Msg(), "")
}
if user.Status == pubdef.UserStatusDisable {
return nil, srvdef.New(srvdef.AccountErrAccessForbid, srvdef.AccountErrAccessForbid.Msg(), "")
}
if !checkPassword(password, user.Password) {
return nil, srvdef.New(srvdef.AccountErrPasswordError, srvdef.AccountErrPasswordError.Msg(), "")
}
token, serr := generateAndSaveToken(user.Id)
if serr != nil {
return nil, serr
}
now := time.Now()
setDoc := bson.M{
usermod.FN.User.LastLoginAt: now,
usermod.FN.User.UpdatedAt: now,
}
if ip != "" {
setDoc[usermod.FN.User.LastIp] = ip
}
if v := strings.TrimSpace(ua.DevID); v != "" {
setDoc[usermod.FN.User.DevID] = v
}
if v := strings.TrimSpace(ua.DevType); v != "" {
setDoc[usermod.FN.User.Device] = v
}
if v := strings.TrimSpace(ua.SysType); v != "" {
setDoc[usermod.FN.User.SysType] = v
}
if v := strings.TrimSpace(ua.Ver); v != "" {
setDoc[usermod.FN.User.AppVer] = v
}
// 已有账号的历史用户统一修正为普通用户
if strings.TrimSpace(user.AccountNo) != "" && user.Type != pubdef.UserTypeNormal {
setDoc[usermod.FN.User.Type] = pubdef.UserTypeNormal
setDoc[usermod.FN.User.IsRegister] = true
user.Type = pubdef.UserTypeNormal
user.IsRegister = true
}
mergeUAExtFieldsToSet(setDoc, user, ua)
_ = usermod.UpdateOneUser(ctx,
bson.M{usermod.FN.User.Id: user.Id},
bson.M{"$set": setDoc},
)
reportser.ReportUserLogin(user, ua, ip, pubdef.LoginTypeAccount)
return toLoginResp(ctx, user, token, true), nil
}
// GetInfo 获取当前登录用户的最新信息(不刷新 token)
func GetInfo(ctx context.Context, userId int64, ua useragent.UA) (*LoginResp, *srvdef.SrvErr) {
if pubdef.IsCrawlerUser(userId) {
return CrawlerLoginResp(""), nil
}
user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
if user == nil {
return nil, srvdef.New(srvdef.ErrUserNotExsit, srvdef.ErrUserNotExsit.Msg(), "")
}
now := time.Now()
setDoc := bson.M{}
if v := strings.TrimSpace(ua.DevID); v != "" && v != user.DevID {
setDoc[usermod.FN.User.DevID] = v
}
if v := strings.TrimSpace(ua.DevType); v != "" && v != user.Device {
setDoc[usermod.FN.User.Device] = v
}
if v := strings.TrimSpace(ua.SysType); v != "" && v != user.SysType {
setDoc[usermod.FN.User.SysType] = v
}
if v := strings.TrimSpace(ua.Ver); v != "" && v != user.AppVer {
setDoc[usermod.FN.User.AppVer] = v
}
if strings.TrimSpace(user.AccountNo) != "" && user.Type != pubdef.UserTypeNormal {
setDoc[usermod.FN.User.Type] = pubdef.UserTypeNormal
setDoc[usermod.FN.User.IsRegister] = true
user.Type = pubdef.UserTypeNormal
user.IsRegister = true
}
mergeUAExtFieldsToSet(setDoc, user, ua)
if len(setDoc) > 0 {
setDoc[usermod.FN.User.UpdatedAt] = now
_ = usermod.UpdateOneUser(ctx, bson.M{usermod.FN.User.Id: user.Id}, bson.M{"$set": setDoc})
}
return toLoginResp(ctx, user, "", true), nil
}
func mergeUAExtFieldsToSet(setDoc bson.M, user *usermod.User, ua useragent.UA) {
if user == nil {
return
}
if v := strings.TrimSpace(ua.SessionID); v != "" && v != user.SessionID {
setDoc[usermod.FN.User.SessionID] = v
}
if v := strings.TrimSpace(ua.DeviceBrand); v != "" && v != user.DeviceBrand {
setDoc[usermod.FN.User.DeviceBrand] = v
}
if v := strings.TrimSpace(ua.DeviceModel); v != "" && v != user.DeviceModel {
setDoc[usermod.FN.User.DeviceModel] = v
}
if v := strings.TrimSpace(ua.SystemName); v != "" && v != user.SystemName {
setDoc[usermod.FN.User.SystemName] = v
}
if v := strings.TrimSpace(ua.SystemVersion); v != "" && v != user.SystemVersion {
setDoc[usermod.FN.User.SystemVersion] = v
}
}
// UpdateProfileReq 修改用户资料的参数(所有字段均为可选)
type UpdateProfileReq struct {
UserId int64 // 用户ID(从 token 中获取)
NickName *string // 新昵称(可选)
Avatar *string // 新头像 URL(可选)
Introduction *string // 简介(可选)
PrivacyMode *bool // 隐私模式:true-开启,false-关闭(可选)
OldPassword *string // 旧密码明文(已设置密码时修改必传)
NewPassword *string // 新密码明文(设置或修改密码时必传)
}
// UpdateProfile 修改用户资料:支持修改头像、简介、密码,或组合修改
func UpdateProfile(ctx context.Context, req *UpdateProfileReq) *srvdef.SrvErr {
user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: req.UserId})
if user == nil {
return srvdef.New(srvdef.ErrUserNotExsit, srvdef.ErrUserNotExsit.Msg(), "")
}
setDoc := bson.M{usermod.FN.User.UpdatedAt: time.Now()}
if req.NickName != nil && *req.NickName != "" {
setDoc[usermod.FN.User.NickName] = *req.NickName
}
if req.Avatar != nil && *req.Avatar != "" {
setDoc[usermod.FN.User.Avatar] = *req.Avatar
}
if req.Introduction != nil {
setDoc[usermod.FN.User.Introduction] = strings.TrimSpace(*req.Introduction)
}
if req.PrivacyMode != nil {
setDoc[usermod.FN.User.PrivacyMode] = *req.PrivacyMode
}
// 设置或修改密码
if req.OldPassword != nil || req.NewPassword != nil {
if req.NewPassword == nil || strings.TrimSpace(*req.NewPassword) == "" {
return srvdef.New(srvdef.ErrParamError, "新密码不能为空", "")
}
if strings.TrimSpace(user.Password) != "" {
if req.OldPassword == nil || strings.TrimSpace(*req.OldPassword) == "" {
return srvdef.New(srvdef.ErrParamError, "修改密码时旧密码和新密码均需提供", "")
}
if !checkPassword(*req.OldPassword, user.Password) {
return srvdef.New(srvdef.AccountErrPasswordError, "旧密码错误", "")
}
} else if req.OldPassword != nil && strings.TrimSpace(*req.OldPassword) != "" {
return srvdef.New(srvdef.ErrParamError, "修改密码时旧密码和新密码均需提供", "")
}
hashed, err := hashPassword(*req.NewPassword)
if err != nil {
return srvdef.New(srvdef.Failure, "密码处理失败", "")
}
setDoc[usermod.FN.User.Password] = hashed
}
// 没有任何需要更新的字段
if len(setDoc) == 1 {
return srvdef.New(srvdef.ErrParamError, "至少需要修改一项资料", "")
}
return usermod.UpdateOneUser(ctx,
bson.M{usermod.FN.User.Id: user.Id},
bson.M{"$set": setDoc},
)
}
// ResetPassword 通过账号+邮箱双重校验后重置密码
func ResetPassword(ctx context.Context, accountNo, email, newPassword string) *srvdef.SrvErr {
user, _ := usermod.FindOneUser(ctx, bson.M{
usermod.FN.User.AccountNo: accountNo,
usermod.FN.User.Email: email,
})
if user == nil {
return srvdef.New(srvdef.ErrUserNotExsit, "账号或邮箱不匹配", "")
}
hashed, err := hashPassword(newPassword)
if err != nil {
return srvdef.New(srvdef.Failure, "密码处理失败", "")
}
return usermod.UpdateOneUser(ctx,
bson.M{usermod.FN.User.Id: user.Id},
bson.M{"$set": bson.M{
usermod.FN.User.Password: hashed,
usermod.FN.User.UpdatedAt: time.Now(),
}},
)
}
+68
View File
@@ -0,0 +1,68 @@
package authser
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"videoweb/app/appg"
"videoweb/pub/common/log"
"videoweb/pub/common/statcenter"
"videoweb/pub/common/utils"
)
// UserDistrictInfo 统计中心返回的渠道/直推信息
type UserDistrictInfo struct {
IsDirect bool `json:"isDirect"`
DistrictCode string `json:"districtCode"`
ParentPromCode string `json:"parentPromCode"`
ParentId uint64 `json:"parentId"`
ParentInviteCount int64 `json:"parentInviteCount"`
}
type userDistrictReq struct {
AppId int32 `json:"appId"`
DistrictCode string `json:"dc"`
PromSeqe string `json:"pc"`
IP string `json:"ip"`
SysType string `json:"sysType"`
}
// resolveUserDistrict 有邀请码视为非直推;否则向统计中心查询补量/直推。
func resolveUserDistrict(channel, inviteCode, ip, sysType string) UserDistrictInfo {
inviteCode = strings.TrimSpace(inviteCode)
if inviteCode != "" {
return UserDistrictInfo{IsDirect: false, ParentPromCode: inviteCode}
}
return GetUserDistictInfo(channel, inviteCode, ip, sysType)
}
// GetUserDistictInfo 查询统计中心用户渠道信息,对齐 ksx_server。
func GetUserDistictInfo(dc, pc, ip, sysType string) (districtInfo UserDistrictInfo) {
base := strings.TrimSpace(appg.Conf.NewProductUrl)
if base == "" {
return
}
request := userDistrictReq{
AppId: statcenter.STAT_APPID,
SysType: sysType,
DistrictCode: dc,
PromSeqe: pc,
IP: ip,
}
bodyStr, err := json.Marshal(request)
if err != nil {
log.Info(fmt.Sprintf("GetUserDistictInfo json.Marshal is fail error:%+v/data:%+v", err, request))
return
}
url := strings.TrimRight(base, "/") + "/api/stat/userInvite/district"
code, err := utils.POSTWithJResp(&districtInfo, url, nil, bodyStr)
if err != nil {
log.Error("GetUserDistictInfo POSTWithJResp", log.Any("url", url), log.E(err))
return
}
if code != http.StatusOK {
log.Error("GetUserDistictInfo response status", log.Any("code", code))
}
return
}
+262
View File
@@ -0,0 +1,262 @@
package authser
import (
"context"
"fmt"
"strings"
"time"
"videoweb/app/appg"
"videoweb/app/middleware/reptilerecord"
"videoweb/app/middleware/visit"
"videoweb/app/service/configser"
"videoweb/app/service/reportser"
"videoweb/pub/common/log"
"videoweb/pub/common/netutil"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/useragent"
"videoweb/pub/models/usermod"
"videoweb/pub/mongos"
"videoweb/pub/pubdef"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson"
)
// GuestLoginReq 游客登录请求
type GuestLoginReq struct {
DevID string `json:"devID"` // 设备唯一标示(必填)
Mac string `json:"mac"` // 设备 Mac(可选)
DeviceId string `json:"deviceId"` // 备用设备ID(可选,写入 DevV2)
Channel string `json:"channel"` // 渠道码(可选,用户无商区码时写入 DistrictCode)
}
// isGuestUser 是否为可升级的游客身份
func isGuestUser(user *usermod.User) bool {
if user == nil {
return false
}
if user.Type == pubdef.UserTypeNormal {
return false
}
return !user.IsRegister && strings.TrimSpace(user.AccountNo) == ""
}
// GuestLogin 游客登录:按 token / DevID / Mac / DeviceId 查找,不存在则创建游客
func GuestLogin(ctx context.Context, userId int64, ip string, req *GuestLoginReq, ua useragent.UA) (*LoginResp, *srvdef.SrvErr) {
if isBotRequest(ctx) {
return crawlerGuestLogin()
}
if req == nil || strings.TrimSpace(req.DevID) == "" {
return nil, srvdef.New(srvdef.ErrParamError, "devID不能为空", "")
}
ua.DevID = strings.TrimSpace(req.DevID)
var user *usermod.User
var serr *srvdef.SrvErr
reuseToken := false
if userId > 0 {
user, serr = usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
if serr != nil {
if serr.Code != srvdef.ErrUserNotExsit {
return nil, serr
}
userId = 0
user = nil
} else {
reuseToken = true
}
}
isNewUser := false
if userId == 0 {
user, serr = findGuestUser(ctx, req)
if serr != nil {
if serr.Code != srvdef.ErrUserNotExsit {
return nil, serr
}
user, serr = createGuestUser(ctx, ua, req, ip)
if serr != nil {
return nil, serr
}
isNewUser = true
}
}
if user.Status == pubdef.UserStatusDisable {
return nil, srvdef.New(srvdef.AccountErrAccessForbid, srvdef.AccountErrAccessForbid.Msg(), "")
}
now := time.Now()
setDoc := bson.M{
usermod.FN.User.LastLoginAt: now,
usermod.FN.User.UpdatedAt: now,
usermod.FN.User.LastIp: ip,
usermod.FN.User.LoginType: pubdef.LoginTypeDevice,
}
if v := strings.TrimSpace(ua.DevID); v != "" {
setDoc[usermod.FN.User.DevID] = v
}
if v := strings.TrimSpace(ua.DevType); v != "" {
setDoc[usermod.FN.User.Device] = v
}
if v := strings.TrimSpace(ua.SysType); v != "" {
setDoc[usermod.FN.User.SysType] = v
}
if v := strings.TrimSpace(ua.Ver); v != "" {
setDoc[usermod.FN.User.AppVer] = v
}
if v := strings.TrimSpace(req.Mac); v != "" {
setDoc[usermod.FN.User.Mac] = v
}
if v := strings.TrimSpace(req.DeviceId); v != "" {
setDoc[usermod.FN.User.DevV2] = v
}
// 历史用户 Type 为空/旧值时,未注册则归为游客
if isGuestUser(user) && user.Type != pubdef.UserTypeGuest {
setDoc[usermod.FN.User.Type] = pubdef.UserTypeGuest
user.Type = pubdef.UserTypeGuest
}
mergeUAExtFieldsToSet(setDoc, user, ua)
_ = usermod.UpdateOneUser(ctx, bson.M{usermod.FN.User.Id: user.Id}, bson.M{"$set": setDoc})
var token string
if reuseToken {
if old, err := tokener.Get(user.Id); err == nil && len(old) > 0 {
token = string(old)
}
}
if token == "" {
token, serr = generateAndSaveToken(user.Id)
if serr != nil {
return nil, serr
}
}
resp := toLoginResp(ctx, user, token, true)
resp.IsNewUser = isNewUser
if isNewUser {
visit.RecordDailyVisit(ctx, user.Id, ua, ip)
reportser.ReportUserRegister(user, ua, ip, "deviceid")
}
reportser.ReportUserLogin(user, ua, ip, pubdef.LoginTypeDevice)
return resp, nil
}
func findGuestUser(ctx context.Context, req *GuestLoginReq) (*usermod.User, *srvdef.SrvErr) {
user, serr := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.DevID: req.DevID})
if serr == nil {
return user, nil
}
if serr.Code != srvdef.ErrUserNotExsit {
return nil, serr
}
mac := strings.TrimSpace(req.Mac)
if mac != "" && !strings.Contains(mac, "00:00:00:00") {
user, serr = usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Mac: mac})
if serr == nil {
return user, nil
}
if serr.Code != srvdef.ErrUserNotExsit {
return nil, serr
}
}
deviceId := strings.TrimSpace(req.DeviceId)
if deviceId != "" && !strings.Contains(deviceId, "00:00:00:00") {
return usermod.FindOneUser(ctx, bson.M{usermod.FN.User.DevV2: deviceId})
}
return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "")
}
func createGuestUser(ctx context.Context, ua useragent.UA, req *GuestLoginReq, ip string) (*usermod.User, *srvdef.SrvErr) {
now := time.Now()
uid := mongos.Lid.User.Get()
districtInfo := resolveUserDistrict(req.Channel, "", ip, ua.SysType)
user := &usermod.User{
Id: uid,
Type: pubdef.UserTypeGuest,
Status: pubdef.UserStatusNormal,
LoginType: pubdef.LoginTypeDevice,
IsRegister: false,
NickName: fmt.Sprintf("游客%d", uid),
Avatar: randomAvatar(),
InviteCode: generateUniqueInviteCode(ctx),
DistrictCode: normalizeChannel(req.Channel),
IsDirect: districtInfo.IsDirect,
RegIp: ip,
LastIp: ip,
DevID: ua.DevID,
Mac: strings.TrimSpace(req.Mac),
DevV2: strings.TrimSpace(req.DeviceId),
Device: ua.DevType,
SysType: ua.SysType,
AppVer: ua.Ver,
OriVer: ua.Ver,
SessionID: ua.SessionID,
DeviceBrand: ua.DeviceBrand,
DeviceModel: ua.DeviceModel,
SystemName: ua.SystemName,
SystemVersion: ua.SystemVersion,
LastLoginAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if nick := appg.GenerateNickname(); nick != "" {
user.NickName = nick
}
if serr := usermod.InsertOneUser(ctx, user); serr != nil {
log.Error("authser:createGuestUser InsertOneUser failed", log.E(serr), log.Any("devID", ua.DevID))
return nil, serr
}
return user, nil
}
func toLoginResp(ctx context.Context, user *usermod.User, token string, withCDN bool) *LoginResp {
avatar := user.Avatar
if withCDN {
cdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx))
avatar = netutil.MergeURL(cdn, user.Avatar)
}
return &LoginResp{
Token: token,
UserId: user.Id,
AccountNo: user.AccountNo,
Email: user.Email,
NickName: user.NickName,
Introduction: user.Introduction,
VipType: user.VipType,
CurrentVipExpire: user.CurrentVipExpire,
ShowCardId: user.ShowCardId,
ShowCardName: user.ShowCardName,
Status: user.Status,
Avatar: avatar,
InviteCode: user.InviteCode,
Type: user.Type,
LoginType: user.LoginType,
IsRegister: user.IsRegister,
PrivacyMode: user.PrivacyMode,
HasPassword: strings.TrimSpace(user.Password) != "",
Score: user.Score,
Recharge: user.Recharge,
}
}
func normalizeChannel(channel string) string {
return strings.TrimSpace(channel)
}
func isBotRequest(ctx context.Context) bool {
gc, ok := ctx.(*gin.Context)
return ok && reptilerecord.IsBot(gc)
}
// crawlerGuestLogin 给爬虫签发可观看的虚拟游客 token,不建用户、不写日活、不上报。
func crawlerGuestLogin() (*LoginResp, *srvdef.SrvErr) {
token, serr := generateCrawlerToken()
if serr != nil {
return nil, serr
}
return CrawlerLoginResp(token), nil
}
+205
View File
@@ -0,0 +1,205 @@
package coinser
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"videoweb/app/service/rechargeser"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/useragent"
"videoweb/pub/dataReport"
"videoweb/pub/models/eventmod"
"videoweb/pub/models/rechargepackagemod"
"videoweb/pub/models/usermod"
"videoweb/pub/models/vipordermod"
"videoweb/pub/mongos"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// BuyCoinResp 购买金币下单响应
type BuyCoinResp struct {
TradeNo string `json:"tradeNo"`
PayUrl string `json:"payUrl"`
PayUrlMode string `json:"payUrlMode"`
IsOpenNewBrowser bool `json:"isOpenNewBrowser"`
}
// GetUserInfo 获取用户信息
func GetUserInfo(ctx context.Context, userId int64) (*usermod.User, *srvdef.SrvErr) {
return usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
}
// CreateCoinOrder 创建金币购买订单(统一写入 vip_order 表,OrderType=2)。
// 防重复:15 秒内同一用户+同一套餐的 pending 订单直接返回原支付链接。
func CreateCoinOrder(ctx context.Context,
userId int64, userName string,
packageId int64, payMode string,
sysType string, devType string, deviceID string, ip string,
districtCode string, sourcePageKey string, sourcePageName string,
) (*BuyCoinResp, *srvdef.SrvErr) {
pkg, serr := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{
rechargepackagemod.FN.RechargePackage.Id: packageId,
rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeCoin,
rechargepackagemod.FN.RechargePackage.Status: rechargepackagemod.StatusEnabled,
})
if serr != nil || pkg == nil {
return nil, srvdef.New(srvdef.ErrNoData, "金币套餐不存在或已下架", "")
}
coinAmount := pkg.BaseCoin + pkg.GiftCoin
if coinAmount <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "金币套餐到账数量无效", "")
}
now := time.Now()
dupFilter := bson.M{
vipordermod.FN.VipOrder.UserId: userId,
vipordermod.FN.VipOrder.CoinCfgId: packageId,
vipordermod.FN.VipOrder.OrderType: vipordermod.OrderTypeCoin,
vipordermod.FN.VipOrder.PayMode: payMode,
vipordermod.FN.VipOrder.Status: pubdef.VipOrderPending,
vipordermod.FN.VipOrder.CreatedAt: bson.M{"$gte": now.Add(-15 * time.Second)},
}
existing, _ := vipordermod.FindOneVipOrder(ctx, dupFilter)
if existing != nil && existing.PayUrl != "" {
return &BuyCoinResp{
TradeNo: existing.TradeNo,
PayUrl: existing.PayUrl,
PayUrlMode: existing.PayUrlMode,
IsOpenNewBrowser: true,
}, nil
}
tradeNo := pubdef.GetTrandNoWithUUID()
payInfo, serr := rechargeser.SubmitPayOrder(ctx, userId, userName, tradeNo, pkg.Price, payMode, sysType, devType, deviceID, ip)
if serr != nil {
return nil, serr
}
order := &vipordermod.VipOrder{
Id: mongos.Lid.VipOrder.Get(),
OrderType: vipordermod.OrderTypeCoin,
UserId: userId,
CoinCfgId: packageId, // 兼容历史字段,存购买套餐ID
Coin: coinAmount,
BaseCoin: pkg.BaseCoin,
GiftCoin: pkg.GiftCoin,
Amount: pkg.Price,
PayMode: payMode,
TradeNo: tradeNo,
Oid: payInfo.Oid,
Status: pubdef.VipOrderPending,
PayUrl: payInfo.PayUrl,
PayUrlMode: payInfo.PayUrlMode,
PayChannel: payInfo.PayChannel,
PayRate: payInfo.PayRate,
IPAddr: ip,
SysType: sysType,
DistrictCode: strings.TrimSpace(districtCode),
Client: vipordermod.NewOrderClient(useragent.FromContext(ctx), sourcePageKey, sourcePageName),
CreatedAt: now,
UpdatedAt: now,
}
if serr = vipordermod.InsertOneVipOrder(ctx, order); serr != nil {
log.Error("coinser:CreateCoinOrder InsertOneVipOrder failed",
log.Any("tradeNo", tradeNo), log.E(serr))
return nil, serr
}
go reportCoinOrderCreated(order, pkg, ip)
return &BuyCoinResp{
TradeNo: tradeNo,
PayUrl: payInfo.PayUrl,
PayUrlMode: payInfo.PayUrlMode,
IsOpenNewBrowser: true,
}, nil
}
func reportCoinOrderCreated(order *vipordermod.VipOrder, pkg *rechargepackagemod.RechargePackage, ip string) {
ctx := context.Background()
user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: order.UserId})
districtCode := order.DistrictCode
if user != nil && strings.TrimSpace(user.DistrictCode) != "" {
districtCode = user.DistrictCode
}
sourceKey, sourceName := orderSource(order)
ua := order.Client.UA(order.SysType)
eventId := newEventId(pubdef.TrackerAppId(), order.UserId, order.TradeNo, order.CreatedAt.Unix())
commonField := dataReport.NewCommonField(eventId, dataReport.EventTypeOrderCreated, districtCode, ua, strconv.FormatInt(order.UserId, 10), ip)
commonField.Payload = dataReport.JsonRawMessage(&dataReport.OrderCreatedEvent{
OrderID: order.TradeNo,
OrderType: "coin_purchase",
ProductID: strconv.FormatInt(pkg.Id, 10),
ProductName: pkg.Name,
Amount: dataReport.JsonNumber(order.Amount),
Currency: "CNY",
CoinQuantity: dataReport.JsonNumber(order.Coin),
SourcePageKey: sourceKey,
SourcePageName: sourceName,
CreateTime: dataReport.JsonNumber(order.CreatedAt.Unix()),
})
eventData, _ := json.Marshal(commonField)
if err := eventmod.InsertOneEvent(ctx, &eventmod.Event{
EventId: commonField.EventID,
EventType: commonField.Event,
Data: string(eventData),
Status: eventmod.Default,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
log.Error("reportCoinOrderCreated InsertOneEvent failed",
log.Any("tradeNo", order.TradeNo), log.E(err))
}
}
func orderSource(order *vipordermod.VipOrder) (string, string) {
key, name := "deposit", "金币充值"
if order != nil && order.Client != nil {
if value := strings.TrimSpace(order.Client.SourcePageKey); value != "" {
key = value
}
if value := strings.TrimSpace(order.Client.SourcePageName); value != "" {
name = value
}
}
return key, name
}
func newEventId(appId string, uid int64, orderId interface{}, createTime int64) string {
input := fmt.Sprintf("%v%v%v%v", appId, uid, orderId, createTime)
h := md5.New()
h.Write([]byte(input))
return hex.EncodeToString(h.Sum(nil))
}
// GetCoinOrderHistory 查询用户金币购买记录
func GetCoinOrderHistory(ctx context.Context, userId int64, page, pageSize int64) (vipordermod.VipOrderList, int64, *srvdef.SrvErr) {
filter := bson.M{
vipordermod.FN.VipOrder.UserId: userId,
vipordermod.FN.VipOrder.OrderType: vipordermod.OrderTypeCoin,
}
total, serr := vipordermod.CountVipOrder(ctx, filter)
if serr != nil {
return nil, 0, serr
}
skip := (page - 1) * pageSize
opts := options.Find().
SetSort(bson.M{vipordermod.FN.VipOrder.CreatedAt: -1}).
SetSkip(skip).
SetLimit(pageSize)
list, serr := vipordermod.FindManyVipOrder(ctx, filter, opts)
return list, total, serr
}
+254
View File
@@ -0,0 +1,254 @@
package coinser
import (
"bytes"
"context"
"encoding/csv"
"strconv"
"time"
"videoweb/app/api/proto"
"videoweb/pub/common/response"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/models/cointransmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
// coinTransDefaultPageSize 金币余额明细默认每页数量
coinTransDefaultPageSize int64 = 10
// coinTransMaxPageSize 金币余额明细最大每页数量,避免单次请求拉取过多流水
coinTransMaxPageSize int64 = 100
// coinTransTypeAll 全部交易
coinTransTypeAll int64 = 0
// coinTransTypeRecharge 充值交易
coinTransTypeRecharge int64 = 1
// coinTransTypeConsume 消费交易
coinTransTypeConsume int64 = 2
// coinTransTypeReward 奖励交易
coinTransTypeReward int64 = 3
)
var (
// coinTransListProjection 余额明细只读取列表展示需要的字段,降低Mongo读取成本
coinTransListProjection = bson.D{
{Key: cointransmod.FN.CoinTrans.Id, Value: 1},
{Key: cointransmod.FN.CoinTrans.FlowDir, Value: 1},
{Key: cointransmod.FN.CoinTrans.TranType, Value: 1},
{Key: cointransmod.FN.CoinTrans.Amount, Value: 1},
{Key: cointransmod.FN.CoinTrans.BalanceBefore, Value: 1},
{Key: cointransmod.FN.CoinTrans.BalanceAfter, Value: 1},
{Key: cointransmod.FN.CoinTrans.OrderNo, Value: 1},
{Key: cointransmod.FN.CoinTrans.RelatedId, Value: 1},
{Key: cointransmod.FN.CoinTrans.Remark, Value: 1},
{Key: cointransmod.FN.CoinTrans.CreatedAt, Value: 1},
}
// coinTransListSort 按交易时间倒序展示余额明细
coinTransListSort = bson.D{
{Key: cointransmod.FN.CoinTrans.CreatedAt, Value: -1},
}
// coinTransListHint 匹配 coin_trans 的 userId+createdAt 复合索引
coinTransListHint = bson.D{
{Key: cointransmod.FN.CoinTrans.UserId, Value: 1},
{Key: cointransmod.FN.CoinTrans.CreatedAt, Value: -1},
}
)
// GetCoinTransList 查询当前用户金币余额明细
//
// 只查询当前登录用户的流水,按交易时间倒序分页,pageSize 最大限制为100
func GetCoinTransList(ctx context.Context, userId int64, page, pageSize int64, transType int64) (*response.BasePageResp, *srvdef.SrvErr) {
if userId <= 0 {
return nil, srvdef.New(srvdef.ErrUserNotExsit, "", "")
}
page, pageSize = normalizeCoinTransPage(page, pageSize)
filter := bson.M{
cointransmod.FN.CoinTrans.UserId: userId,
}
if err := applyCoinTransTypeFilter(filter, transType); err != nil {
return nil, err
}
total, err := cointransmod.CountCoinTrans(ctx, filter)
if err != nil {
return nil, err
}
if total <= 0 {
return &response.BasePageResp{
TotalCount: 0,
PageSize: int(pageSize),
PageNum: int(page),
List: make([]*proto.CoinTransListItemResp, 0),
}, nil
}
list, err1 := cointransmod.FindManyCoinTrans(ctx, filter, options.Find().
SetProjection(coinTransListProjection).
SetSort(coinTransListSort).
SetHint(coinTransListHint).
SetSkip((page-1)*pageSize).
SetLimit(pageSize))
if err1 != nil {
return nil, err1
}
resp := make([]*proto.CoinTransListItemResp, 0, len(list))
for _, item := range list {
resp = append(resp, coinTransToListItemResp(item))
}
return &response.BasePageResp{
TotalCount: total,
PageSize: int(pageSize),
PageNum: int(page),
List: resp,
}, nil
}
// ExportCoinTrans 导出当前用户金币余额明细CSV
func ExportCoinTrans(ctx context.Context, userId int64, transType int64) ([]byte, string, *srvdef.SrvErr) {
if userId <= 0 {
return nil, "", srvdef.New(srvdef.ErrUserNotExsit, "", "")
}
filter := bson.M{
cointransmod.FN.CoinTrans.UserId: userId,
}
if err := applyCoinTransTypeFilter(filter, transType); err != nil {
return nil, "", err
}
list, err := cointransmod.FindManyCoinTrans(ctx, filter, options.Find().
SetProjection(coinTransListProjection).
SetSort(coinTransListSort).
SetHint(coinTransListHint))
if err != nil {
return nil, "", err
}
data, serr := buildCoinTransCSV(list)
if serr != nil {
return nil, "", serr
}
return data, coinTransExportFileName(transType), nil
}
// buildCoinTransCSV 构建金币流水CSV文件内容
func buildCoinTransCSV(list cointransmod.CoinTransList) ([]byte, *srvdef.SrvErr) {
var buf bytes.Buffer
buf.WriteString("\xEF\xBB\xBF")
writer := csv.NewWriter(&buf)
if err := writer.Write([]string{"类型", "说明", "时间", "金币变动"}); err != nil {
return nil, srvdef.New(srvdef.ExcelExportErr, "文件导出错误", "文件导出错误")
}
for _, item := range list {
resp := coinTransToListItemResp(item)
if resp == nil {
continue
}
if err := writer.Write([]string{
resp.TranTypeName,
resp.Title,
resp.CreatedAt,
formatCoinTransChange(resp.ChangeAmount),
}); err != nil {
return nil, srvdef.New(srvdef.ExcelExportErr, "文件导出错误", "文件导出错误")
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, srvdef.New(srvdef.ExcelExportErr, "文件导出错误", "文件导出错误")
}
return buf.Bytes(), nil
}
// coinTransExportFileName 生成金币流水导出文件名
func coinTransExportFileName(transType int64) string {
name := "交易明细"
switch transType {
case coinTransTypeRecharge:
name = "充值明细"
case coinTransTypeConsume:
name = "消费明细"
case coinTransTypeReward:
name = "奖励明细"
}
return name + "_" + time.Now().In(utils.LocBJ).Format("20060102150405") + ".csv"
}
// formatCoinTransChange 格式化金币变动值
func formatCoinTransChange(change int64) string {
if change > 0 {
return "+" + strconv.FormatInt(change, 10)
}
return strconv.FormatInt(change, 10)
}
// applyCoinTransTypeFilter 按交易明细Tab追加筛选条件
func applyCoinTransTypeFilter(filter bson.M, transType int64) *srvdef.SrvErr {
switch transType {
case coinTransTypeAll:
return nil
case coinTransTypeRecharge:
filter[cointransmod.FN.CoinTrans.TranType] = cointransmod.TranTypeCoinRecharge
case coinTransTypeConsume:
filter[cointransmod.FN.CoinTrans.FlowDir] = cointransmod.FlowOut
case coinTransTypeReward:
filter[cointransmod.FN.CoinTrans.FlowDir] = cointransmod.FlowIn
filter[cointransmod.FN.CoinTrans.TranType] = bson.M{"$ne": cointransmod.TranTypeCoinRecharge}
default:
return srvdef.New(srvdef.ErrParamError, "类型错误", "类型错误")
}
return nil
}
// normalizeCoinTransPage 规范化金币流水分页参数
//
// page 小于等于0时按第1页处理;pageSize 未传时使用默认值,超过上限时截断
func normalizeCoinTransPage(page, pageSize int64) (int64, int64) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = coinTransDefaultPageSize
}
if pageSize > coinTransMaxPageSize {
pageSize = coinTransMaxPageSize
}
return page, pageSize
}
// coinTransToListItemResp 转换金币流水响应项
//
// amount 保持数据库中的正数金额,changeAmount 根据收支方向转换为带正负号的变动金额
func coinTransToListItemResp(item *cointransmod.CoinTrans) *proto.CoinTransListItemResp {
title := item.Remark
if title == "" {
title = item.TranType.Desc()
}
changeAmount := item.Amount
if item.FlowDir == cointransmod.FlowOut {
changeAmount = -item.Amount
}
return &proto.CoinTransListItemResp{
Id: item.Id,
Title: title,
FlowDir: item.FlowDir,
FlowDirName: item.FlowDir.Desc(),
TranType: item.TranType,
TranTypeName: item.TranType.Desc(),
Amount: item.Amount,
ChangeAmount: changeAmount,
BalanceBefore: item.BalanceBefore,
BalanceAfter: item.BalanceAfter,
OrderNo: item.OrderNo,
RelatedId: item.RelatedId,
Remark: item.Remark,
CreatedAt: formatCoinTransTime(item.CreatedAt),
}
}
func formatCoinTransTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.In(utils.LocBJ).Format(pubdef.TimeISO)
}
+705
View File
@@ -0,0 +1,705 @@
package commentser
import (
"context"
"strconv"
"strings"
"time"
"unicode/utf8"
"videoweb/app/api/proto"
"videoweb/app/service/configser"
"videoweb/app/service/reportser"
"videoweb/pub/common/enum/redisconst"
"videoweb/pub/common/log"
"videoweb/pub/common/netutil"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/commentlikemod"
"videoweb/pub/models/commentmod"
"videoweb/pub/models/playletchaptermod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/usermod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/mongos"
"videoweb/pub/redis"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
ObjectTypePlaylet int64 = 10 // 短剧评论
ObjectTypePlayletChapter int64 = 11 // 短剧剧集评论
ObjectTypeVideo int64 = 20 // 视频评论
commentLevelFirst int64 = 1 // 一级评论
commentLevelSecond int64 = 2 // 二级评论
commentReviewPending int = 0 // 待审核
commentReviewPass int = 1 // 审核通过
commentMaxTextLen int = 200 // 评论最大字数
commentDefaultPageSize int64 = 20 // 默认分页条数
commentMaxPageSize int64 = 100 // 最大分页条数
commentSortHot int64 = 1 // 热门
commentSortNewest int64 = 2 // 最新
commentPlayletHeatInc int64 = 3 // 发布评论增加的短剧热度值
operateStatusYes int64 = 1 // 已操作
operateStatusNo int64 = 2 // 未操作
)
var commentListProjection = bson.D{
{Key: commentmod.FN.Comment.Id, Value: 1},
{Key: commentmod.FN.Comment.ObjectId, Value: 1},
{Key: commentmod.FN.Comment.ObjectType, Value: 1},
{Key: commentmod.FN.Comment.UserId, Value: 1},
{Key: commentmod.FN.Comment.UserName, Value: 1},
{Key: commentmod.FN.Comment.UserAvatar, Value: 1},
{Key: commentmod.FN.Comment.Text, Value: 1},
{Key: commentmod.FN.Comment.Level, Value: 1},
{Key: commentmod.FN.Comment.ParentsId, Value: 1},
{Key: commentmod.FN.Comment.ReplyCommentId, Value: 1},
{Key: commentmod.FN.Comment.ReplyUserName, Value: 1},
{Key: commentmod.FN.Comment.CommentCount, Value: 1},
{Key: commentmod.FN.Comment.CountLike, Value: 1},
{Key: commentmod.FN.Comment.CreatedAt, Value: 1},
}
// SendComment 发布评论
func SendComment(ctx context.Context, userId int64, ip string, req *proto.CommentSendReq) *srvdef.SrvErr {
// 评论入口先做频控和基础参数校验,再校验短剧/剧集是否可评论,避免无效请求进入写库链路。
if userId <= 0 {
return srvdef.New(srvdef.InvalidToken, "请先登录", "")
}
if err := checkCommentLimit(ip, req); err != nil {
return err
}
text, err := normalizeCommentText(req)
if err != nil {
return err
}
objectName, playletId, err := validateCommentTarget(ctx, req.ObjectType, req.ObjectId)
if err != nil {
return err
}
level := commentLevelFirst
parentsId := int64(0)
replyCommentId := int64(0)
replyUserName := ""
if req.ParentsId > 0 {
replyComment, err := findReplyTargetComment(ctx, req)
if err != nil {
return err
}
level = commentLevelSecond
parentsId = req.ParentsId
replyCommentId = replyComment.Id
replyUserName = replyComment.UserName
}
user, err := findCommentUser(ctx, userId)
if err != nil {
return err
}
now := time.Now()
info := commentmod.CommentModel{
Id: mongos.Lid.Comment.Get(),
Ip: ip,
ObjectId: req.ObjectId,
ObjectName: objectName,
ObjectType: req.ObjectType,
UserId: user.Id,
UserName: commentUserName(user),
UserAvatar: user.Avatar,
Level: level,
ParentsId: parentsId,
ReplyCommentId: replyCommentId,
ReplyUserName: replyUserName,
Text: text,
CommentCount: 0,
CountLike: 0,
ReviewStatus: commentReviewPending,
UpdatedAt: now,
CreatedAt: now,
}
if err := commentmod.InsertOneComment(ctx, &info); err != nil {
log.Error("插入评论失败", log.Any("评论信息", info), log.E(err))
return err
}
if isPlayletCommentType(req.ObjectType) {
if err := incrementCommentPlayletHeatValue(ctx, playletId, commentPlayletHeatInc); err != nil {
log.Error("评论发布后更新短剧热度失败", log.Any("短剧ID", playletId), log.Any("评论信息", info), log.E(err))
return err
}
chapterId := int64(0)
if req.ObjectType == ObjectTypePlayletChapter {
chapterId = req.ObjectId
}
reportser.ReportPlayletComment(ctx, user, playletId, chapterId, text)
} else if req.ObjectType == ObjectTypeVideo {
reportser.ReportVideoComment(ctx, user, req.ObjectId, text)
}
// app端发布评论先进入待审核状态,不立即增加业务对象评论数;后台审核通过时再统一同步 countComment
return nil
}
func findCommentUser(ctx context.Context, userId int64) (*usermod.User, *srvdef.SrvErr) {
user, err := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
if err != nil {
log.Error("查询评论用户失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
return user, nil
}
func commentUserName(user *usermod.User) string {
if user == nil {
return ""
}
if name := strings.TrimSpace(user.NickName); name != "" {
return name
}
if name := strings.TrimSpace(user.AccountNo); name != "" {
return name
}
return "用户" + strconv.FormatInt(user.Id, 10)
}
// GetCommentPage 分页查询评论列表
func GetCommentPage(ctx context.Context, userId int64, req *proto.GetCommentListReq) (*proto.GetCommentListResp, *srvdef.SrvErr) {
// 列表只查一级且已审核评论,并通过一次批量查询补齐当前用户点赞状态,避免列表 N+1 查询。
if req == nil || req.ObjectId <= 0 || !isSupportedCommentType(req.ObjectType) {
return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "")
}
pageNum, pageSize := normalizeCommentPage(req.PageNum, req.PageSize)
return getCommentPageData(ctx, userId, req.ObjectId, req.ObjectType, req.SortOrder, pageNum, pageSize)
}
// GetCommentList 兼容旧详情接口的评论查询
func GetCommentList(ctx context.Context, objectId, objectType, page int64) (*proto.GetCommentListResp, *srvdef.SrvErr) {
pageNum, _ := normalizeCommentPage(page, commentDefaultPageSize)
return getCommentPageData(ctx, 0, objectId, objectType, commentSortNewest, pageNum, commentDefaultPageSize)
}
// SetCommentLike 设置当前用户评论点赞状态
func SetCommentLike(ctx context.Context, userId int64, req *proto.CommentLikeSetReq) (*proto.CommentLikeSetResp, *srvdef.SrvErr) {
// 使用评论点赞关系表做去重;只有关系真实变化时才更新 _comment.countLike,重复点赞/取消不会重复增减计数。
if userId <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "")
}
if req == nil || req.CommentId <= 0 || req.IsLike == nil {
return nil, srvdef.New(srvdef.ErrParamError, "参数错误", "")
}
comment, err := findLikeTargetComment(ctx, req.CommentId)
if err != nil {
return nil, err
}
filter := bson.M{
commentlikemod.FN.CommentLike.UserId: userId,
commentlikemod.FN.CommentLike.CommentId: req.CommentId,
}
changed := false
if *req.IsLike {
inserted, err := commentlikemod.InsertOneCommentLike(ctx, &commentlikemod.CommentLike{
UserId: userId,
CommentId: req.CommentId,
ObjectId: comment.ObjectId,
ObjectType: comment.ObjectType,
})
if err != nil {
log.Error("新增评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", req.CommentId), log.E(err))
return nil, err
}
changed = inserted
} else {
deleted, err := commentlikemod.DeleteOneCommentLike(ctx, filter)
if err != nil {
log.Error("删除评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", req.CommentId), log.E(err))
return nil, err
}
changed = deleted > 0
}
if changed {
delta := int64(1)
if !*req.IsLike {
delta = -1
}
if err := incrementCommentLikeCount(ctx, req.CommentId, delta); err != nil {
rollbackCommentLike(ctx, userId, comment, *req.IsLike)
return nil, err
}
}
return &proto.CommentLikeSetResp{
CommentId: req.CommentId,
IsLike: operateStatusByFlag(*req.IsLike),
CountLike: applyLocalCounter(comment.CountLike, *req.IsLike, changed),
}, nil
}
func checkCommentLimit(ip string, req *proto.CommentSendReq) *srvdef.SrvErr {
curValue, err := redis.Incr(redisconst.CommentLimitKey(ip), redisconst.CommentLimitKeyExpire)
if err != nil {
log.Error("评论频率限制写入Redis失败", log.E(err), log.Any("IP", ip))
}
if curValue > 1 {
log.Error("评论操作过于频繁", log.Any("IP", ip), log.Any("请求参数", req))
return srvdef.New(srvdef.ErrOperationTooFrequently, "", "评论太频繁")
}
dayCurValue, err := redis.Incr(redisconst.CommentOneDayLimitKey(ip), redisconst.CommentDayLimitKeyExpire)
if err != nil {
log.Error("评论每日限制写入Redis失败", log.E(err), log.Any("IP", ip))
}
if dayCurValue > 50 {
log.Error("评论每日次数超限", log.Any("IP", ip), log.Any("请求参数", req))
return srvdef.New(srvdef.ErrOperationTooFrequently, "", "评论太频繁")
}
return nil
}
func normalizeCommentText(req *proto.CommentSendReq) (string, *srvdef.SrvErr) {
if req == nil || req.ObjectId <= 0 || !isSupportedCommentType(req.ObjectType) {
return "", srvdef.New(srvdef.ErrParamError, "参数错误", "")
}
text := strings.TrimSpace(req.Text)
if text == "" {
return "", srvdef.New(srvdef.CommentTextIsEmpty, "评论内容不能为空", "")
}
if utf8.RuneCountInString(text) > commentMaxTextLen {
return "", srvdef.New(srvdef.ErrParamError, "评论内容不能超过200字", "")
}
return text, nil
}
func validateCommentTarget(ctx context.Context, objectType, objectId int64) (string, int64, *srvdef.SrvErr) {
switch objectType {
case ObjectTypePlaylet:
playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: objectId,
playletmod.FN.Playlet.Status: playletmod.StatusUp,
}, options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Title, Value: 1},
}))
if err != nil {
log.Error("查询评论短剧目标失败", log.Any("短剧ID", objectId), log.E(err))
return "", 0, srvdef.New(srvdef.ErrParamError, "短剧不存在或未上架", "")
}
return playlet.Title, playlet.Id, nil
case ObjectTypePlayletChapter:
chapter, err := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{
playletchaptermod.FN.PlayletChapter.Id: objectId,
playletchaptermod.FN.PlayletChapter.Status: playletchaptermod.StatusUp,
}, options.FindOne().SetProjection(bson.D{
{Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
}))
if err != nil {
log.Error("查询评论剧集目标失败", log.Any("剧集ID", objectId), log.E(err))
return "", 0, srvdef.New(srvdef.ErrParamError, "剧集不存在", "")
}
if _, err = playletmod.FindOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: chapter.PlayletId,
playletmod.FN.Playlet.Status: playletmod.StatusUp,
}, options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
})); err != nil {
log.Error("查询剧集所属短剧失败", log.Any("剧集ID", objectId), log.Any("短剧ID", chapter.PlayletId), log.E(err))
return "", 0, srvdef.New(srvdef.ErrParamError, "短剧不存在或未上架", "")
}
if chapter.Title != "" {
return chapter.Title, chapter.PlayletId, nil
}
return "第" + strconv.FormatInt(chapter.CurrentEpisode, 10) + "集", chapter.PlayletId, nil
case ObjectTypeVideo:
video, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Id: objectId,
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
}, options.FindOne().SetProjection(bson.D{
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Title, Value: 1},
}))
if err != nil {
log.Error("查询评论视频目标失败", log.Any("视频ID", objectId), log.E(err))
return "", 0, srvdef.New(srvdef.ErrParamError, "视频不存在或未上架", "")
}
return video.Title, 0, nil
default:
return "", 0, srvdef.New(srvdef.ErrParamError, "评论类型有误", "")
}
}
func findReplyTargetComment(ctx context.Context, req *proto.CommentSendReq) (*commentmod.CommentModel, *srvdef.SrvErr) {
parentComment, err := commentmod.FindOneComment(ctx, bson.M{
commentmod.FN.Comment.Id: req.ParentsId,
commentmod.FN.Comment.ObjectId: req.ObjectId,
commentmod.FN.Comment.ObjectType: req.ObjectType,
commentmod.FN.Comment.Level: commentLevelFirst,
commentmod.FN.Comment.ParentsId: 0,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
}, options.FindOne().SetProjection(bson.D{
{Key: commentmod.FN.Comment.Id, Value: 1},
{Key: commentmod.FN.Comment.UserName, Value: 1},
}))
if err != nil {
log.Error("查询回复父评论失败", log.Any("父评论ID", req.ParentsId), log.Any("请求参数", req), log.E(err))
return nil, srvdef.New(srvdef.ErrParamError, "父评论不存在或不可回复", "")
}
replyCommentId := req.ReplyCommentId
if replyCommentId <= 0 {
replyCommentId = req.ParentsId
}
if replyCommentId == parentComment.Id {
return parentComment, nil
}
replyComment, err := commentmod.FindOneComment(ctx, bson.M{
commentmod.FN.Comment.Id: replyCommentId,
commentmod.FN.Comment.ObjectId: req.ObjectId,
commentmod.FN.Comment.ObjectType: req.ObjectType,
commentmod.FN.Comment.Level: commentLevelSecond,
commentmod.FN.Comment.ParentsId: req.ParentsId,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
}, options.FindOne().SetProjection(bson.D{
{Key: commentmod.FN.Comment.Id, Value: 1},
{Key: commentmod.FN.Comment.UserName, Value: 1},
}))
if err != nil {
log.Error("查询被回复评论失败", log.Any("被回复评论ID", replyCommentId), log.Any("请求参数", req), log.E(err))
return nil, srvdef.New(srvdef.ErrParamError, "被回复评论不存在或不可回复", "")
}
return replyComment, nil
}
func incrementTargetCommentCount(ctx context.Context, objectType, objectId int64, delta int64) *srvdef.SrvErr {
switch objectType {
case ObjectTypePlaylet:
if err := playletmod.UpdateOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: objectId,
}, bson.M{"$inc": bson.M{playletmod.FN.Playlet.CountComment: delta}}); err != nil {
log.Error("更新短剧评论数失败", log.Any("短剧ID", objectId), log.Any("增量", delta), log.E(err))
return err
}
case ObjectTypePlayletChapter:
if err := playletchaptermod.UpdateOnePlayletChapter(ctx, bson.M{
playletchaptermod.FN.PlayletChapter.Id: objectId,
}, bson.M{"$inc": bson.M{playletchaptermod.FN.PlayletChapter.CountComment: delta}}); err != nil {
log.Error("更新剧集评论数失败", log.Any("剧集ID", objectId), log.Any("增量", delta), log.E(err))
return err
}
case ObjectTypeVideo:
filter := bson.M{
videoinformationmod.FN.VideoInformation.Id: objectId,
}
if delta < 0 {
filter[videoinformationmod.FN.VideoInformation.Real.Comments] = bson.M{"$gt": 0}
}
if err := videoinformationmod.UpdateOneVideoInformation(ctx, filter, bson.M{
"$inc": bson.M{videoinformationmod.FN.VideoInformation.Real.Comments: delta},
}); err != nil {
if delta < 0 && err.Code == srvdef.ErrNoData {
log.Warn("视频评论数已为0,跳过扣减", log.Any("视频ID", objectId))
return nil
}
log.Error("更新视频评论数失败", log.Any("视频ID", objectId), log.Any("增量", delta), log.E(err))
return err
}
}
return nil
}
func incrementCommentPlayletHeatValue(ctx context.Context, playletId int64, delta int64) *srvdef.SrvErr {
if playletId <= 0 || delta == 0 {
return nil
}
return playletmod.UpdateOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: playletId,
}, bson.M{
"$inc": bson.M{
playletmod.FN.Playlet.HeatValue: delta,
},
})
}
func getCommentPageData(ctx context.Context, userId int64, objectId, objectType, sortOrder, pageNum, pageSize int64) (*proto.GetCommentListResp, *srvdef.SrvErr) {
resp := &proto.GetCommentListResp{
ObjectId: objectId,
TotalCount: 0,
PageSize: int(pageSize),
PageNum: int(pageNum),
List: make([]proto.CommentInfo, 0),
}
if objectId <= 0 || !isSupportedCommentType(objectType) {
return resp, nil
}
filter := bson.M{
commentmod.FN.Comment.ObjectId: objectId,
commentmod.FN.Comment.ObjectType: objectType,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
commentmod.FN.Comment.Level: commentLevelFirst,
commentmod.FN.Comment.ParentsId: 0,
}
total, err := commentmod.CountComments(ctx, filter)
if err != nil {
log.Warn("统计评论列表失败", log.Any("filter", filter), log.E(err))
return resp, err
}
resp.TotalCount = total
if total == 0 {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if skip >= total {
return resp, nil
}
opts := options.Find().
SetProjection(commentListProjection).
SetSkip(skip).
SetLimit(pageSize).
SetSort(commentListSort(sortOrder))
list, err := commentmod.FindManyComment(ctx, filter, opts)
if err != nil {
log.Warn("查询评论列表失败", log.Any("filter", filter), log.E(err))
return resp, err
}
parentIds := commentIdsFromList(list)
children, err := findChildComments(ctx, parentIds)
if err != nil {
return resp, err
}
allComments := make(commentmod.CommentModelList, 0, len(list)+len(children))
allComments = append(allComments, list...)
allComments = append(allComments, children...)
likedCommentIds, err := findLikedCommentIdMap(ctx, userId, allComments)
if err != nil {
return resp, err
}
childrenByParent := make(map[int64][]proto.CommentInfo)
for _, item := range children {
if item == nil {
continue
}
_, liked := likedCommentIds[item.Id]
childrenByParent[item.ParentsId] = append(childrenByParent[item.ParentsId], buildCommentInfo(ctx, item, liked))
}
resp.List = make([]proto.CommentInfo, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
_, liked := likedCommentIds[item.Id]
info := buildCommentInfo(ctx, item, liked)
info.Children = childrenByParent[item.Id]
resp.List = append(resp.List, info)
}
return resp, nil
}
func commentListSort(sortOrder int64) bson.D {
switch sortOrder {
case commentSortHot:
return bson.D{
{Key: commentmod.FN.Comment.CountLike, Value: -1},
{Key: commentmod.FN.Comment.Id, Value: -1},
}
case commentSortNewest:
fallthrough
default:
return bson.D{
{Key: commentmod.FN.Comment.CreatedAt, Value: -1},
{Key: commentmod.FN.Comment.Id, Value: -1},
}
}
}
func commentIdsFromList(list commentmod.CommentModelList) []int64 {
commentIds := make([]int64, 0, len(list))
for _, item := range list {
if item != nil {
commentIds = append(commentIds, item.Id)
}
}
return commentIds
}
func findChildComments(ctx context.Context, parentIds []int64) (commentmod.CommentModelList, *srvdef.SrvErr) {
if len(parentIds) == 0 {
return make(commentmod.CommentModelList, 0), nil
}
filter := bson.M{
commentmod.FN.Comment.ParentsId: bson.M{"$in": parentIds},
commentmod.FN.Comment.Level: commentLevelSecond,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
}
opts := options.Find().
SetProjection(commentListProjection).
SetSort(bson.D{
{Key: commentmod.FN.Comment.ParentsId, Value: 1},
{Key: commentmod.FN.Comment.CreatedAt, Value: 1},
{Key: commentmod.FN.Comment.Id, Value: 1},
})
children, err := commentmod.FindManyComment(ctx, filter, opts)
if err != nil {
log.Warn("查询二级评论列表失败", log.Any("filter", filter), log.E(err))
return nil, err
}
return children, nil
}
func findLikedCommentIdMap(ctx context.Context, userId int64, list commentmod.CommentModelList) (map[int64]struct{}, *srvdef.SrvErr) {
likedMap := make(map[int64]struct{})
if userId <= 0 || len(list) == 0 {
return likedMap, nil
}
commentIds := commentIdsFromList(list)
if len(commentIds) == 0 {
return likedMap, nil
}
likes, err := commentlikemod.FindManyCommentLike(ctx, bson.M{
commentlikemod.FN.CommentLike.UserId: userId,
commentlikemod.FN.CommentLike.CommentId: bson.M{"$in": commentIds},
}, options.Find().SetProjection(bson.D{
{Key: commentlikemod.FN.CommentLike.CommentId, Value: 1},
}))
if err != nil {
log.Error("批量查询评论点赞状态失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
for _, item := range likes {
if item != nil {
likedMap[item.CommentId] = struct{}{}
}
}
return likedMap, nil
}
func buildCommentInfo(ctx context.Context, item *commentmod.CommentModel, liked bool) proto.CommentInfo {
return proto.CommentInfo{
Id: item.Id,
ObjectId: item.ObjectId,
ObjectType: item.ObjectType,
UserId: item.UserId,
UserName: item.UserName,
UserAvatar: withCommentAvatarDomain(ctx, item.UserAvatar),
Text: item.Text,
Level: item.Level,
ParentsId: item.ParentsId,
ReplyCommentId: item.ReplyCommentId,
ReplyUserName: item.ReplyUserName,
CommentCount: item.CommentCount,
CountLike: item.CountLike,
IsLike: operateStatusByFlag(liked),
CreatedAt: item.CreatedAt,
Children: make([]proto.CommentInfo, 0),
}
}
func withCommentAvatarDomain(ctx context.Context, path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx))
if imgCdn == "" {
return path
}
return netutil.MergeURL(imgCdn, path)
}
func findLikeTargetComment(ctx context.Context, commentId int64) (*commentmod.CommentModel, *srvdef.SrvErr) {
comment, err := commentmod.FindOneComment(ctx, bson.M{
commentmod.FN.Comment.Id: commentId,
commentmod.FN.Comment.ReviewStatus: commentReviewPass,
commentmod.FN.Comment.ObjectType: bson.M{
"$in": []int64{ObjectTypePlaylet, ObjectTypePlayletChapter, ObjectTypeVideo},
},
}, options.FindOne().SetProjection(bson.D{
{Key: commentmod.FN.Comment.Id, Value: 1},
{Key: commentmod.FN.Comment.ObjectId, Value: 1},
{Key: commentmod.FN.Comment.ObjectType, Value: 1},
{Key: commentmod.FN.Comment.CountLike, Value: 1},
}))
if err != nil {
log.Error("查询评论点赞目标失败", log.Any("评论ID", commentId), log.E(err))
return nil, srvdef.New(srvdef.ErrParamError, "评论不存在", "")
}
return comment, nil
}
func incrementCommentLikeCount(ctx context.Context, commentId int64, delta int64) *srvdef.SrvErr {
filter := bson.M{
commentmod.FN.Comment.Id: commentId,
}
if delta < 0 {
filter[commentmod.FN.Comment.CountLike] = bson.M{"$gt": 0}
}
err := commentmod.UpdateOneComment(ctx, filter, bson.M{
"$inc": bson.M{
commentmod.FN.Comment.CountLike: delta,
},
})
if err != nil {
if delta < 0 && err.Code == srvdef.ErrNoData {
log.Warn("评论点赞数已为0,跳过扣减", log.Any("评论ID", commentId))
return nil
}
log.Error("更新评论点赞数失败", log.Any("评论ID", commentId), log.Any("增量", delta), log.E(err))
return err
}
return nil
}
func rollbackCommentLike(ctx context.Context, userId int64, comment *commentmod.CommentModel, enabled bool) {
filter := bson.M{
commentlikemod.FN.CommentLike.UserId: userId,
commentlikemod.FN.CommentLike.CommentId: comment.Id,
}
if enabled {
if _, err := commentlikemod.DeleteOneCommentLike(ctx, filter); err != nil {
log.Error("回滚评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", comment.Id), log.E(err))
}
return
}
if _, err := commentlikemod.InsertOneCommentLike(ctx, &commentlikemod.CommentLike{
UserId: userId,
CommentId: comment.Id,
ObjectId: comment.ObjectId,
ObjectType: comment.ObjectType,
}); err != nil {
log.Error("恢复评论点赞关系失败", log.Any("用户ID", userId), log.Any("评论ID", comment.Id), log.E(err))
}
}
func normalizeCommentPage(pageNum, pageSize int64) (int64, int64) {
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = commentDefaultPageSize
}
if pageSize > commentMaxPageSize {
pageSize = commentMaxPageSize
}
return pageNum, pageSize
}
func isPlayletCommentType(objectType int64) bool {
return objectType == ObjectTypePlaylet || objectType == ObjectTypePlayletChapter
}
func isSupportedCommentType(objectType int64) bool {
return isPlayletCommentType(objectType) || objectType == ObjectTypeVideo
}
func applyLocalCounter(current int64, enabled bool, changed bool) int64 {
if !changed {
return current
}
if enabled {
return current + 1
}
if current <= 0 {
return 0
}
return current - 1
}
func operateStatusByFlag(enabled bool) int64 {
if enabled {
return operateStatusYes
}
return operateStatusNo
}
+97
View File
@@ -0,0 +1,97 @@
package configser
import (
"strconv"
"videoweb/app/api/proto"
"videoweb/app/appg"
"videoweb/pub/adcenter"
"videoweb/pub/common/log"
"videoweb/pub/common/netutil"
"videoweb/pub/storage/proxy"
)
// UpdateAdvitiseDataFromJt 从广告中心拉取福利页广告
func UpdateAdvitiseDataFromJt() {
config := GetConfigInfo()
var resp proto.AdvitiseData
jtAds, err := adcenter.JtAdvertiseThreeServer(appg.Conf.AdCenter.ApiDomain, appg.Conf.AdCenter.MerchantCode, appg.Conf.AdCenter.AppCode, appg.Conf.AdCenter.AesKey)
if err != nil {
log.Error("JtAdvertiseThreeServer error occur", log.E(err))
return
}
adMap := make(map[string]adcenter.AdCacheInfo)
for _, loc := range jtAds {
pos, err := strconv.Atoi(loc.AdvertiseLocationCode)
if err != nil {
log.Error("广告位置代码错误,必须为数字", log.Any("code", loc.AdvertiseLocationCode))
continue
}
//if pos < 100001 {
// continue
//}
ads := make([]proxy.AdvitiseInfo, 0)
for _, ad := range loc.AdDetailInfoList {
//extra := ad.GetExtraData()
ads = append(ads, proxy.AdvitiseInfo{
Id: ad.AdvertiseCode,
Location: int64(pos),
Title: ad.AdvertiseName,
Images: netutil.MergeURL(config.ImgCdn, ad.GetCoverLsj()),
Link: ad.GetRealLink(),
})
adMap[ad.AdvertiseCode] = adcenter.AdCacheInfo{
AdvertiseCode: ad.AdvertiseCode,
CustomerCode: ad.CustomerCode,
AdvertiseName: ad.AdvertiseName,
AdvertiseUrl: ad.AdvertiseUrl,
AdvertiseIcon: ad.AdvertiseIcon,
AdvertiseType: ad.AdvertiseType,
AdMode: ad.AdMode,
AppCode: ad.AppCode,
LocationType: ad.LocationType,
AdvertiseDesc: ad.AdvertiseDesc,
PageType: ad.PageType,
StartTimeStamp: ad.StartTimeStamp,
EndTimeStamp: ad.EndTimeStamp,
Sort: ad.Sort,
AdExtData: ad.AdExtData,
DeptCode: loc.DeptCode,
MerchantCode: loc.MerchantCode,
AdvertiseLocationCode: loc.AdvertiseLocationCode,
AdvertiseLocationName: loc.AdvertiseLocationName,
DisplayMode: loc.DisplayMode,
AdvertiseHeight: loc.AdvertiseHeight,
AdvertiseWidth: loc.AdvertiseWidth,
MaterialType: loc.MaterialType,
AdLimit: loc.AdLimit,
}
}
switch pos {
case 1: //弹窗
resp.DialogList = ads
case 2: // 应用
resp.NineGridList = ads
case 3: // 横幅
resp.DetailbannerList = ads
case 12:
resp.AList = ads
case 13:
resp.BList = ads
}
}
CacheMgr.Lock.Lock()
defer CacheMgr.Lock.Unlock()
CacheMgr.Advitise = resp
CacheMgr.AdvitiseMap = adMap
}
func GetAdvertiseLocation(adverId string) (res adcenter.AdCacheInfo) {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
if ad, ok := CacheMgr.AdvitiseMap[adverId]; ok {
res = ad
}
return
}
+578
View File
@@ -0,0 +1,578 @@
package configser
import (
"context"
"regexp"
"strconv"
"strings"
"sync"
"videoweb/app/api/proto"
"videoweb/app/service/translationser"
"videoweb/pub/adcenter"
"videoweb/pub/common/log"
"videoweb/pub/common/netutil"
"videoweb/pub/common/srvdef"
"videoweb/pub/dataReport"
"videoweb/pub/models/announcementmod"
"videoweb/pub/models/cfg"
"videoweb/pub/models/ipblackmod"
"videoweb/pub/models/richtextcontentmod"
"videoweb/pub/models/videotagmod"
"github.com/robfig/cron/v3"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
var (
CacheMgr CacheInfo //
skdCron *cron.Cron
)
// richTextImgSrcRe 匹配富文本HTML里 img 标签的 src 图片地址,兼容单引号、双引号和JSON转义后的双引号。
var richTextImgSrcRe = regexp.MustCompile(`(?i)(<img\b[^>]*?\s*)src\s*=\s*(\\?"([^"\\]*)\\?"|'([^']*)')`)
type CacheInfo struct {
Lock sync.RWMutex // 读写锁
Config proto.ConfigInfo // 系统配置缓存
Advitise proto.AdvitiseData
AdvitiseMap map[string]adcenter.AdCacheInfo
TagCategoryList []proto.VideoTagCategoryData
TotalHistory int64
IpBlackMaps map[string]struct{}
TagMaps map[int64]proto.TagsInfoData
NewAddressInfo proto.NewAddressInfo
AppRichTexts []proto.AppRichTextItemResp
Announcements []*proto.AnnouncementItem
}
type appRichTextCacheItem struct {
Id int64 `bson:"_id,omitempty"`
Title string `bson:"title"`
Content string `bson:"content"`
Desc string `bson:"desc"`
}
func init() {
skdCron = cron.New(cron.WithSeconds()) // 秒级定时任务
CacheMgr.Config = proto.ConfigInfo{}
CacheMgr.IpBlackMaps = make(map[string]struct{})
}
func Start() {
ctx := context.Background()
dataReport.StartSDKConfigCron()
UpdateConfigInfo(ctx)
UpdateAdvitiseDataFromJt()
updateIpBlack()
updateTagsCache()
updateAppRichTexts()
updateAnnouncements()
_, _ = skdCron.AddFunc("0 */5 * * * ?", func() {
UpdateConfigInfo(ctx)
UpdateAdvitiseDataFromJt()
updateIpBlack()
updateTagsCache()
updateAppRichTexts()
})
_, _ = skdCron.AddFunc("0 */10 * * * ?", func() {
updateAnnouncements()
})
skdCron.Start()
}
func updateTagsCache() {
filter := bson.M{videotagmod.FN.VideoTagInfo.Status: true}
opts := &options.FindOptions{}
opts.SetLimit(5000)
opts.SetSort(bson.M{videotagmod.FN.VideoTagInfo.Sort: -1})
list, err := videotagmod.FindManyVideoTagInfos(context.Background(), filter, opts)
if err != nil {
log.Warn("updateTaggCache.FFindManyVideoTagInfos", log.Any("err", err))
return
}
tempMaps := make(map[int64]proto.TagsInfoData)
for _, v := range list {
tempMaps[v.Id] = proto.TagsInfoData{
Id: v.Id,
Name: v.Title,
Slug: v.Slug,
SeoInfo: v.SeoInfo,
}
}
CacheMgr.Lock.Lock()
defer CacheMgr.Lock.Unlock()
CacheMgr.TagMaps = tempMaps
}
func UpdateConfigInfo(ctx context.Context) {
config := proto.ConfigInfo{}
newAddressInfo := proto.NewAddressInfo{}
filter := bson.M{cfg.FN.SysCfg.Id: 1}
sysCfg, _ := cfg.FindOneSysCfg(context.Background(), filter)
if sysCfg == nil {
return
}
config.NewUrlAddr = sysCfg.NewUrlAddr
config.NewUrlAddr1 = sysCfg.NewUrlAddr1
config.NewUrlAddr2 = sysCfg.NewUrlAddr2
config.ForeverUrlAddr = sysCfg.ForeverUrlAddr
config.GmailAddr = sysCfg.GmailAddr
config.AppAddr = sysCfg.AppAddr
config.OfficialAppAddr = sysCfg.OfficialAppAddr
config.WeiChart = sysCfg.WeiChart
config.QQ = sysCfg.QQ
config.TgGroup = sysCfg.TgGroup
config.PotaoGroup = sysCfg.PotaoGroup
config.GitAddr = sysCfg.GitAddr
config.TwitterAddr = sysCfg.TwitterAddr
config.Telegram = sysCfg.Telegram
config.TelegramAdd = sysCfg.TelegramAdd
config.TranslationAddr = sysCfg.TranslationAddr
config.ImgCdn = sysCfg.ImgAppCdn
config.TsCdn = sysCfg.MediaAppCdn
config.ImgAppUsCdn = sysCfg.ImgAppUsCdn
config.MediaAppUsCdn = sysCfg.MediaAppUsCdn
config.PdfAddr = sysCfg.PdfAddr
config.WebTitle = sysCfg.WebTitle
config.WebKeyworld = sysCfg.WebKeyworld
config.WebDescription = sysCfg.WebDescription
config.WebSiteName = sysCfg.WebSiteName
config.WebAddr = sysCfg.WebAddr
config.DHNewUrlAddr1 = sysCfg.DHNewUrlAddr1
config.DHNewUrlAddr2 = sysCfg.DHNewUrlAddr2
config.DHNewUrlAddr3 = sysCfg.DHNewUrlAddr3
config.UseIngAddress = sysCfg.UseAddress
config.FreeVPN = sysCfg.FreeVPN
config.AdvitiesTopicNum = sysCfg.AdvitiesTopicNum
config.DHKeyworld = sysCfg.DHKeyworld
config.DHDescription = sysCfg.DHDescription
config.DHSiteName = sysCfg.DHSiteName
config.DHTitle = sysCfg.DHTitle
config.ActressBackGround = netutil.MergeURL(sysCfg.ImgAppCdn, sysCfg.ActressBackGround)
config.ReportSDKConfig = dataReport.GetSDKDomainConfig()
config.WatermarkImage = sysCfg.WatermarkImage
config.CustomerServiceHost = sysCfg.CustomerServiceHost
config.ChannelCode = sysCfg.ChannelCode
config.EnableH265 = sysCfg.EnableH265
cfg.CacheAppKey(sysCfg.AppKey)
// NewAddressInfo 来源于系统配置 sys_cfg,用于只返回前端需要展示的最新地址信息。
newAddressInfo.NewUrlAddr = sysCfg.NewUrlAddr
newAddressInfo.NewUrlAddr1 = sysCfg.NewUrlAddr1
newAddressInfo.NewUrlAddr2 = sysCfg.NewUrlAddr2
newAddressInfo.ForeverUrlAddr = sysCfg.ForeverUrlAddr
newAddressInfo.GmailAddr = sysCfg.GmailAddr
newAddressInfo.TgGroup = sysCfg.TgGroup
newAddressInfo.Telegram = sysCfg.Telegram
newAddressInfo.CustomerServiceHost = sysCfg.CustomerServiceHost
newAddressInfo.TwitterAddr = sysCfg.TwitterAddr
newAddressInfo.PdfAddr = sysCfg.PdfAddr
newAddressInfo.AppAddr = sysCfg.AppAddr
newAddressInfo.OfficialAppAddr = sysCfg.OfficialAppAddr
CacheMgr.Lock.Lock()
defer CacheMgr.Lock.Unlock()
CacheMgr.Config = config
CacheMgr.NewAddressInfo = newAddressInfo
}
func GetConfigInfo() proto.ConfigInfo {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
return CacheMgr.Config
}
func GetAdvitise() proto.AdvitiseData {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
return CacheMgr.Advitise
}
func GetNewAddress() proto.NewAddressInfo {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
return CacheMgr.NewAddressInfo
}
// GetFooterInfo 查询APP底部信息
func GetFooterInfo() proto.FooterInfo {
// 底部富文本和系统配置都来自缓存,接口请求不直接访问Mongo。
config := GetConfigInfo()
return proto.FooterInfo{
Brand: buildFooterBrand(config),
NavList: buildFooterNavList(),
ContactList: buildFooterContactList(config),
}
}
func getFooterRichTextMap() map[string]proto.AppRichTextItemResp {
data := getCachedAppRichTexts()
if len(data) == 0 {
updateAppRichTexts()
data = getCachedAppRichTexts()
}
result := make(map[string]proto.AppRichTextItemResp, len(data))
for _, item := range data {
title := strings.TrimSpace(item.Title)
if title == "" {
continue
}
result[title] = item
}
return result
}
func buildFooterBrand(config proto.ConfigInfo) proto.FooterBrand {
return proto.FooterBrand{
Title: config.WebSiteName,
Content: config.WebDescription,
}
}
func buildFooterNavList() []proto.TranslationCategory {
bottom := translationser.GetTranslationBottomList()
return append([]proto.TranslationCategory{}, bottom.CategoryList...)
}
func buildFooterContactList(config proto.ConfigInfo) []proto.FooterContact {
// 联系方式名称按页面截图固定返回,右侧只包含 E-mail、商务合作、官方群 三项。
return []proto.FooterContact{
{Name: "E-mail", Value: config.GmailAddr},
{Name: "商务合作:立即联系", Value: config.Telegram},
{Name: "官方群:立即加入", Value: firstNotEmpty(config.TgGroup, config.TelegramAdd)},
}
}
func firstNotEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
// GetAppRichTextList 查询APP富文本内容列表
func GetAppRichTextList(rawIds string, rawTitles string) proto.AppRichTextListResp {
ids := parseInt64Set(rawIds)
titles := parseStringSet(rawTitles)
data := getCachedAppRichTexts()
if len(data) == 0 {
updateAppRichTexts()
data = getCachedAppRichTexts()
}
return proto.AppRichTextListResp{List: filterAppRichTexts(data, ids, titles)}
}
func getCachedAppRichTexts() []proto.AppRichTextItemResp {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
return append([]proto.AppRichTextItemResp{}, CacheMgr.AppRichTexts...)
}
func filterAppRichTexts(data []proto.AppRichTextItemResp, ids map[int64]struct{}, titles map[string]struct{}) []proto.AppRichTextItemResp {
list := make([]proto.AppRichTextItemResp, 0, len(data))
for _, item := range data {
if len(ids) > 0 {
if _, ok := ids[item.Id]; !ok {
continue
}
}
if len(titles) > 0 {
if _, ok := titles[item.Title]; !ok {
continue
}
}
list = append(list, item)
}
return list
}
func parseInt64Set(raw string) map[int64]struct{} {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
resp := make(map[int64]struct{})
for _, item := range strings.Split(raw, ",") {
id, err := strconv.ParseInt(strings.TrimSpace(item), 10, 64)
if err != nil || id <= 0 {
continue
}
resp[id] = struct{}{}
}
return resp
}
func parseStringSet(raw string) map[string]struct{} {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
resp := make(map[string]struct{})
for _, item := range strings.Split(raw, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
resp[item] = struct{}{}
}
return resp
}
// GetSiteSeoInfo 返回全站SEO配置,供统一渲染 title、meta keywords、description 和 canonical。
func GetSiteSeoInfo() proto.SeoInfo {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
webAddr := normalizeWebAddr(CacheMgr.Config.WebAddr)
return proto.SeoInfo{
Title: CacheMgr.Config.WebTitle,
Keywords: append([]string{}, CacheMgr.Config.WebKeyworld...),
Description: CacheMgr.Config.WebDescription,
WebSiteName: CacheMgr.Config.WebSiteName,
CanonicalURL: webAddr,
}
}
// GetNavSeoInfo 返回导航页SEO配置
func GetNavSeoInfo() proto.SeoNavInfo {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
return proto.SeoNavInfo{
DHTitle: CacheMgr.Config.DHTitle,
DHKeyworld: append([]string{}, CacheMgr.Config.DHKeyworld...),
DHDescription: CacheMgr.Config.DHDescription,
DHSiteName: CacheMgr.Config.DHSiteName,
}
}
// GetNavPageList 返回导航页线路集合。
func GetNavPageList() []proto.NavPageItem {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
list := make([]proto.NavPageItem, 0, 3)
for _, item := range []proto.NavPageItem{
{Line: 1, Url: strings.TrimSpace(CacheMgr.Config.DHNewUrlAddr1)},
{Line: 2, Url: strings.TrimSpace(CacheMgr.Config.DHNewUrlAddr2)},
{Line: 3, Url: strings.TrimSpace(CacheMgr.Config.DHNewUrlAddr3)},
} {
if item.Url == "" {
continue
}
list = append(list, item)
}
return list
}
func normalizeWebAddr(webAddr string) string {
webAddr = strings.TrimSpace(webAddr)
webAddr = strings.TrimRight(webAddr, "/")
if webAddr == "" {
return ""
}
if !strings.HasPrefix(webAddr, "http://") && !strings.HasPrefix(webAddr, "https://") {
return "https://" + webAddr
}
return webAddr
}
func GetRandCDN() string {
config := GetConfigInfo()
return config.TsCdn
}
func updateIpBlack() {
ctx := context.Background()
list, err := ipblackmod.FindManyIPBlackModel(ctx, bson.M{})
if err != nil {
log.Error("updateIpBlack FindManyIPBlackModel error", log.E(err))
return
}
tempMaps := make(map[string]struct{})
for _, v := range list {
tempMaps[v.Ip] = struct{}{}
}
CacheMgr.Lock.Lock()
defer CacheMgr.Lock.Unlock()
CacheMgr.IpBlackMaps = tempMaps
}
func updateAppRichTexts() {
resp, err := queryAppRichTexts(context.Background())
if err != nil {
log.Warn("查询APP富文本内容失败", log.E(err))
return
}
CacheMgr.Lock.Lock()
defer CacheMgr.Lock.Unlock()
CacheMgr.AppRichTexts = resp
}
// queryAppRichTexts 查询启用状态的APP富文本内容,统一供定时缓存和接口兜底加载使用。
func queryAppRichTexts(ctx context.Context) ([]proto.AppRichTextItemResp, *srvdef.SrvErr) {
filter := bson.M{
richtextcontentmod.FN.RichTextContentModel.Status: true,
}
opts := options.Find().
SetProjection(bson.M{
richtextcontentmod.FN.RichTextContentModel.Id: 1,
richtextcontentmod.FN.RichTextContentModel.Title: 1,
richtextcontentmod.FN.RichTextContentModel.Content: 1,
richtextcontentmod.FN.RichTextContentModel.Desc: 1,
}).
SetSort(bson.D{
{Key: richtextcontentmod.FN.RichTextContentModel.Sort, Value: -1},
{Key: richtextcontentmod.FN.RichTextContentModel.UpdatedAt, Value: -1},
}).
SetLimit(100)
list := make([]appRichTextCacheItem, 0)
err := richtextcontentmod.FindManyRichTextContentModelModelWithBind(ctx, &list, filter, opts)
if err != nil {
log.Warn("查询APP富文本内容失败", log.Any("filter", filter), log.E(err))
return nil, err
}
imgCdn := GetConfigInfo().ImgCdn
resp := make([]proto.AppRichTextItemResp, 0, len(list))
for _, item := range list {
resp = append(resp, proto.AppRichTextItemResp{
Id: item.Id,
Title: item.Title,
Content: applyRichTextImgSrcCdn(imgCdn, item.Content),
Desc: item.Desc,
})
}
return resp, nil
}
// applyRichTextImgSrcCdn 给富文本 content 中 img 标签的 src 相对图片地址拼接图片CDN
func applyRichTextImgSrcCdn(imgCdn string, content string) string {
if imgCdn == "" || content == "" {
return content
}
return richTextImgSrcRe.ReplaceAllStringFunc(content, func(match string) string {
parts := richTextImgSrcRe.FindStringSubmatch(match)
if len(parts) < 5 {
return match
}
prefix := parts[1]
quote := `"`
src := parts[3]
if src == "" {
quote = `'`
src = parts[4]
} else if strings.HasPrefix(parts[2], `\"`) {
quote = `\"`
}
src = strings.TrimSpace(src)
if src == "" || isAbsoluteRichTextSrc(src) {
return match
}
return prefix + "src=" + quote + netutil.MergeURL(imgCdn, src) + quote
})
}
// isAbsoluteRichTextSrc 判断图片地址是否已经是完整地址或特殊协议,避免重复拼接CDN。
func isAbsoluteRichTextSrc(src string) bool {
lowerSrc := strings.ToLower(src)
return strings.HasPrefix(lowerSrc, "http://") ||
strings.HasPrefix(lowerSrc, "https://") ||
strings.HasPrefix(lowerSrc, "data:") ||
strings.HasPrefix(lowerSrc, "blob:") ||
strings.HasPrefix(src, "//")
}
func updateAnnouncements() {
list, err := queryAnnouncements(context.Background())
if err != nil {
log.Warn("查询公告失败", log.E(err))
return
}
CacheMgr.Lock.Lock()
defer CacheMgr.Lock.Unlock()
CacheMgr.Announcements = list
}
func queryAnnouncements(ctx context.Context) ([]*proto.AnnouncementItem, *srvdef.SrvErr) {
filter := bson.M{announcementmod.FN.Announcement.Status: true}
opts := options.Find().
SetProjection(bson.M{
announcementmod.FN.Announcement.Id: 1,
announcementmod.FN.Announcement.Title: 1,
announcementmod.FN.Announcement.Content: 1,
}).
SetSort(bson.D{
{Key: announcementmod.FN.Announcement.Sort, Value: -1},
{Key: announcementmod.FN.Announcement.UpdatedAt, Value: -1},
}).
SetLimit(50)
list, err := announcementmod.FindManyAnnouncement(ctx, filter, opts)
if err != nil {
log.Warn("查询公告失败", log.Any("filter", filter), log.E(err))
return nil, err
}
resp := make([]*proto.AnnouncementItem, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
resp = append(resp, &proto.AnnouncementItem{
Id: item.Id,
Title: item.Title,
Content: item.Content,
})
}
return resp, nil
}
// GetAnnouncements 返回启用中的公告缓存,每10分钟刷新。
func GetAnnouncements() []*proto.AnnouncementItem {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
if len(CacheMgr.Announcements) == 0 {
return make([]*proto.AnnouncementItem, 0)
}
resp := make([]*proto.AnnouncementItem, len(CacheMgr.Announcements))
copy(resp, CacheMgr.Announcements)
return resp
}
func CheckIpBlack(ip string) bool {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
if _, ok := CacheMgr.IpBlackMaps[ip]; ok {
return true
}
return false
}
func GetVideoTagCategoryDataList() []proto.VideoTagCategoryData {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
rsl := make([]proto.VideoTagCategoryData, 0)
for _, v := range CacheMgr.TagCategoryList {
rsl = append(rsl, v)
}
return rsl
}
func Stop() {
dataReport.StopSDKConfigCron()
skdCron.Stop()
}
func GetTag(tagId int64) *proto.TagsInfoData {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
tag, ok := CacheMgr.TagMaps[tagId]
if !ok {
return nil
}
return &tag
}
+47
View File
@@ -0,0 +1,47 @@
package configser
import (
"context"
"strings"
"videoweb/app/appg"
"videoweb/pub/common/ipaddr"
"github.com/gin-gonic/gin"
)
// ClientIPFromCtx 从 gin 请求上下文解析客户端 IP。
func ClientIPFromCtx(ctx context.Context) string {
c, ok := ctx.(*gin.Context)
if !ok || c == nil {
return ""
}
return ipaddr.ResolveClientIP(c)
}
// IsOverseasIP 与 m3u8 TS 域名判断一致:按 v4/v6 库识别是否海外。
func IsOverseasIP(ip string) bool {
ip = strings.TrimSpace(ip)
if ip == "" {
return false
}
detector := appg.DetectorV6
if ipaddr.IsIPv4(ip) {
detector = appg.DetectorV4
}
if detector == nil {
return false
}
ok, _ := detector.IsOverseasIP(ip)
return ok
}
// GetAppImageCdn 海外 IP 且配置了 imgAppUsCdn 时用海外图床,否则用 imgAppCdn。
func GetAppImageCdn(ip string) string {
cfg := GetConfigInfo()
if IsOverseasIP(ip) {
if us := strings.TrimSpace(cfg.ImgAppUsCdn); us != "" {
return us
}
}
return strings.TrimSpace(cfg.ImgCdn)
}
+22
View File
@@ -0,0 +1,22 @@
package configser
import "videoweb/app/api/proto"
// ApplyVideoH265Policy 根据 sys_cfg.enableH265 决定是否向客户端暴露 H265 地址。
func ApplyVideoH265Policy(data *proto.VideoInfoData) {
if data == nil || GetConfigInfo().EnableH265 {
return
}
proto.StripH265Url(data)
}
// ApplyVideoH265PolicyList 批量处理视频列表。
func ApplyVideoH265PolicyList(list []proto.VideoInfoData) []proto.VideoInfoData {
if GetConfigInfo().EnableH265 {
return list
}
for i := range list {
proto.StripH265Url(&list[i])
}
return list
}
+93
View File
@@ -0,0 +1,93 @@
package contactusser
import (
"context"
"net/mail"
"strings"
"time"
"unicode/utf8"
"videoweb/app/api/proto"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/contactusmod"
)
const (
contactUsMaxEmailLen = 100
contactUsMaxTitleLen = 100
contactUsMaxContentLen = 1000
contactUsMaxAttachmentNum = 9
contactUsSubmitMessage = "提交成功,我们会在24小时内回复。"
)
const (
ContactUsTypeOrder int32 = 1 // 订单问题
ContactUsTypeAccount int32 = 2 // 账号问题
ContactUsTypeFeature int32 = 3 // 功能问题
ContactUsTypeExp int32 = 4 // 体验问题
ContactUsTypeOther int32 = 5 // 其他
)
// AddContactUs 提交联系我们记录
func AddContactUs(ctx context.Context, userId int64, req *proto.ContactUsAddReq) (*proto.ContactUsAddResp, *srvdef.SrvErr) {
if req == nil || userId <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
email := strings.TrimSpace(req.Email)
if !isValidEmail(email) || utf8.RuneCountInString(email) > contactUsMaxEmailLen {
return nil, srvdef.New(srvdef.ErrParamError, "邮箱格式错误")
}
if !isValidContactUsType(req.Type) {
return nil, srvdef.New(srvdef.ErrParamError, "问题类型无效")
}
title := strings.TrimSpace(req.Title)
if title == "" || utf8.RuneCountInString(title) > contactUsMaxTitleLen {
return nil, srvdef.New(srvdef.ErrParamError, "标题不能为空且不能超过100字")
}
content := strings.TrimSpace(req.Content)
if content == "" || utf8.RuneCountInString(content) > contactUsMaxContentLen {
return nil, srvdef.New(srvdef.ErrParamError, "内容不能为空且不能超过1000字")
}
attachments := normalizeAttachments(req.Attachments)
if len(attachments) > contactUsMaxAttachmentNum {
return nil, srvdef.New(srvdef.ErrParamError, "附件不能超过9个")
}
now := time.Now()
doc := &contactusmod.ContactUs{
UserId: userId,
Email: email,
QuestionType: req.Type,
Title: title,
Content: content,
Attachments: attachments,
Status: 0,
CreatedAt: now,
UpdatedAt: now,
}
if err := contactusmod.InsertOneContactUs(ctx, doc); err != nil {
return nil, err
}
return &proto.ContactUsAddResp{Message: contactUsSubmitMessage}, nil
}
func isValidEmail(email string) bool {
if email == "" {
return false
}
_, err := mail.ParseAddress(email)
return err == nil
}
func isValidContactUsType(questionType int32) bool {
return questionType >= ContactUsTypeOrder && questionType <= ContactUsTypeOther
}
func normalizeAttachments(rawAttachments []string) []string {
attachments := make([]string, 0, len(rawAttachments))
for _, attachment := range rawAttachments {
attachment = strings.TrimSpace(attachment)
if attachment != "" {
attachments = append(attachments, attachment)
}
}
return attachments
}
+77
View File
@@ -0,0 +1,77 @@
package feedbackser
import (
"context"
"strings"
"time"
"unicode/utf8"
"videoweb/app/api/proto"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/feedbackmod"
)
const (
feedbackMaxContentLen = 500
feedbackMaxContactLen = 100
feedbackMaxScreenshotNum = 9
feedbackSubmitMessage = "反馈提交成功"
)
const ( //问题类型,1-功能建议、2-播放问题、3-账号问题、4-其他
FeedbackTypeFeature int32 = 1
FeedbackTypePlay int32 = 2
FeedbackTypeAccount int32 = 3
FeedbackTypeOther int32 = 4
)
// AddFeedback 提交用户意见反馈
func AddFeedback(ctx context.Context, userId int64, req *proto.FeedbackReq) (*proto.FeedbackResp, *srvdef.SrvErr) {
if req == nil || userId <= 0 {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
if !isValidFeedbackType(req.Type) {
return nil, srvdef.New(srvdef.ErrParamError, "问题类型无效")
}
content := strings.TrimSpace(req.Content)
if content == "" || utf8.RuneCountInString(content) > feedbackMaxContentLen {
return nil, srvdef.New(srvdef.ErrParamError, "描述内容不能为空且不能超过500字")
}
contact := strings.TrimSpace(req.Contact)
if utf8.RuneCountInString(contact) > feedbackMaxContactLen {
return nil, srvdef.New(srvdef.ErrParamError, "联系方式不能超过100字")
}
screenshots := normalizeScreenshots(req.Screenshots)
if len(screenshots) > feedbackMaxScreenshotNum {
return nil, srvdef.New(srvdef.ErrParamError, "问题截图不能超过9张")
}
now := time.Now()
doc := &feedbackmod.Feedback{
UserId: userId,
FeedbackType: req.Type,
Content: content,
Screenshots: screenshots,
Contact: contact,
Status: 0,
CreatedAt: now,
UpdatedAt: now,
}
if err := feedbackmod.InsertOneFeedback(ctx, doc); err != nil {
return nil, err
}
return &proto.FeedbackResp{Message: feedbackSubmitMessage}, nil
}
func isValidFeedbackType(feedbackType int32) bool {
return feedbackType >= FeedbackTypeFeature && feedbackType <= FeedbackTypeOther
}
func normalizeScreenshots(rawScreenshots []string) []string {
screenshots := make([]string, 0, len(rawScreenshots))
for _, screenshot := range rawScreenshots {
screenshot = strings.TrimSpace(screenshot)
if screenshot != "" {
screenshots = append(screenshots, screenshot)
}
}
return screenshots
}
+217
View File
@@ -0,0 +1,217 @@
package healthser
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"net/http"
"os"
"runtime"
"strings"
"time"
"videoweb/app/api/proto"
"videoweb/app/appg"
"videoweb/pub/common/log"
"videoweb/pub/common/netutil"
"videoweb/pub/common/srvdef"
"videoweb/pub/mongos"
"videoweb/pub/redis"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/mem"
"go.mongodb.org/mongo-driver/bson"
)
const (
timestampTolerance = 5 * time.Minute
bytesInGB = 1024 * 1024 * 1024
healthGroupID = "69840d508591cdac5a3defcb"
healthSecretKey = "5e47c292e775ebe8bb0d79057cd17778"
)
func HealthCheck(ctx context.Context, req *proto.HealthCheckRequest) (*proto.HealthCheckData, *srvdef.SrvErr) {
if req.GroupID != healthGroupID {
log.Warn("health check invalid groupId", log.Any("groupId", req.GroupID))
return nil, srvdef.New(srvdef.ErrParamError, "invalid groupId")
}
if !isTimestampValid(req.Timestamp) {
log.Warn("health check timestamp expired", log.Any("timestamp", req.Timestamp))
return nil, srvdef.New(srvdef.ErrParamError, "timestamp expired")
}
if !verifySign(req.GroupID, req.Timestamp, healthSecretKey, req.Sign) {
log.Warn("health check invalid sign", log.Any("groupId", req.GroupID))
return nil, srvdef.New(srvdef.ErrParamError, "invalid sign")
}
data := collectServerMetrics()
data.Timestamp = time.Now().Unix()
data.Mysql = disabledStatus("mysql not used")
data.Kafka = disabledStatus("kafka not used")
data.Redis = pingRedis()
data.Mongodb = pingMongo(ctx)
data.Elasticsearch = pingElasticsearch(ctx)
return data, nil
}
func verifySign(groupID string, ts int64, secret string, sign string) bool {
payload := fmt.Sprintf("%s:%d", groupID, ts)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected))
}
func isTimestampValid(ts int64) bool {
now := time.Now().Unix()
diff := now - ts
if diff < 0 {
diff = -diff
}
return diff <= int64(timestampTolerance.Seconds())
}
func disabledStatus(msg string) *proto.ComponentStatus {
return &proto.ComponentStatus{Status: "disabled", Msg: msg}
}
func okStatus() *proto.ComponentStatus {
return &proto.ComponentStatus{Status: "ok", Msg: "success"}
}
func pingRedis() *proto.ComponentStatus {
cli := redis.Client()
if cli == nil {
return &proto.ComponentStatus{Status: "err", Msg: "redis client not initialized"}
}
if err := cli.Ping().Err(); err != nil {
log.Warn("health check redis ping failed", log.E(err))
return &proto.ComponentStatus{Status: "err", Msg: err.Error()}
}
status := okStatus()
status.Master = appg.Conf.Redis.Addr
return status
}
func pingMongo(ctx context.Context) *proto.ComponentStatus {
if mongos.Shop == nil {
return &proto.ComponentStatus{Status: "err", Msg: "mongo client not initialized"}
}
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := mongos.Shop.Database().RunCommand(pingCtx, bson.D{{Key: "ping", Value: 1}}).Err(); err != nil {
log.Warn("health check mongo ping failed", log.E(err))
return &proto.ComponentStatus{Status: "err", Msg: err.Error()}
}
return okStatus()
}
func pingElasticsearch(ctx context.Context) *proto.ComponentStatus {
addr := strings.TrimSpace(appg.Conf.Es.Addr)
if addr == "" {
return disabledStatus("elasticsearch not configured")
}
url := strings.TrimSuffix(addr, "/") + "/_cluster/health"
reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
if err != nil {
log.Warn("health check es request build failed", log.E(err))
return &proto.ComponentStatus{Status: "err", Msg: err.Error()}
}
if appg.Conf.Es.User != "" || appg.Conf.Es.Pass != "" {
req.SetBasicAuth(appg.Conf.Es.User, appg.Conf.Es.Pass)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Warn("health check es request failed", log.E(err))
return &proto.ComponentStatus{Status: "err", Msg: err.Error()}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &proto.ComponentStatus{Status: "err", Msg: fmt.Sprintf("es status %d", resp.StatusCode)}
}
return okStatus()
}
func collectServerMetrics() *proto.HealthCheckData {
name, err := os.Hostname()
if err != nil || name == "" {
name = "app-server"
}
ip := netutil.GetLocalIP()
if ip == "" {
ip = "127.0.0.1"
}
cores := runtime.NumCPU()
usedCores := calcUsedCores(cores)
memMetrics := memoryMetrics()
diskMetrics := diskMetrics()
return &proto.HealthCheckData{
ServerName: name,
ServerIP: ip,
CPU: proto.HealthCPUMetrics{
Cores: cores,
Used: usedCores,
},
Memory: memMetrics,
Disk: diskMetrics,
}
}
func calcUsedCores(cores int) float64 {
usage, err := cpu.Percent(200*time.Millisecond, false)
if err != nil || len(usage) == 0 {
if err != nil {
log.Warn("health check cpu percent failed", log.E(err))
}
return 0
}
used := float64(cores) * usage[0] / 100
return math.Round(used*10) / 10
}
func memoryMetrics() proto.HealthMemoryMetrics {
vm, err := mem.VirtualMemory()
if err != nil {
log.Warn("health check memory stats failed", log.E(err))
return proto.HealthMemoryMetrics{}
}
return proto.HealthMemoryMetrics{
Total: bytesToGB(vm.Total),
Used: bytesToGB(vm.Used),
}
}
func diskMetrics() proto.HealthMemoryMetrics {
path := rootPath()
du, err := disk.Usage(path)
if err != nil {
log.Warn("health check disk stats failed", log.E(err))
return proto.HealthMemoryMetrics{}
}
return proto.HealthMemoryMetrics{
Total: bytesToGB(du.Total),
Used: bytesToGB(du.Used),
}
}
func bytesToGB(value uint64) int64 {
if value == 0 {
return 0
}
return int64(math.Round(float64(value) / bytesInGB))
}
func rootPath() string {
if runtime.GOOS == "windows" {
return "C:\\"
}
return "/"
}
+138
View File
@@ -0,0 +1,138 @@
package navigationser
import (
"context"
"strings"
"videoweb/app/api/proto"
"videoweb/app/service/configser"
"videoweb/pub/common/netutil"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/translationcategorymod"
"videoweb/pub/models/translationmod"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
headerNavigationCategoryLimit int64 = 30
headerNavigationInfoLimit int64 = 50
)
var (
headerNavigationCategoryProjection = bson.D{
{Key: translationcategorymod.FN.TranslationCategory.Id, Value: 1},
{Key: translationcategorymod.FN.TranslationCategory.Name, Value: 1},
{Key: translationcategorymod.FN.TranslationCategory.Icon, Value: 1},
{Key: translationcategorymod.FN.TranslationCategory.CategoryType, Value: 1},
{Key: translationcategorymod.FN.TranslationCategory.UrlValue, Value: 1},
{Key: translationcategorymod.FN.TranslationCategory.SeoInfo.Title, Value: 1},
{Key: translationcategorymod.FN.TranslationCategory.SeoInfo.Description, Value: 1},
{Key: translationcategorymod.FN.TranslationCategory.SeoInfo.Keyworld, Value: 1},
}
headerNavigationInfoProjection = bson.D{
{Key: translationmod.FN.TranslationInfo.Id, Value: 1},
{Key: translationmod.FN.TranslationInfo.Name, Value: 1},
{Key: translationmod.FN.TranslationInfo.TransCategoryId, Value: 1},
{Key: translationmod.FN.TranslationInfo.IsFriendAds, Value: 1},
{Key: translationmod.FN.TranslationInfo.Link, Value: 1},
}
)
// HeaderNavigation 查询头部导航配置
func HeaderNavigation(ctx context.Context) (proto.AppTranslationInfo, *srvdef.SrvErr) {
categoryList, err := translationcategorymod.FindManyTranslationCategorys(ctx, bson.M{
translationcategorymod.FN.TranslationCategory.Status: true,
translationcategorymod.FN.TranslationCategory.CategoryType: bson.M{
"$in": []pubdef.TranslationType{
pubdef.TranslationTypeUrl,
pubdef.TranslationTypeSub,
},
},
}, options.Find().
SetProjection(headerNavigationCategoryProjection).
SetSort(bson.D{
{Key: translationcategorymod.FN.TranslationCategory.Rank, Value: -1},
{Key: translationcategorymod.FN.TranslationCategory.Id, Value: -1},
}).
SetLimit(headerNavigationCategoryLimit))
if err != nil {
return proto.AppTranslationInfo{}, err
}
resp := proto.AppTranslationInfo{CategoryList: make([]proto.TranslationCategory, 0, len(categoryList))}
if len(categoryList) == 0 {
return resp, nil
}
categoryIds := make([]int64, 0, len(categoryList))
for _, item := range categoryList {
if item != nil && item.Id > 0 {
categoryIds = append(categoryIds, item.Id)
}
}
translationListByCategory, err := queryHeaderTranslationList(ctx, categoryIds)
if err != nil {
return proto.AppTranslationInfo{}, err
}
imgCdn := configser.GetConfigInfo().ImgCdn
for _, item := range categoryList {
if item == nil {
continue
}
resp.CategoryList = append(resp.CategoryList, proto.TranslationCategory{
ID: item.Id,
Name: item.Name,
Icon: netutil.MergeURL(imgCdn, item.Icon),
UrlValue: item.UrlValue,
CategoryType: item.CategoryType,
SeoInfo: item.SeoInfo,
TranslationList: translationListByCategory[item.Id],
})
}
return resp, nil
}
// queryHeaderTranslationList 批量查询头部导航二级配置并按分类ID分组
func queryHeaderTranslationList(ctx context.Context, categoryIds []int64) (map[int64][]proto.TranslationInfo, *srvdef.SrvErr) {
result := make(map[int64][]proto.TranslationInfo, len(categoryIds))
if len(categoryIds) == 0 {
return result, nil
}
list, err := translationmod.FindManyTranslationInfos(ctx, bson.M{
translationmod.FN.TranslationInfo.Status: true,
translationmod.FN.TranslationInfo.TransCategoryId: bson.M{"$in": categoryIds},
}, options.Find().
SetProjection(headerNavigationInfoProjection).
SetSort(bson.D{
{Key: translationmod.FN.TranslationInfo.Rank, Value: -1},
{Key: translationmod.FN.TranslationInfo.Id, Value: -1},
}).
SetLimit(headerNavigationInfoLimit))
if err != nil {
return nil, err
}
for _, item := range list {
if item == nil || item.IsFriendAds {
continue
}
info := proto.TranslationInfo{
ID: item.Id,
Name: item.Name,
TransCategoryId: item.TransCategoryId,
UrlValue: headerTranslationURL(item),
}
if info.UrlValue == "" {
continue
}
result[item.TransCategoryId] = append(result[item.TransCategoryId], info)
}
return result, nil
}
// headerTranslationURL 解析导航明细跳转地址
func headerTranslationURL(item *translationmod.TranslationInfo) string {
if item == nil {
return ""
}
return strings.TrimSpace(item.Link)
}
+499
View File
@@ -0,0 +1,499 @@
package playletcache
import (
"context"
"sort"
"strings"
"sync"
"videoweb/pub/common/log"
"videoweb/pub/models/hotsearchmod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playlettagmod"
"videoweb/pub/models/playletthememod"
"videoweb/pub/pubdef"
"github.com/robfig/cron/v3"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
var (
skdCron *cron.Cron
cache struct {
lock sync.RWMutex
themeByID map[int64]*ThemeItem
themeBySlug map[string]*ThemeItem
tagByID map[int64]*TagItem
tagBySlug map[string]*TagItem
hotSearch []HotSearchItem
guessLike []*playletmod.Playlet
}
)
// HotSearchItem 热搜词缓存项
type HotSearchItem struct {
Keyword string
Count int64
}
// ThemeItem 题材缓存项(含 SEO)
type ThemeItem struct {
Id int64
Name string
Slug string
Icon string
Rank int64
Status bool
IsRecommend bool
PlayletCount int64
SeoInfo pubdef.SeoValue
}
// TagItem 标签缓存项(含 SEO)
type TagItem struct {
Id int64
Name string
Slug string
Rank int64
Status bool
SeoInfo pubdef.SeoValue
}
// Start 启动题材/标签/热搜词/猜你喜欢缓存,每 10 分钟刷新一次。
func Start() {
skdCron = cron.New(cron.WithSeconds())
UpdateCache(context.Background())
_, _ = skdCron.AddFunc("0 */10 * * * ?", func() {
UpdateCache(context.Background())
})
skdCron.Start()
}
// UpdateCache 从 Mongo 全量刷新题材、标签、热搜词和猜你喜欢缓存。
func UpdateCache(ctx context.Context) {
updateThemeCache(ctx)
updateTagCache(ctx)
updateHotSearchCache(ctx)
updateGuessLikeCache(ctx)
}
func updateThemeCache(ctx context.Context) {
list, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{}, options.Find().
SetProjection(bson.D{
{Key: playletthememod.FN.PlayletTheme.Id, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Name, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Slug, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Icon, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Rank, Value: 1},
{Key: playletthememod.FN.PlayletTheme.Status, Value: 1},
{Key: playletthememod.FN.PlayletTheme.IsRecommend, Value: 1},
{Key: "seoInfo", Value: 1},
}).
SetSort(bson.D{
{Key: playletthememod.FN.PlayletTheme.Rank, Value: -1},
{Key: playletthememod.FN.PlayletTheme.Id, Value: -1},
}))
if err != nil {
log.Warn("playletcache.updateThemeCache failed", log.E(err))
return
}
countMap := countUpPlayletsByTheme(ctx)
temp := make(map[int64]*ThemeItem, len(list))
tempBySlug := make(map[string]*ThemeItem, len(list))
for _, item := range list {
if item == nil || item.Id <= 0 {
continue
}
cached := &ThemeItem{
Id: item.Id,
Name: item.Name,
Slug: item.Slug,
Icon: item.Icon,
Rank: item.Rank,
Status: item.Status,
IsRecommend: item.IsRecommend,
PlayletCount: countMap[item.Id],
SeoInfo: item.SeoInfo,
}
temp[item.Id] = cached
if slug := strings.TrimSpace(item.Slug); slug != "" {
tempBySlug[slug] = cached
}
}
cache.lock.Lock()
cache.themeByID = temp
cache.themeBySlug = tempBySlug
cache.lock.Unlock()
}
type themePlayletCount struct {
Id int64 `bson:"_id"`
Count int64 `bson:"count"`
}
// countUpPlayletsByTheme 按题材统计已上架短剧数量。
func countUpPlayletsByTheme(ctx context.Context) map[int64]int64 {
result := make(map[int64]int64)
list := make([]themePlayletCount, 0)
err := playletmod.AggregatePlaylet(ctx, &list, []bson.M{
{
"$match": bson.M{
playletmod.FN.Playlet.Status: playletmod.StatusUp,
},
},
{
"$unwind": "$" + playletmod.FN.Playlet.ThemeIds,
},
{
"$group": bson.M{
"_id": "$" + playletmod.FN.Playlet.ThemeIds,
"count": bson.M{"$sum": 1},
},
},
})
if err != nil {
log.Warn("playletcache.countUpPlayletsByTheme failed", log.E(err))
return result
}
for _, item := range list {
if item.Id <= 0 || item.Count <= 0 {
continue
}
result[item.Id] = item.Count
}
return result
}
func updateTagCache(ctx context.Context) {
list, err := playlettagmod.FindManyPlayletTag(ctx, bson.M{}, options.Find().
SetProjection(bson.D{
{Key: playlettagmod.FN.PlayletTag.Id, Value: 1},
{Key: playlettagmod.FN.PlayletTag.Name, Value: 1},
{Key: playlettagmod.FN.PlayletTag.Slug, Value: 1},
{Key: playlettagmod.FN.PlayletTag.Rank, Value: 1},
{Key: playlettagmod.FN.PlayletTag.Status, Value: 1},
{Key: "seoInfo", Value: 1},
}).
SetSort(bson.D{
{Key: playlettagmod.FN.PlayletTag.Rank, Value: -1},
{Key: playlettagmod.FN.PlayletTag.Id, Value: -1},
}))
if err != nil {
log.Warn("playletcache.updateTagCache failed", log.E(err))
return
}
temp := make(map[int64]*TagItem, len(list))
tempBySlug := make(map[string]*TagItem, len(list))
for _, item := range list {
if item == nil || item.Id <= 0 {
continue
}
cached := &TagItem{
Id: item.Id,
Name: item.Name,
Slug: item.Slug,
Rank: item.Rank,
Status: item.Status,
SeoInfo: item.SeoInfo,
}
temp[item.Id] = cached
if slug := strings.TrimSpace(item.Slug); slug != "" {
tempBySlug[slug] = cached
}
}
cache.lock.Lock()
cache.tagByID = temp
cache.tagBySlug = tempBySlug
cache.lock.Unlock()
}
func updateHotSearchCache(ctx context.Context) {
list, err := hotsearchmod.FindTopHotSearch(ctx, hotsearchmod.TopLimit)
if err != nil {
log.Warn("playletcache.updateHotSearchCache failed", log.E(err))
return
}
temp := make([]HotSearchItem, 0, len(list))
for _, item := range list {
if item == nil || strings.TrimSpace(item.Keyword) == "" {
continue
}
temp = append(temp, HotSearchItem{
Keyword: item.Keyword,
Count: item.Count,
})
}
cache.lock.Lock()
cache.hotSearch = temp
cache.lock.Unlock()
}
// ListHotSearch 返回缓存的前10个热搜词。
func ListHotSearch() []HotSearchItem {
cache.lock.RLock()
defer cache.lock.RUnlock()
if len(cache.hotSearch) == 0 {
return make([]HotSearchItem, 0)
}
result := make([]HotSearchItem, len(cache.hotSearch))
copy(result, cache.hotSearch)
return result
}
// GetTheme 按 ID 取题材缓存;不存在返回 nil。
func GetTheme(id int64) *ThemeItem {
if id <= 0 {
return nil
}
cache.lock.RLock()
defer cache.lock.RUnlock()
item := cache.themeByID[id]
if item == nil {
return nil
}
cp := *item
return &cp
}
// GetEnabledTheme 按 ID 取上架题材;未上架或不存在返回 nil。
func GetEnabledTheme(id int64) *ThemeItem {
item := GetTheme(id)
if item == nil || !item.Status {
return nil
}
return item
}
// GetThemeBySlug 按 slug 取题材缓存;不存在返回 nil。
func GetThemeBySlug(slug string) *ThemeItem {
slug = strings.TrimSpace(slug)
if slug == "" {
return nil
}
cache.lock.RLock()
defer cache.lock.RUnlock()
item := cache.themeBySlug[slug]
if item == nil {
return nil
}
cp := *item
return &cp
}
// GetTag 按 ID 取标签缓存;不存在返回 nil。
func GetTag(id int64) *TagItem {
if id <= 0 {
return nil
}
cache.lock.RLock()
defer cache.lock.RUnlock()
item := cache.tagByID[id]
if item == nil {
return nil
}
cp := *item
return &cp
}
// GetEnabledTag 按 ID 取上架标签;未上架或不存在返回 nil。
func GetEnabledTag(id int64) *TagItem {
item := GetTag(id)
if item == nil || !item.Status {
return nil
}
return item
}
// ListEnabledTags 返回已上架标签列表,按缓存排序。
func ListEnabledTags() []*TagItem {
cache.lock.RLock()
defer cache.lock.RUnlock()
result := make([]*TagItem, 0, len(cache.tagByID))
seen := make(map[int64]struct{}, len(cache.tagByID))
for _, item := range cache.tagBySlug {
if item == nil || !item.Status || item.Id <= 0 {
continue
}
if _, ok := seen[item.Id]; ok {
continue
}
cp := *item
result = append(result, &cp)
seen[item.Id] = struct{}{}
}
for _, item := range cache.tagByID {
if item == nil || !item.Status || item.Id <= 0 {
continue
}
if _, ok := seen[item.Id]; ok {
continue
}
cp := *item
result = append(result, &cp)
seen[item.Id] = struct{}{}
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].Rank == result[j].Rank {
return result[i].Id > result[j].Id
}
return result[i].Rank > result[j].Rank
})
return result
}
// GetTagBySlug 按 slug 取标签缓存;不存在返回 nil。
func GetTagBySlug(slug string) *TagItem {
slug = strings.TrimSpace(slug)
if slug == "" {
return nil
}
cache.lock.RLock()
defer cache.lock.RUnlock()
item := cache.tagBySlug[slug]
if item == nil {
return nil
}
cp := *item
return &cp
}
// ListEnabledThemes 返回全部上架题材,按 Rank、Id 倒序。
func ListEnabledThemes() []*ThemeItem {
cache.lock.RLock()
defer cache.lock.RUnlock()
result := make([]*ThemeItem, 0, len(cache.themeByID))
for _, item := range cache.themeByID {
if item == nil || !item.Status {
continue
}
cp := *item
result = append(result, &cp)
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].Rank != result[j].Rank {
return result[i].Rank > result[j].Rank
}
return result[i].Id > result[j].Id
})
return result
}
// ListEnabledThemesByPlayletCount 返回有上架短剧的启用题材,按绑定短剧数量倒序
func ListEnabledThemesByPlayletCount(limit int) []*ThemeItem {
cache.lock.RLock()
defer cache.lock.RUnlock()
result := make([]*ThemeItem, 0, len(cache.themeByID))
for _, item := range cache.themeByID {
if item == nil || !item.Status || item.PlayletCount <= 0 {
continue
}
cp := *item
result = append(result, &cp)
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].PlayletCount != result[j].PlayletCount {
return result[i].PlayletCount > result[j].PlayletCount
}
if result[i].Rank != result[j].Rank {
return result[i].Rank > result[j].Rank
}
return result[i].Id > result[j].Id
})
if limit > 0 && len(result) > limit {
return result[:limit]
}
return result
}
const homeRecommendThemeLimit = 4
// ListRecommendedThemes 返回已上架且推荐的题材,按 Rank、Id 倒序,最多 limit 条。
func ListRecommendedThemes(limit int) []*ThemeItem {
if limit <= 0 {
limit = homeRecommendThemeLimit
}
cache.lock.RLock()
defer cache.lock.RUnlock()
result := make([]*ThemeItem, 0, limit)
for _, item := range cache.themeByID {
if item == nil || !item.Status || !item.IsRecommend {
continue
}
cp := *item
result = append(result, &cp)
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].Rank != result[j].Rank {
return result[i].Rank > result[j].Rank
}
return result[i].Id > result[j].Id
})
if len(result) > limit {
result = result[:limit]
}
return result
}
// ThemeNameMap 批量取题材名称(含未上架,便于列表展示历史题材名)。
func ThemeNameMap(ids []int64) map[int64]string {
result := make(map[int64]string, len(ids))
if len(ids) == 0 {
return result
}
cache.lock.RLock()
defer cache.lock.RUnlock()
for _, id := range ids {
if item := cache.themeByID[id]; item != nil {
result[id] = item.Name
}
}
return result
}
// TagNameMap 批量取标签名称(含未上架)。
func TagNameMap(ids []int64) map[int64]string {
result := make(map[int64]string, len(ids))
if len(ids) == 0 {
return result
}
cache.lock.RLock()
defer cache.lock.RUnlock()
for _, id := range ids {
if item := cache.tagByID[id]; item != nil {
result[id] = item.Name
}
}
return result
}
// ThemeSlugMap 批量取题材 slug(含未上架)。
func ThemeSlugMap(ids []int64) map[int64]string {
result := make(map[int64]string, len(ids))
if len(ids) == 0 {
return result
}
cache.lock.RLock()
defer cache.lock.RUnlock()
for _, id := range ids {
if item := cache.themeByID[id]; item != nil {
result[id] = item.Slug
}
}
return result
}
// TagSlugMap 批量取标签 slug(含未上架)。
func TagSlugMap(ids []int64) map[int64]string {
result := make(map[int64]string, len(ids))
if len(ids) == 0 {
return result
}
cache.lock.RLock()
defer cache.lock.RUnlock()
for _, id := range ids {
if item := cache.tagByID[id]; item != nil {
result[id] = item.Slug
}
}
return result
}
+101
View File
@@ -0,0 +1,101 @@
package playletcache
import (
"context"
"videoweb/pub/common/log"
"videoweb/pub/models/playletmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const GuessLikeLimit = 15
var guessLikeProjection = bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Status, Value: 1},
{Key: playletmod.FN.Playlet.IsFeatured, Value: 1},
{Key: playletmod.FN.Playlet.IsRecommend, Value: 1},
{Key: playletmod.FN.Playlet.ShowOnBanner, Value: 1},
{Key: playletmod.FN.Playlet.Banner, Value: 1},
{Key: playletmod.FN.Playlet.OnlineAt, Value: 1},
{Key: playletmod.FN.Playlet.MediaCreatedAt, Value: 1},
{Key: playletmod.FN.Playlet.MediaUpdatedAt, Value: 1},
{Key: playletmod.FN.Playlet.ThemeIds, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.PlayName, Value: 1},
{Key: playletmod.FN.Playlet.Description, Value: 1},
{Key: playletmod.FN.Playlet.Slug, Value: 1},
{Key: playletmod.FN.Playlet.Desc, Value: 1},
{Key: playletmod.FN.Playlet.Anchor, Value: 1},
{Key: playletmod.FN.Playlet.Cover, Value: 1},
{Key: playletmod.FN.Playlet.CompressedCover, Value: 1},
{Key: playletmod.FN.Playlet.TagIds, Value: 1},
{Key: playletmod.FN.Playlet.TotalEpisode, Value: 1},
{Key: playletmod.FN.Playlet.UpdateStatus, Value: 1},
{Key: playletmod.FN.Playlet.CountBrowse, Value: 1},
{Key: playletmod.FN.Playlet.HeatValue, Value: 1},
{Key: playletmod.FN.Playlet.CountCollect, Value: 1},
{Key: playletmod.FN.Playlet.CountLike, Value: 1},
{Key: playletmod.FN.Playlet.VirtualCountLike, Value: 1},
{Key: playletmod.FN.Playlet.IsOriginal, Value: 1},
{Key: playletmod.FN.Playlet.OperateType, Value: 1},
{Key: playletmod.FN.Playlet.CreatedAt, Value: 1},
{Key: playletmod.FN.Playlet.UpdatedAt, Value: 1},
{Key: "seoInfo", Value: 1},
}
func updateGuessLikeCache(ctx context.Context) {
list, err := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Status: playletmod.StatusUp,
}, options.Find().
SetProjection(guessLikeProjection).
SetSort(bson.D{
{Key: playletmod.FN.Playlet.CountCollect, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}).
SetLimit(GuessLikeLimit))
if err != nil {
log.Warn("playletcache.updateGuessLikeCache failed", log.E(err))
return
}
temp := make([]*playletmod.Playlet, 0, len(list))
for _, item := range list {
if cloned := clonePlaylet(item); cloned != nil {
temp = append(temp, cloned)
}
}
cache.lock.Lock()
cache.guessLike = temp
cache.lock.Unlock()
}
// ListGuessLike 返回收藏量最多的上架短剧,最多15部。
func ListGuessLike() []*playletmod.Playlet {
cache.lock.RLock()
defer cache.lock.RUnlock()
if len(cache.guessLike) == 0 {
return make([]*playletmod.Playlet, 0)
}
result := make([]*playletmod.Playlet, 0, len(cache.guessLike))
for _, item := range cache.guessLike {
if cloned := clonePlaylet(item); cloned != nil {
result = append(result, cloned)
}
}
return result
}
func clonePlaylet(item *playletmod.Playlet) *playletmod.Playlet {
if item == nil || item.Id <= 0 {
return nil
}
cp := *item
if item.ThemeIds != nil {
cp.ThemeIds = append([]int64(nil), item.ThemeIds...)
}
if item.TagIds != nil {
cp.TagIds = append([]int64(nil), item.TagIds...)
}
return &cp
}
@@ -0,0 +1,49 @@
package playletser
import (
"reflect"
"testing"
"videoweb/pub/models/homeconfigmod"
"videoweb/pub/models/playletmod"
"go.mongodb.org/mongo-driver/bson"
)
func TestHomeConfigDisplayLimit(t *testing.T) {
tests := []struct {
name string
config *homeconfigmod.HomeConfig
want int64
}{
{name: "旧题材配置", config: &homeconfigmod.HomeConfig{Type: homeconfigmod.TypeTheme}, want: 8},
{name: "自定义题材数量", config: &homeconfigmod.HomeConfig{Type: homeconfigmod.TypeTheme, Limit: 5}, want: 5},
{name: "旧短剧配置", config: &homeconfigmod.HomeConfig{Type: homeconfigmod.TypePlaylet}, want: 12},
{name: "自定义短剧数量", config: &homeconfigmod.HomeConfig{Type: homeconfigmod.TypePlaylet, Limit: 20}, want: 20},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := homeConfigDisplayLimit(tt.config); got != tt.want {
t.Fatalf("homeConfigDisplayLimit() = %d, want %d", got, tt.want)
}
})
}
}
func TestHomeConfigSortTypeFourUsesOnlineAt(t *testing.T) {
filter, sort := homeConfigPlayletQuery(homeconfigmod.SortTypeOnlineAt, 0)
if _, ok := filter[playletmod.FN.Playlet.Preview]; ok {
t.Fatal("上架时间排序不应过滤无预告片的短剧")
}
want := bson.D{
{Key: playletmod.FN.Playlet.OnlineAt, Value: -1},
{Key: playletmod.FN.Playlet.Id, Value: -1},
}
if !reflect.DeepEqual(sort, want) {
t.Fatalf("上架时间排序 = %v, want %v", sort, want)
}
_, latestSort := homeConfigPlayletQuery(homeconfigmod.SortTypeLatest, 0)
if len(latestSort) == 0 || latestSort[0].Key != playletmod.FN.Playlet.UpdatedAt {
t.Fatalf("最近更新应按更新时间排序,得到 %v", latestSort)
}
}
+231
View File
@@ -0,0 +1,231 @@
package playletser
import (
"context"
"strings"
"videoweb/app/api/proto"
"videoweb/app/service/configser"
"videoweb/app/service/playletcache"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/homeconfigmod"
"videoweb/pub/models/playletmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func homeConfigToModule(item *proto.HomeConfigData) *proto.HomeModuleData {
moduleType := int32(1)
if item.Type == homeconfigmod.TypeTheme {
moduleType = 2
}
return &proto.HomeModuleData{Id: item.Id, Slug: item.Slug, Title: item.Name, Desc: item.Desc, Keyword: item.Keyword, Label: item.Label,
Style: item.Style, CardBackgroundImage: item.CardBackgroundImage, ModuleType: moduleType, DataType: 1,
RecommendType: item.RecommendType, CategoryId: item.CategoryId, Rank: item.Rank, Limit: item.Limit, SeoInfo: item.SeoInfo,
List: item.List, TopicList: item.ThemeList}
}
// recommendType=0 保留历史 sortType 的含义
func homeConfigPlayletQueryForConfig(config *homeconfigmod.HomeConfig) (bson.M, bson.D, int64) {
categoryID := config.CategoryId
if categoryID == 0 && config.RecommendType == 0 && config.Type == homeconfigmod.TypePlaylet {
categoryID = homeConfigCategoryID(config.SortType, config.Id)
}
if config.RecommendType == 0 {
filter, sort := homeConfigPlayletQuery(config.SortType, categoryID)
return filter, sort, categoryID
}
filter := homeUpPlayletFilter(nil)
field := playletmod.FN.Playlet.UpdatedAt
switch config.RecommendType {
case 2:
field = playletmod.FN.Playlet.CountLike
case 3:
field = playletmod.FN.Playlet.CountBrowse
case 4:
field = playletmod.FN.Playlet.HeatValue
case 5:
if categoryID > 0 {
filter[playletmod.FN.Playlet.CategoryIds] = categoryID
}
field = playletmod.FN.Playlet.RecommendRank
}
return filter, bson.D{{Key: field, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}}, categoryID
}
func findHomeConfigPlayletListForConfig(ctx context.Context, config *homeconfigmod.HomeConfig, limit int64) (playletmod.PlayletList, *srvdef.SrvErr) {
if config.RecommendType == 0 {
_, _, categoryID := homeConfigPlayletQueryForConfig(config)
return findHomeConfigPlayletList(ctx, config.SortType, categoryID, limit)
}
filter, sort, categoryID := homeConfigPlayletQueryForConfig(config)
if config.RecommendType == 5 {
if categoryID <= 0 {
return make(playletmod.PlayletList, 0), nil
}
return playletmod.FindManyPlayletByCategoryRank(ctx, filter, categoryID, 0, limit, playletmod.CategoryRankSort(), playletHomeProjection)
}
return findHomePlaylets(ctx, filter, sort, limit)
}
func findHomeConfigThemePlayletsForConfig(ctx context.Context, config *homeconfigmod.HomeConfig, skip, limit int64) ([]homeConfigThemePlaylets, playletmod.PlayletList, *srvdef.SrvErr) {
if len(config.TopicIds) == 0 {
if skip == 0 {
return findHomeConfigThemePlaylets(ctx, limit)
}
groups, _, all, err := findHomeConfigThemePlayletsPage(ctx, skip, limit)
return groups, all, err
}
selected := map[int64]bool{}
for _, id := range config.TopicIds {
selected[id] = true
}
result := make([]homeConfigThemePlaylets, 0)
all := make(playletmod.PlayletList, 0)
var offset int64
for _, theme := range playletcache.ListEnabledThemesByPlayletCount(0) {
if theme == nil || !selected[theme.Id] {
continue
}
filter := topicDetailPlayletFilter(theme.Id)
count, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return nil, nil, err
}
if count == 0 {
continue
}
if offset < skip {
offset++
continue
}
if int64(len(result)) >= limit {
break
}
list, err := findHomePlaylets(ctx, filter, playletTopicDetailSort(topicDetailSortEdit), topicListPlayletLimit)
if err != nil {
return nil, nil, err
}
result = append(result, homeConfigThemePlaylets{Theme: theme, Playlets: list, TotalCount: count})
all = append(all, list...)
offset++
}
return result, all, nil
}
func countHomeConfigThemes(ctx context.Context, config *homeconfigmod.HomeConfig) (int64, *srvdef.SrvErr) {
if len(config.TopicIds) == 0 {
_, count, _, err := findHomeConfigThemePlayletsPage(ctx, 0, 0)
return count, err
}
selected := map[int64]bool{}
for _, id := range config.TopicIds {
selected[id] = true
}
var count int64
for _, theme := range playletcache.ListEnabledThemesByPlayletCount(0) {
if theme == nil || !selected[theme.Id] {
continue
}
filter := topicDetailPlayletFilter(theme.Id)
total, err := playletmod.CountPlaylet(ctx, filter)
if err != nil {
return 0, err
}
if total > 0 {
count++
}
}
return count, nil
}
func HomeConfigList(ctx context.Context, req *proto.PlayletHomeConfigListReq) (*proto.PlayletHomeConfigListResp, *srvdef.SrvErr) {
if req == nil || (req.Id <= 0 && strings.TrimSpace(req.Slug) == "") {
return nil, srvdef.New(srvdef.ErrParamError, "首页配置ID或slug不能为空", "")
}
filter := bson.M{}
if slug := strings.TrimSpace(req.Slug); slug != "" {
filter[homeconfigmod.FN.HomeConfig.Slug] = slug
} else {
filter[homeconfigmod.FN.HomeConfig.Id] = req.Id
}
config, err := homeconfigmod.FindOneHomeConfig(ctx, filter)
if err != nil {
return nil, err
}
if config.Type != homeconfigmod.TypePlaylet && config.Type != homeconfigmod.TypeTheme {
return nil, srvdef.New(srvdef.ErrNoData, "首页配置不存在", "")
}
pageNum, pageSize := req.PageNum, req.PageSize
if pageNum <= 0 {
pageNum = 1
}
if pageSize <= 0 {
pageSize = 20
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
module := &proto.HomeModuleData{Id: config.Id, Slug: config.Slug, Title: config.Name, Desc: config.Desc, Keyword: homeconfigmod.NormalizeKeywords(config.Keyword), Label: config.Label,
Style: config.Style, CardBackgroundImage: withImageDomain(ctx, config.CardBackgroundImage), DataType: 1,
RecommendType: config.RecommendType, CategoryId: config.CategoryId, Rank: config.Rank, Limit: homeConfigDisplayLimit(config), SeoInfo: config.SeoInfo,
List: make([]*proto.CommonData, 0), TopicList: make([]*proto.HomeConfigThemeData, 0)}
if config.Type == homeconfigmod.TypeTheme {
module.ModuleType = 2
} else {
module.ModuleType = 1
}
resp := &proto.PlayletHomeConfigListResp{Config: module, PageNum: int(pageNum), PageSize: int(pageSize),
List: make([]*proto.CommonData, 0), TopicList: make([]*proto.HomeConfigThemeData, 0), Advitise: configser.GetAdvitise()}
if !config.Status {
return resp, nil
}
skip := (pageNum - 1) * pageSize
if config.Type == homeconfigmod.TypePlaylet {
playletFilter, sort, categoryID := homeConfigPlayletQueryForConfig(config)
if (config.RecommendType == 5 || (config.RecommendType == 0 && config.SortType == homeconfigmod.SortTypeCategory)) && categoryID <= 0 {
return resp, nil
}
resp.TotalCount, err = playletmod.CountPlaylet(ctx, playletFilter)
if err != nil {
return nil, err
}
var list playletmod.PlayletList
if config.RecommendType == 5 || (config.RecommendType == 0 && config.SortType == homeconfigmod.SortTypeCategory) {
list, err = playletmod.FindManyPlayletByCategoryRank(ctx, playletFilter, categoryID, skip, pageSize, playletmod.CategoryRankSort(), playletHomeProjection)
} else {
list, err = findHomePlaylets(ctx, playletFilter, sort, pageSize, options.Find().SetSkip(skip))
}
if err != nil {
return nil, err
}
themeNames, err := buildHomeThemeNameMap(ctx, list)
if err != nil {
return nil, err
}
tagNames, err := buildHomeTagNameMap(ctx, list)
if err != nil {
return nil, err
}
resp.List = playletsToHomeCommonData(ctx, list, themeNames, tagNames)
return resp, nil
}
resp.TotalCount, err = countHomeConfigThemes(ctx, config)
if err != nil {
return nil, err
}
groups, all, err := findHomeConfigThemePlayletsForConfig(ctx, config, skip, pageSize)
if err != nil {
return nil, err
}
themeNames, err := buildHomeThemeNameMap(ctx, all)
if err != nil {
return nil, err
}
tagNames, err := buildHomeTagNameMap(ctx, all)
if err != nil {
return nil, err
}
resp.TopicList = homeConfigThemesToHomeData(ctx, groups, themeNames, tagNames)
return resp, nil
}
@@ -0,0 +1,38 @@
package playletser
import (
"testing"
"videoweb/pub/models/homeconfigmod"
"videoweb/pub/models/playletmod"
)
func TestHomeConfigPlayletQueryForConfig(t *testing.T) {
tests := []struct {
name string
config homeconfigmod.HomeConfig
field string
category int64
recommended bool
}{
{name: "legacy recommendation", config: homeconfigmod.HomeConfig{SortType: homeconfigmod.SortTypeRecommend}, field: playletmod.FN.Playlet.RecommendRank, recommended: true},
{name: "new likes", config: homeconfigmod.HomeConfig{RecommendType: 2}, field: playletmod.FN.Playlet.CountLike},
{name: "new category", config: homeconfigmod.HomeConfig{RecommendType: 5, CategoryId: 42}, field: playletmod.FN.Playlet.RecommendRank, category: 42},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, sort, category := homeConfigPlayletQueryForConfig(&tt.config)
if category != tt.category {
t.Fatalf("category = %d, want %d", category, tt.category)
}
if len(sort) == 0 || sort[0].Key != tt.field {
t.Fatalf("sort = %v, want %s", sort, tt.field)
}
if _, ok := filter[playletmod.FN.Playlet.IsRecommend]; ok != tt.recommended {
t.Fatalf("recommend filter = %v, want %v", ok, tt.recommended)
}
if tt.category > 0 && filter[playletmod.FN.Playlet.CategoryIds] != tt.category {
t.Fatalf("category filter = %v", filter)
}
})
}
}
+91
View File
@@ -0,0 +1,91 @@
package playletser
import (
"context"
"videoweb/app/api/proto"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletlikemod"
"videoweb/pub/models/playletmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// LikeList 分页查询当前用户点赞的短剧
func LikeList(ctx context.Context, userID int64, req *proto.PlayletLikeListReq) (*proto.PlayletLikeListResp, *srvdef.SrvErr) {
if userID <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
if req == nil {
return nil, srvdef.New(srvdef.ErrParamError, "", "")
}
pageNum, pageSize := normalizePlayletPage(req.PageNum, req.PageSize)
filter := bson.M{playletlikemod.FN.PlayletLike.UserId: userID}
totalCount, err := playletlikemod.CountPlayletLike(ctx, filter)
if err != nil {
return nil, err
}
resp := &proto.PlayletLikeListResp{
TotalCount: totalCount, PageNum: int(pageNum), PageSize: int(pageSize),
List: make([]proto.PlayletLikeData, 0),
}
skip := (pageNum - 1) * pageSize
if skip >= totalCount {
return resp, nil
}
likes, err := playletlikemod.FindManyPlayletLike(ctx, filter, options.Find().
SetProjection(bson.D{
{Key: playletlikemod.FN.PlayletLike.Id, Value: 1},
{Key: playletlikemod.FN.PlayletLike.PlayletId, Value: 1},
{Key: playletlikemod.FN.PlayletLike.CreatedAt, Value: 1},
}).
SetSort(bson.D{
{Key: playletlikemod.FN.PlayletLike.CreatedAt, Value: -1},
{Key: playletlikemod.FN.PlayletLike.Id, Value: -1},
}).
SetSkip(skip).
SetLimit(pageSize))
if err != nil {
return nil, err
}
ids := make([]int64, 0, len(likes))
for _, like := range likes {
if like != nil && like.PlayletId > 0 {
ids = append(ids, like.PlayletId)
}
}
if len(ids) == 0 {
return resp, nil
}
playlets, err := playletmod.FindManyPlaylet(ctx, bson.M{
playletmod.FN.Playlet.Id: bson.M{"$in": ids},
}, options.Find().SetProjection(playletHomeProjection))
if err != nil {
return nil, err
}
themeNameMap, err := buildHomeThemeNameMap(ctx, playlets)
if err != nil {
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, playlets)
if err != nil {
return nil, err
}
dataByID := make(map[int64]*proto.CommonData, len(playlets))
for _, data := range playletsToHomeCommonData(ctx, playlets, themeNameMap, tagNameMap) {
dataByID[data.Id] = data
}
for _, like := range likes {
if like == nil {
continue
}
data := dataByID[like.PlayletId]
if data == nil {
continue
}
resp.List = append(resp.List, proto.PlayletLikeData{
CommonData: *data, IsLike: 1, LikedAt: like.CreatedAt,
})
}
return resp, nil
}
+81
View File
@@ -0,0 +1,81 @@
package playletser
import (
"context"
"time"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const operateRandomModulus int64 = 2147483647
func operateListFilter(typ int64) bson.M {
filter := homeUpPlayletFilter(nil)
if typ == operateListOriginal {
filter[playletmod.FN.Playlet.IsRecommend] = true
}
return filter
}
func operateListSort(typ int64) bson.D {
if typ == operateListHot {
return bson.D{{Key: playletmod.FN.Playlet.HeatValue, Value: -1}, {Key: playletmod.FN.Playlet.Id, Value: -1}}
}
return playletRecentlyUpdatedSort()
}
// findRandomOperatePlaylets 按每日固定的伪随机顺序分页查询短剧
func findRandomOperatePlaylets(ctx context.Context, filter bson.M, skip, limit int64, now time.Time) (playletmod.PlayletList, *srvdef.SrvErr) {
var rows []struct {
Id int64 `bson:"_id"`
}
if err := playletmod.AggregatePlaylet(ctx, &rows, operateRandomPipeline(filter, skip, limit, now)); err != nil {
return nil, err
}
ids := make([]int64, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.Id)
}
if len(ids) == 0 {
return make(playletmod.PlayletList, 0), nil
}
queryFilter := bson.M{playletmod.FN.Playlet.Id: bson.M{"$in": ids}}
for key, value := range filter {
queryFilter[key] = value
}
list, err := playletmod.FindManyPlaylet(ctx, queryFilter, options.Find().SetProjection(playletHomeProjection))
if err != nil {
return nil, err
}
byID := make(map[int64]*playletmod.Playlet, len(list))
for _, item := range list {
if item != nil {
byID[item.Id] = item
}
}
ordered := make(playletmod.PlayletList, 0, len(ids))
for _, id := range ids {
if item := byID[id]; item != nil {
ordered = append(ordered, item)
}
}
return ordered, nil
}
func operateRandomPipeline(filter bson.M, skip, limit int64, now time.Time) []bson.M {
day := (now.Unix() + 8*60*60) / (24 * 60 * 60)
factor := int64(48271) + day%997*2767
idMod := bson.M{"$mod": bson.A{"$_id", operateRandomModulus}}
score := bson.M{"$mod": bson.A{bson.M{"$multiply": bson.A{idMod, factor}}, operateRandomModulus}}
return []bson.M{
{"$match": filter},
{"$project": bson.M{"_id": 1}},
{"$addFields": bson.M{"randomScore": score}},
{"$sort": bson.D{{Key: "randomScore", Value: 1}, {Key: "_id", Value: 1}}},
{"$skip": skip},
{"$limit": limit},
}
}
@@ -0,0 +1,46 @@
package playletser
import (
"reflect"
"testing"
"time"
"videoweb/pub/models/playletmod"
"go.mongodb.org/mongo-driver/bson"
)
func TestOperateListFiltersAndSorts(t *testing.T) {
for _, typ := range []int64{operateListLatest, operateListHot, operateListOriginal, operateListRandom} {
filter := operateListFilter(typ)
if filter[playletmod.FN.Playlet.Status] != playletmod.StatusUp {
t.Fatalf("type %d must contain the up status filter", typ)
}
_, recommend := filter[playletmod.FN.Playlet.IsRecommend]
if recommend != (typ == operateListOriginal) {
t.Fatalf("type %d recommend filter = %v", typ, recommend)
}
}
if got := operateListSort(operateListLatest)[0].Key; got != playletmod.FN.Playlet.UpdatedAt {
t.Fatalf("latest sort = %s", got)
}
if got := operateListSort(operateListHot)[0].Key; got != playletmod.FN.Playlet.HeatValue {
t.Fatalf("hot sort = %s", got)
}
}
func TestOperateRandomPipelineIsStableWithinDay(t *testing.T) {
filter := bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp}
day := time.Date(2026, 10, 24, 1, 0, 0, 0, time.UTC)
first := operateRandomPipeline(filter, 20, 10, day)
second := operateRandomPipeline(filter, 20, 10, day.Add(time.Hour))
if !reflect.DeepEqual(first, second) {
t.Fatal("random order changed within one China Standard Time day")
}
third := operateRandomPipeline(filter, 20, 10, day.Add(24*time.Hour))
if reflect.DeepEqual(first, third) {
t.Fatal("random order did not change on the next China Standard Time day")
}
if first[4]["$skip"] != int64(20) || first[5]["$limit"] != int64(10) {
t.Fatalf("pagination stages = %v", first)
}
}
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
package playletser
import (
"context"
"strings"
"videoweb/app/service/playletcache"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playlettagmod"
"videoweb/pub/models/playletthememod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// resolvePlayletID 解析短剧ID:slug 非空时先按 slug 查 ID,否则使用传入 ID。
func resolvePlayletID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) {
slug = strings.TrimSpace(slug)
if slug != "" {
item, err := playletmod.FindOnePlaylet(ctx, bson.M{
playletmod.FN.Playlet.Slug: slug,
}, options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
}))
if err != nil {
return 0, err
}
return item.Id, nil
}
if required && id <= 0 {
return 0, srvdef.New(srvdef.ErrParamError, "短剧ID不能为空", "短剧ID不能为空")
}
return id, nil
}
// resolveThemeID 解析题材ID:slug 非空时先按 slug 查 ID,否则使用传入 ID(0 表示不按题材筛选)。
func resolveThemeID(ctx context.Context, id int64, slug string) (int64, *srvdef.SrvErr) {
slug = strings.TrimSpace(slug)
if slug == "" {
return id, nil
}
if item := playletcache.GetThemeBySlug(slug); item != nil {
return item.Id, nil
}
theme, err := playletthememod.FindOnePlayletTheme(ctx, bson.M{
playletthememod.FN.PlayletTheme.Slug: slug,
}, options.FindOne().SetProjection(bson.D{
{Key: playletthememod.FN.PlayletTheme.Id, Value: 1},
}))
if err != nil {
return 0, err
}
return theme.Id, nil
}
// resolveTagID 解析标签ID:slug 非空时先按 slug 查 ID,否则使用传入 ID。
func resolveTagID(ctx context.Context, id int64, slug string) (int64, *srvdef.SrvErr) {
slug = strings.TrimSpace(slug)
if slug != "" {
if item := playletcache.GetTagBySlug(slug); item != nil {
return item.Id, nil
}
tag, err := playlettagmod.FindOnePlayletTag(ctx, bson.M{
playlettagmod.FN.PlayletTag.Slug: slug,
}, options.FindOne().SetProjection(bson.D{
{Key: playlettagmod.FN.PlayletTag.Id, Value: 1},
}))
if err != nil {
return 0, err
}
return tag.Id, nil
}
if id <= 0 {
return 0, srvdef.New(srvdef.ErrParamError, "标签ID不能为空", "标签ID不能为空")
}
return id, nil
}
+131
View File
@@ -0,0 +1,131 @@
package playletser
import (
"context"
"strings"
"videoweb/app/api/proto"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/playletthememod"
"videoweb/pub/models/playlettopicgroupmod"
"go.mongodb.org/mongo-driver/bson"
)
func enabledTopicGroups(ctx context.Context) (playlettopicgroupmod.PlayletTopicGroupList, *srvdef.SrvErr) {
return playlettopicgroupmod.FindMany(ctx, bson.M{"status": true})
}
type topicPlayletCount struct {
Id int64 `bson:"_id"`
Count int64 `bson:"count"`
}
// topicGroupPage 分页查询区块下有已上架短剧的专题
func topicGroupPage(ctx context.Context, group *playlettopicgroupmod.PlayletTopicGroup, skip, limit int64) (playletthememod.PlayletThemeList, map[int64]int64, int64, *srvdef.SrvErr) {
counts := make(map[int64]int64)
if len(group.TopicIds) == 0 {
return playletthememod.PlayletThemeList{}, counts, 0, nil
}
themes, err := playletthememod.FindManyPlayletTheme(ctx, bson.M{
playletthememod.FN.PlayletTheme.Id: bson.M{"$in": group.TopicIds},
playletthememod.FN.PlayletTheme.Status: true,
})
if err != nil {
return nil, nil, 0, err
}
byID := make(map[int64]*playletthememod.PlayletTheme, len(themes))
for _, theme := range themes {
if theme != nil {
byID[theme.Id] = theme
}
}
rows := make([]topicPlayletCount, 0)
err = playletmod.AggregatePlaylet(ctx, &rows, []bson.M{
{"$match": bson.M{playletmod.FN.Playlet.Status: playletmod.StatusUp, playletmod.FN.Playlet.ThemeIds: bson.M{"$in": group.TopicIds}}},
{"$unwind": "$" + playletmod.FN.Playlet.ThemeIds},
{"$match": bson.M{playletmod.FN.Playlet.ThemeIds: bson.M{"$in": group.TopicIds}}},
{"$group": bson.M{"_id": "$" + playletmod.FN.Playlet.ThemeIds, "count": bson.M{"$sum": 1}}},
})
if err != nil {
return nil, nil, 0, err
}
for _, row := range rows {
counts[row.Id] = row.Count
}
available := make(playletthememod.PlayletThemeList, 0, len(themes))
seen := make(map[int64]bool, len(group.TopicIds))
for _, id := range group.TopicIds {
if seen[id] {
continue
}
seen[id] = true
if theme := byID[id]; theme != nil && counts[id] > 0 {
available = append(available, theme)
}
}
total := int64(len(available))
if skip >= total {
return playletthememod.PlayletThemeList{}, counts, total, nil
}
end := skip + limit
if end > total {
end = total
}
return available[skip:end], counts, total, nil
}
// topicGroupPageData 组装区块当前页专题及单部短剧预览
func topicGroupPageData(ctx context.Context, group *playlettopicgroupmod.PlayletTopicGroup, skip, limit int64) (*proto.PlayletTopicGroupPageItem, *srvdef.SrvErr) {
themes, counts, totalCount, err := topicGroupPage(ctx, group, skip, limit)
if err != nil {
return nil, err
}
page := &proto.PlayletTopicGroupPageItem{
Id: group.Id, Name: group.Name, Desc: group.Desc, Rank: group.Rank,
TotalCount: totalCount, List: make([]*proto.PlayletTopicPageItem, 0, len(themes)),
}
if len(themes) == 0 {
return page, nil
}
previewByTheme := make(map[int64]playletmod.PlayletList, len(themes))
previews := make(playletmod.PlayletList, 0, len(themes))
for _, theme := range themes {
filter, sort := playletTopicPagePlayletQuery(0, theme.Id)
list, err := findHomePlaylets(ctx, filter, sort, 1)
if err != nil {
return nil, err
}
previewByTheme[theme.Id] = list
previews = append(previews, list...)
}
themeNameMap, err := buildHomeThemeNameMap(ctx, previews)
if err != nil {
return nil, err
}
tagNameMap, err := buildHomeTagNameMap(ctx, previews)
if err != nil {
return nil, err
}
for _, theme := range themes {
preview := playletsToHomeCommonData(ctx, previewByTheme[theme.Id], themeNameMap, tagNameMap)
covers := make([]string, 0, len(preview))
if len(preview) > 0 {
cover := preview[0].HorizontalCover
if cover == "" {
cover = preview[0].Cover
}
if cover != "" {
covers = append(covers, cover)
}
}
page.List = append(page.List, &proto.PlayletTopicPageItem{
Id: theme.Id, Name: theme.Name, Slug: theme.Slug,
Desc: strings.TrimSpace(theme.SeoInfo.Description),
Icon: withImageDomain(ctx, theme.Icon), Rank: theme.Rank,
PlayletCount: counts[theme.Id], Covers: covers, List: preview,
SeoInfo: buildPageSeoInfoFromValue(theme.SeoInfo, theme.Name, "", buildPlayletThemeListCanonicalURL(theme.Slug, theme.Id)),
})
}
return page, nil
}
+89
View File
@@ -0,0 +1,89 @@
package playlettagser
import (
"context"
"strings"
"videoweb/app/api/proto"
"videoweb/app/service/configser"
"videoweb/app/service/playletcache"
"videoweb/pub/common/srvdef"
"videoweb/pub/pubdef"
"videoweb/pub/seo/h5path"
)
// List 查询 app 端短剧标签列表(读标签缓存,含 SEO)。
func List(ctx context.Context) (*proto.PlayletTagListResponse, *srvdef.SrvErr) {
list := playletcache.ListEnabledTags()
items := make([]*proto.PlayletTagItem, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
items = append(items, &proto.PlayletTagItem{
Id: item.Id,
Name: item.Name,
Slug: item.Slug,
Rank: item.Rank,
Status: item.Status,
SeoInfo: buildTagSeoInfo(item),
})
}
return &proto.PlayletTagListResponse{List: items}, nil
}
// buildTagSeoInfo 将标签后台 SEO 转为页面 SeoInfo,缺失字段回退标签名/站点默认。
func buildTagSeoInfo(item *playletcache.TagItem) *proto.SeoInfo {
seo := configser.GetSiteSeoInfo()
if item == nil {
return &seo
}
custom := normalizeTagSeoValue(item.SeoInfo)
if title := strings.TrimSpace(custom.Title); title != "" {
seo.Title = title
} else if name := strings.TrimSpace(item.Name); name != "" {
seo.Title = name
}
if desc := strings.TrimSpace(custom.Description); desc != "" {
seo.Description = desc
}
if len(custom.Keyworld) > 0 {
seo.Keywords = append([]string{}, custom.Keyworld...)
}
pagePath := h5path.Tag(item.Slug, item.Id)
seo.CanonicalURL = joinCanonicalURL(seo.CanonicalURL, pagePath)
return &seo
}
func normalizeTagSeoValue(seo pubdef.SeoValue) pubdef.SeoValue {
seo.Title = strings.TrimSpace(seo.Title)
seo.Description = strings.TrimSpace(seo.Description)
if seo.Keyworld == nil {
seo.Keyworld = make([]string, 0)
return seo
}
words := make([]string, 0, len(seo.Keyworld))
for _, word := range seo.Keyworld {
word = strings.TrimSpace(word)
if word == "" {
continue
}
words = append(words, word)
}
seo.Keyworld = words
return seo
}
func joinCanonicalURL(baseURL, pagePath string) string {
pagePath = strings.TrimSpace(pagePath)
if pagePath == "" {
return strings.TrimSpace(baseURL)
}
if !strings.HasPrefix(pagePath, "/") {
pagePath = "/" + pagePath
}
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return pagePath
}
return strings.TrimRight(baseURL, "/") + pagePath
}
+109
View File
@@ -0,0 +1,109 @@
package playletthemeser
import (
"context"
"strings"
"videoweb/app/api/proto"
"videoweb/app/service/configser"
"videoweb/app/service/playletcache"
"videoweb/pub/common/netutil"
"videoweb/pub/common/srvdef"
"videoweb/pub/pubdef"
"videoweb/pub/seo/h5path"
)
// List 查询 app 端短剧题材列表(读题材缓存,含 SEO)
func List(ctx context.Context) (*proto.PlayletThemeListResponse, *srvdef.SrvErr) {
list := playletcache.ListEnabledThemes()
imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx))
items := make([]*proto.PlayletThemeItem, 0, len(list))
for _, item := range list {
if item == nil {
continue
}
items = append(items, &proto.PlayletThemeItem{
Id: item.Id,
Name: item.Name,
Slug: item.Slug,
Icon: mergeThemeIcon(imgCdn, item.Icon),
Rank: item.Rank,
Status: item.Status,
IsRecommend: item.IsRecommend,
PlayletCount: item.PlayletCount,
SeoInfo: buildThemeSeoInfo(item),
})
}
return &proto.PlayletThemeListResponse{List: items}, nil
}
// buildThemeSeoInfo 将题材后台 SEO 转为页面 SeoInfo,缺失字段回退题材名/站点默认。
func buildThemeSeoInfo(item *playletcache.ThemeItem) *proto.SeoInfo {
seo := configser.GetSiteSeoInfo()
if item == nil {
return &seo
}
custom := normalizeThemeSeoValue(item.SeoInfo)
if title := strings.TrimSpace(custom.Title); title != "" {
seo.Title = title
} else if name := strings.TrimSpace(item.Name); name != "" {
seo.Title = name
}
if desc := strings.TrimSpace(custom.Description); desc != "" {
seo.Description = desc
}
if len(custom.Keyworld) > 0 {
seo.Keywords = append([]string{}, custom.Keyworld...)
}
pagePath := h5path.ListTheme(item.Slug, item.Id)
seo.CanonicalURL = joinCanonicalURL(seo.CanonicalURL, pagePath)
return &seo
}
func normalizeThemeSeoValue(seo pubdef.SeoValue) pubdef.SeoValue {
seo.Title = strings.TrimSpace(seo.Title)
seo.Description = strings.TrimSpace(seo.Description)
if seo.Keyworld == nil {
seo.Keyworld = make([]string, 0)
return seo
}
words := make([]string, 0, len(seo.Keyworld))
for _, word := range seo.Keyworld {
word = strings.TrimSpace(word)
if word == "" {
continue
}
words = append(words, word)
}
seo.Keyworld = words
return seo
}
func joinCanonicalURL(baseURL, pagePath string) string {
pagePath = strings.TrimSpace(pagePath)
if pagePath == "" {
return strings.TrimSpace(baseURL)
}
if !strings.HasPrefix(pagePath, "/") {
pagePath = "/" + pagePath
}
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return pagePath
}
return strings.TrimRight(baseURL, "/") + pagePath
}
// mergeThemeIcon 给题材图标拼接图片域名
func mergeThemeIcon(imgCdn string, icon string) string {
icon = strings.TrimSpace(icon)
if icon == "" {
return ""
}
if strings.HasPrefix(icon, "http://") || strings.HasPrefix(icon, "https://") {
return icon
}
if imgCdn == "" {
return icon
}
return netutil.MergeURL(imgCdn, icon)
}
+108
View File
@@ -0,0 +1,108 @@
package rechargepackageser
import (
"context"
"videoweb/app/api/proto"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/rechargepackagemod"
"videoweb/pub/payCenter"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// GetRechargePackageList 查询购买套餐页所需的会员畅享和金币充值列表,并为各套餐附上可用支付方式。
// 内部统一调用一次新支付中心通道查询,避免 N+1 请求。
func GetRechargePackageList(ctx context.Context) (*proto.RechargePackageListResp, *srvdef.SrvErr) {
filter := bson.M{rechargepackagemod.FN.RechargePackage.Status: rechargepackagemod.StatusEnabled}
opts := options.Find().SetSort(bson.D{
{Key: rechargepackagemod.FN.RechargePackage.PackageType, Value: 1},
{Key: rechargepackagemod.FN.RechargePackage.Sort, Value: 1},
{Key: rechargepackagemod.FN.RechargePackage.Id, Value: -1},
})
list, err := rechargepackagemod.FindManyRechargePackage(ctx, filter, opts)
if err != nil {
return nil, err
}
if len(list) == 0 {
return &proto.RechargePackageListResp{
VipList: make([]*proto.RechargePackageItem, 0),
CoinList: make([]*proto.RechargePackageItem, 0),
}, nil
}
channelMap := buildPayMethodMap(ctx, list)
resp := &proto.RechargePackageListResp{
VipList: make([]*proto.RechargePackageItem, 0),
CoinList: make([]*proto.RechargePackageItem, 0),
}
for _, item := range list {
data := rechargePackageToAppItem(item, channelMap[item.Price])
switch item.PackageType {
case rechargepackagemod.PackageTypeVip:
resp.VipList = append(resp.VipList, data)
case rechargepackagemod.PackageTypeCoin:
resp.CoinList = append(resp.CoinList, data)
}
}
return resp, nil
}
// buildPayMethodMap 按金额批量查询可用支付方式;查询失败时返回空映射,不影响套餐列表展示。
func buildPayMethodMap(ctx context.Context, list rechargepackagemod.RechargePackageList) map[int64][]proto.PayMethodInfo {
seen := map[int64]struct{}{}
moneys := make([]int64, 0)
for _, item := range list {
if item == nil || item.Price <= 0 {
continue
}
if _, ok := seen[item.Price]; ok {
continue
}
seen[item.Price] = struct{}{}
moneys = append(moneys, item.Price)
}
if len(moneys) == 0 {
return map[int64][]proto.PayMethodInfo{}
}
channels, serr := payCenter.GetPayChannelByMoney(ctx, moneys)
if serr != nil {
log.Error("rechargepackageser:查询支付通道失败", log.E(serr))
return map[int64][]proto.PayMethodInfo{}
}
channelMap := make(map[int64][]proto.PayMethodInfo, len(moneys))
for _, money := range moneys {
payTypes := channels.GetPayType(money)
methods := make([]proto.PayMethodInfo, 0, len(payTypes))
for _, payType := range payTypes {
methods = append(methods, proto.PayMethodInfo{Type: payType.Type, TypeName: payType.TypeName})
}
channelMap[money] = methods
}
return channelMap
}
// rechargePackageToAppItem 将购买套餐表数据转换为 App 展示字段
func rechargePackageToAppItem(item *rechargepackagemod.RechargePackage, payMethods []proto.PayMethodInfo) *proto.RechargePackageItem {
if payMethods == nil {
payMethods = []proto.PayMethodInfo{}
}
return &proto.RechargePackageItem{
Id: item.Id,
PackageType: item.PackageType,
Name: item.Name,
SubTitle: item.SubTitle,
Price: item.Price,
OriginalPrice: item.OriginalPrice,
DurationDays: item.DurationDays,
DurationHours: item.DurationDays * 24,
BaseCoin: item.BaseCoin,
GiftCoin: item.GiftCoin,
TotalCoin: item.BaseCoin + item.GiftCoin,
Sort: item.Sort,
PayMethods: payMethods,
}
}
+54
View File
@@ -0,0 +1,54 @@
package rechargeser
import (
"context"
"strconv"
"strings"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/payCenter"
"videoweb/pub/pubdef"
)
// PayDeviceID 支付中心要求 deviceId 非空。本次请求带了设备号就用本次的,否则用用户资料里保存的设备号。
func PayDeviceID(requestDeviceID, savedDeviceID, backupDeviceID string) string {
for _, value := range []string{requestDeviceID, savedDeviceID, backupDeviceID} {
if deviceID := strings.TrimSpace(value); deviceID != "" {
return deviceID
}
}
return ""
}
// SubmitPayOrder 向新支付中心下单。会员和金币订单共用同一协议,本地订单仍由调用方入库。
func SubmitPayOrder(ctx context.Context, userId int64, userName, tradeNo string, amount int64, payMode, sysType, devType, deviceID, ip string) (*pubdef.SubmitVipOrderInfo, *srvdef.SrvErr) {
deviceType := devType
if deviceType == "" {
deviceType = sysType
}
response, err := (&payCenter.Recharge{
Ctx: ctx,
TradeNo: tradeNo,
Money: payCenter.FenToYuan(amount),
Type: payMode,
Info: payCenter.PayInfo{
PlayerId: strconv.FormatInt(userId, 10),
PlayerIp: ip,
DeviceType: deviceType,
DeviceId: strings.TrimSpace(deviceID),
Name: userName,
},
}).ToPayNew()
if err != nil {
log.Error("新支付中心下单失败", log.Any("tradeNo", tradeNo), log.Any("userId", userId), log.E(err))
return nil, srvdef.New(srvdef.Err, "充值失败", err.Error())
}
return &pubdef.SubmitVipOrderInfo{
TradeNo: tradeNo,
Oid: response.OID,
PayUrl: response.PayUrl,
PayUrlMode: response.Mode,
PayRate: response.Rebate,
}, nil
}
+193
View File
@@ -0,0 +1,193 @@
package rechargeser
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/useragent"
"videoweb/pub/dataReport"
"videoweb/pub/models/eventmod"
"videoweb/pub/models/rechargepackagemod"
"videoweb/pub/models/usermod"
"videoweb/pub/models/vipordermod"
"videoweb/pub/mongos"
"videoweb/pub/pubdef"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// SubmitOrderResp 创建第三方支付订单的响应
type SubmitOrderResp struct {
TradeNo string `json:"tradeNo"` // 本地订单号(可用于查询)
PayUrl string `json:"payUrl"` // 支付链接
PayUrlMode string `json:"payUrlMode"` // 链接打开方式
IsOpenNewBrowser bool `json:"isOpenNewBrowser"` // 是否需要外部浏览器打开
}
// CreateOrder 创建第三方支付 VIP 订单。
// 防重复:15 秒内同一用户+同一套餐的 pending 订单直接返回原支付链接。
func CreateOrder(ctx context.Context,
userId int64, userName string,
packageId int64, payMode string,
sysType string, devType string, deviceID string, ip string,
districtCode string, sourcePageKey string, sourcePageName string,
) (*SubmitOrderResp, *srvdef.SrvErr) {
pkg, serr := rechargepackagemod.FindOneRechargePackage(ctx, bson.M{
rechargepackagemod.FN.RechargePackage.Id: packageId,
rechargepackagemod.FN.RechargePackage.PackageType: rechargepackagemod.PackageTypeVip,
rechargepackagemod.FN.RechargePackage.Status: rechargepackagemod.StatusEnabled,
})
if serr != nil || pkg == nil {
return nil, srvdef.New(srvdef.ErrNoData, "会员套餐不存在或已下架", "")
}
now := time.Now()
dupFilter := bson.M{
vipordermod.FN.VipOrder.UserId: userId,
vipordermod.FN.VipOrder.CardId: packageId,
vipordermod.FN.VipOrder.PayMode: payMode,
vipordermod.FN.VipOrder.Status: pubdef.VipOrderPending,
vipordermod.FN.VipOrder.CreatedAt: bson.M{"$gte": now.Add(-15 * time.Second)},
}
existing, _ := vipordermod.FindOneVipOrder(ctx, dupFilter)
if existing != nil && existing.PayUrl != "" {
return &SubmitOrderResp{
TradeNo: existing.TradeNo,
PayUrl: existing.PayUrl,
PayUrlMode: existing.PayUrlMode,
IsOpenNewBrowser: true,
}, nil
}
tradeNo := pubdef.GetTrandNoWithUUID()
amount := pkg.Price
payInfo, serr := SubmitPayOrder(ctx, userId, userName, tradeNo, amount, payMode, sysType, devType, deviceID, ip)
if serr != nil {
return nil, serr
}
order := &vipordermod.VipOrder{
Id: mongos.Lid.VipOrder.Get(),
OrderType: vipordermod.OrderTypeVip,
UserId: userId,
CardId: packageId, // 兼容历史字段,存购买套餐ID
CardName: pkg.Name,
Amount: amount,
PayMode: payMode,
TradeNo: tradeNo,
Oid: payInfo.Oid,
Status: pubdef.VipOrderPending,
PayUrl: payInfo.PayUrl,
PayUrlMode: payInfo.PayUrlMode,
PayChannel: payInfo.PayChannel,
PayRate: payInfo.PayRate,
IPAddr: ip,
SysType: sysType,
DistrictCode: strings.TrimSpace(districtCode),
Client: vipordermod.NewOrderClient(useragent.FromContext(ctx), sourcePageKey, sourcePageName),
CreatedAt: now,
UpdatedAt: now,
}
if serr = vipordermod.InsertOneVipOrder(ctx, order); serr != nil {
log.Error("rechargeser:CreateOrder InsertOneVipOrder failed",
log.Any("tradeNo", tradeNo), log.E(serr))
return nil, serr
}
go reportOrderCreated(ctx, order, pkg, userId, ip)
return &SubmitOrderResp{
TradeNo: tradeNo,
PayUrl: payInfo.PayUrl,
PayUrlMode: payInfo.PayUrlMode,
IsOpenNewBrowser: true,
}, nil
}
// GetOrderHistory 查询用户的 VIP 订单历史
func GetOrderHistory(ctx context.Context, userId int64, page, pageSize int64) (vipordermod.VipOrderList, int64, *srvdef.SrvErr) {
filter := bson.M{
vipordermod.FN.VipOrder.UserId: userId,
vipordermod.FN.VipOrder.OrderType: vipordermod.OrderTypeVip,
}
total, serr := vipordermod.CountVipOrder(ctx, filter)
if serr != nil {
return nil, 0, serr
}
skip := (page - 1) * pageSize
opts := options.Find().
SetSort(bson.M{vipordermod.FN.VipOrder.CreatedAt: -1}).
SetSkip(skip).
SetLimit(pageSize)
list, serr := vipordermod.FindManyVipOrder(ctx, filter, opts)
return list, total, serr
}
// GetUserInfo 获取用户信息(供 app 层展示当前 VIP 状态)
func GetUserInfo(ctx context.Context, userId int64) (*usermod.User, *srvdef.SrvErr) {
return usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
}
func reportOrderCreated(ctx context.Context, order *vipordermod.VipOrder, pkg *rechargepackagemod.RechargePackage, userId int64, ip string) {
user, _ := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
districtCode := ""
if user != nil {
districtCode = user.DistrictCode
}
sourceKey, sourceName := "deposit", "金币充值"
if order.Client != nil {
if value := strings.TrimSpace(order.Client.SourcePageKey); value != "" {
sourceKey = value
}
if value := strings.TrimSpace(order.Client.SourcePageName); value != "" {
sourceName = value
}
}
ua := order.Client.UA(order.SysType)
eventId := newEventId(pubdef.TrackerAppId(), user.Id, order.TradeNo, order.CreatedAt.Unix())
commonField := dataReport.NewCommonField(eventId, dataReport.EventTypeOrderCreated, districtCode, ua, strconv.FormatInt(user.Id, 10), ip)
commonField.Payload = dataReport.JsonRawMessage(&dataReport.OrderCreatedEvent{
OrderID: order.TradeNo,
OrderType: "vip_subscription",
ProductID: strconv.FormatInt(pkg.Id, 10),
ProductName: pkg.Name,
Amount: dataReport.JsonNumber(order.Amount),
Currency: "CNY",
SourcePageKey: sourceKey,
SourcePageName: sourceName,
CreateTime: dataReport.JsonNumber(order.CreatedAt.Unix()),
})
eventData, _ := json.Marshal(commonField)
if err := eventmod.InsertOneEvent(ctx, &eventmod.Event{
EventId: commonField.EventID,
EventType: commonField.Event,
Data: string(eventData),
Status: eventmod.Default,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
log.Error("reportOrderCreated InsertOneEvent failed",
log.Any("tradeNo", order.TradeNo), log.E(err))
}
}
func newEventId(appId string, uid int64, orderId interface{}, createTime int64) string {
input := fmt.Sprintf("%v%v%v%v", appId, uid, orderId, createTime)
h := md5.New()
h.Write([]byte(input))
return hex.EncodeToString(h.Sum(nil))
}
+559
View File
@@ -0,0 +1,559 @@
package reportser
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"videoweb/app/service/playletcache"
"videoweb/pub/common"
"videoweb/pub/common/enum/consts"
"videoweb/pub/common/ipaddr"
"videoweb/pub/common/log"
"videoweb/pub/common/useragent"
"videoweb/pub/dataReport"
"videoweb/pub/models/eventmod"
"videoweb/pub/models/playletchaptermod"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/usermod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
videoContentTypeVideo = "video"
)
// ReportUserRegister 用户注册事件
func ReportUserRegister(user *usermod.User, ua useragent.UA, ip, registerType string) {
if user == nil {
return
}
if registerType == "" {
registerType = "deviceid"
}
ua = fillUserUA(user, ua)
createAt := user.CreatedAt
if !user.RegisterTime.IsZero() {
createAt = user.RegisterTime
}
eventId := newEventId(pubdef.TrackerAppId(), user.Id, "", createAt.Unix())
event := dataReport.NewCommonField(eventId, dataReport.EventTypeUserRegister, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip)
event.Payload = dataReport.JsonRawMessage(&dataReport.UserRegisterEvent{
Type: registerType,
CreateTime: dataReport.JsonNumber(createAt.Unix()),
})
insertEvent(event)
}
// ReportUserLogin 用户登录事件
func ReportUserLogin(user *usermod.User, ua useragent.UA, ip string, loginType pubdef.LoginType) {
if user == nil {
return
}
ua = fillUserUA(user, ua)
createTime := time.Now().Unix()
eventId := newEventId(pubdef.TrackerAppId(), user.Id, "", createTime)
event := dataReport.NewCommonField(eventId, dataReport.EventTypeUserLogin, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip)
event.Payload = dataReport.JsonRawMessage(&dataReport.UserLoginEvent{
Type: loginTypeEventValue(loginType),
})
insertEvent(event)
}
// ReportPlayletLike 短剧点赞
func ReportPlayletLike(ctx context.Context, userId, playletId, flag int64) {
reportDramaEvent(ctx, userId, playletId, 0, dataReport.EventTypeDramaLike, flag, "")
}
// ReportVideoLike 视频点赞
func ReportVideoLike(ctx context.Context, userId, videoId, flag int64) {
reportVideoEvent(ctx, userId, videoId, dataReport.EventTypeVideoLike, flag)
}
// ReportPlayletCollect 短剧收藏
func ReportPlayletCollect(ctx context.Context, userId, playletId, flag int64) {
reportDramaEvent(ctx, userId, playletId, 0, dataReport.EventTypeDramaCollect, flag, "")
}
// ReportVideoCollect 视频收藏
func ReportVideoCollect(ctx context.Context, userId, videoId, flag int64) {
reportVideoEvent(ctx, userId, videoId, dataReport.EventTypeVideoCollect, flag)
}
// ReportVideoComment 视频评论
func ReportVideoComment(ctx context.Context, user *usermod.User, videoId int64, content string) {
if user == nil || videoId <= 0 {
return
}
pub := buildVideoPub(ctx, videoId)
if pub.VideoID == "" {
return
}
ua, ip := uaAndIPFromCtx(ctx)
ua = fillUserUA(user, ua)
event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), dataReport.EventTypeVideoComment, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip)
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoCommentEvent{
VideoEventPub: pub,
CommentContent: content,
})
insertEvent(event)
}
// ReportPlayletChapterLike 短剧剧集点赞
func ReportPlayletChapterLike(ctx context.Context, userId, playletId, chapterId, flag int64) {
reportDramaEvent(ctx, userId, playletId, chapterId, dataReport.EventTypeDramaLike, flag, "")
}
// ReportPlayletChapterCollect 短剧剧集收藏
func ReportPlayletChapterCollect(ctx context.Context, userId, playletId, chapterId, flag int64) {
reportDramaEvent(ctx, userId, playletId, chapterId, dataReport.EventTypeDramaCollect, flag, "")
}
// ReportPlayletComment 短剧/剧集评论
func ReportPlayletComment(ctx context.Context, user *usermod.User, playletId, chapterId int64, content string) {
if user == nil || playletId <= 0 {
return
}
pub := buildDramaPub(ctx, playletId, chapterId)
if pub.DramaID == "" {
return
}
ua, ip := uaAndIPFromCtx(ctx)
ua = fillUserUA(user, ua)
event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), dataReport.EventTypeDramaComment, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip)
event.Payload = dataReport.JsonRawMessage(&dataReport.DramaCommentEvent{
DramaEventPub: pub,
CommentContent: content,
})
insertEvent(event)
}
// ReportPlayletUnlock 短剧剧集金币解锁:同时上报金币消耗和购买
func ReportPlayletUnlock(ctx context.Context, user *usermod.User, playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter, amount, balanceBefore, balanceAfter int64, orderNo string) {
if user == nil || playlet == nil || chapter == nil || amount <= 0 {
return
}
if full, err := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: user.Id}); err == nil && full != nil {
user = full
}
ua, ip := uaAndIPFromCtx(ctx)
ua = fillUserUA(user, ua)
productId := strconv.FormatInt(chapter.Id, 10)
productName := playletUnlockProductName(playlet, chapter)
reportCoinConsume(user, ua, ip, productId, productName, amount, balanceBefore, balanceAfter, KeyPlayletUnlock, orderNo)
reportDramaPurchase(ctx, user, ua, ip, playlet, chapter, amount, orderNo)
}
// ReportPlayletStatusChange 短剧上下架,复用 video_status_change
func ReportPlayletStatusChange(playlet *playletmod.Playlet, flag int64) {
if playlet == nil || playlet.Id == 0 || flag == 0 {
return
}
typeID, typeName := resolvePlayletType(playlet)
event := dataReport.NewCommonField("", dataReport.EventTypeVideoStatusChange, "", useragent.UA{}, "", "")
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoStatusChangeEvent{
MediaId: playletMediaId(playlet),
VideoID: strconv.FormatInt(playlet.Id, 10),
VideoTitle: playlet.Title,
VideoTypeID: typeID,
VideoTypeName: typeName,
Flag: dataReport.JsonNumber(flag),
})
insertEvent(event)
}
// PlayletStatusFlag 短剧上下架映射上报 flag:1上架 2下架
func PlayletStatusFlag(status playletmod.Status) int64 {
switch status {
case playletmod.StatusUp:
return 1
case playletmod.StatusDown:
return 2
default:
return 0
}
}
func reportDramaEvent(ctx context.Context, userId, playletId, chapterId int64, eventType string, flag int64, content string) {
if userId <= 0 || playletId <= 0 {
return
}
user, err := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
if err != nil || user == nil {
return
}
pub := buildDramaPub(ctx, playletId, chapterId)
if pub.DramaID == "" {
return
}
ua, ip := uaAndIPFromCtx(ctx)
ua = fillUserUA(user, ua)
event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), eventType, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip)
switch eventType {
case dataReport.EventTypeDramaLike:
event.Payload = dataReport.JsonRawMessage(&dataReport.DramaLikeEvent{
DramaEventPub: pub,
Flag: dataReport.JsonNumber(flag),
})
case dataReport.EventTypeDramaCollect:
event.Payload = dataReport.JsonRawMessage(&dataReport.DramaCollectEvent{
DramaEventPub: pub,
Flag: dataReport.JsonNumber(flag),
})
case dataReport.EventTypeDramaComment:
event.Payload = dataReport.JsonRawMessage(&dataReport.DramaCommentEvent{
DramaEventPub: pub,
CommentContent: content,
})
default:
return
}
insertEvent(event)
}
func reportVideoEvent(ctx context.Context, userId, videoId int64, eventType string, flag int64) {
if userId <= 0 || videoId <= 0 {
return
}
user, err := usermod.FindOneUser(ctx, bson.M{usermod.FN.User.Id: userId})
if err != nil || user == nil {
return
}
pub := buildVideoPub(ctx, videoId)
if pub.VideoID == "" {
return
}
ua, ip := uaAndIPFromCtx(ctx)
ua = fillUserUA(user, ua)
event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), eventType, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip)
switch eventType {
case dataReport.EventTypeVideoLike:
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoLikeEvent{
VideoEventPub: pub,
Flag: dataReport.JsonNumber(flag),
})
case dataReport.EventTypeVideoCollect:
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoCollectEvent{
VideoEventPub: pub,
Flag: dataReport.JsonNumber(flag),
})
default:
return
}
insertEvent(event)
}
func reportCoinConsume(user *usermod.User, ua useragent.UA, ip, productId, productName string, amount, balanceBefore, balanceAfter int64, reason Key, orderId string) {
if user == nil || amount == 0 {
return
}
if productId == "" {
if productName == "" {
productName = "金币消耗"
}
productId = md5String(productName)
}
createTime := time.Now().Unix()
eventId := newEventId(pubdef.TrackerAppId(), user.Id, orderId, createTime)
event := dataReport.NewCommonField(eventId, dataReport.EventTypeCoinConsume, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip)
event.Payload = dataReport.JsonRawMessage(&dataReport.CoinConsumeEvent{
ProductID: productId,
ProductName: productName,
CoinConsumeAmount: dataReport.JsonNumber(amount),
CoinBalanceBefore: dataReport.JsonNumber(balanceBefore),
CoinBalanceAfter: dataReport.JsonNumber(balanceAfter),
ConsumeReasonKey: reason.Key(),
ConsumeReasonName: reason.Name(),
CreateTime: dataReport.JsonNumber(createTime),
OrderId: orderId,
})
insertEvent(event)
}
func reportDramaPurchase(ctx context.Context, user *usermod.User, ua useragent.UA, ip string, playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter, amount int64, orderId string) {
pub := dataReport.DramaEventPub{}
if playlet != nil && chapter != nil && chapter.Id > 0 {
pub = buildDramaPub(ctx, playlet.Id, chapter.Id)
}
if pub.DramaID == "" {
pub = playletToDramaPub(playlet, chapter)
}
if pub.DramaID == "" {
return
}
event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), dataReport.EventTypeDramaPurchase, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip)
event.Payload = dataReport.JsonRawMessage(&dataReport.DramaPurchaseEvent{
DramaEventPub: pub,
CoinQuantity: dataReport.JsonNumber(amount),
OrderID: orderId,
UnlockType: dataReport.DramaUnlockTypeCoin,
})
insertEvent(event)
}
func buildDramaPub(ctx context.Context, playletId, chapterId int64) dataReport.DramaEventPub {
playlet, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: playletId}, options.FindOne().SetProjection(bson.D{
{Key: playletmod.FN.Playlet.Id, Value: 1},
{Key: playletmod.FN.Playlet.Title, Value: 1},
{Key: playletmod.FN.Playlet.ThemeIds, Value: 1},
{Key: playletmod.FN.Playlet.TagIds, Value: 1},
{Key: playletmod.FN.Playlet.TotalEpisode, Value: 1},
}))
if err != nil || playlet == nil {
return dataReport.DramaEventPub{}
}
chapter := findDramaReportChapter(ctx, playletId, chapterId)
return playletToDramaPub(playlet, chapter)
}
// findDramaReportChapter 取上报用剧集。指定了剧集就用该集,短剧维度事件用第一集,保证 drama_id、video_id 都来自剧集。
func findDramaReportChapter(ctx context.Context, playletId, chapterId int64) *playletchaptermod.PlayletChapter {
projection := options.FindOne().SetProjection(bson.D{
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.MediaContentId, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.MediaTime, Value: 1},
})
if chapterId > 0 {
chapter, _ := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: chapterId}, projection)
return chapter
}
chapter, _ := playletchaptermod.FindOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.PlayletId: playletId}, projection.SetSort(bson.D{
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
}))
return chapter
}
func buildVideoPub(ctx context.Context, videoId int64) dataReport.VideoEventPub {
video, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, bson.M{videoinformationmod.FN.VideoInformation.Id: videoId}, options.FindOne().SetProjection(bson.D{
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Title, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.MediaType, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.FileId, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.Video.TaskId, Value: 1},
{Key: videoinformationmod.FN.VideoInformation.SyncId, Value: 1},
}))
if err != nil || video == nil {
return dataReport.VideoEventPub{}
}
return videoToVideoPub(video)
}
func videoToVideoPub(video *videoinformationmod.VideoInformation) dataReport.VideoEventPub {
if video == nil {
return dataReport.VideoEventPub{}
}
mediaId := video.Video.FileId
if mediaId == "" {
mediaId = video.Video.TaskId
}
if mediaId == "" {
mediaId = video.SyncId
}
return dataReport.VideoEventPub{
MediaId: mediaId,
VideoID: strconv.FormatInt(video.Id, 10),
VideoTitle: video.Title,
VideoTypeID: strconv.FormatInt(int64(video.MediaType), 10),
VideoTypeName: videoMediaTypeName(video.MediaType),
VideoContentType: videoContentTypeVideo,
}
}
func playletToDramaPub(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) dataReport.DramaEventPub {
if playlet == nil {
return dataReport.DramaEventPub{}
}
typeID, typeName := resolvePlayletType(playlet)
tagKey, tagName := resolvePlayletTags(playlet.TagIds)
episode := int64(1)
duration := int64(0)
dramaID := ""
videoID := ""
if chapter != nil && chapter.Id > 0 {
if chapter.CurrentEpisode > 0 {
episode = chapter.CurrentEpisode
}
duration = chapter.MediaTime
dramaID = strconv.FormatInt(chapter.Id, 10)
if chapter.MediaContentId > 0 {
videoID = strconv.FormatUint(uint64(chapter.MediaContentId), 10)
}
}
return dataReport.DramaEventPub{
DramaID: dramaID,
DramaTitle: playlet.Title,
Episode: dataReport.JsonNumber(episode),
VideoID: videoID,
TotalEpisode: dataReport.JsonNumber(playlet.TotalEpisode),
DramaTypeID: typeID,
DramaTypeName: typeName,
DramaContentType: dataReport.DramaContentTypeShortDrama,
DramaTagKey: tagKey,
DramaTagName: tagName,
DramaDuration: dataReport.JsonNumber(duration),
}
}
func videoMediaTypeName(mediaType videoinformationmod.MediaType) string {
switch mediaType {
case videoinformationmod.MediaType_Video:
return "视频"
case videoinformationmod.MediaType_Collection:
return "合集"
case videoinformationmod.MediaType_Advitise:
return "广告"
case videoinformationmod.MediaType_VideoV2:
return "视频"
default:
return "视频"
}
}
func playletUnlockProductName(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) string {
title := ""
if playlet != nil {
title = playlet.Title
}
if chapter == nil {
return title
}
if chapter.Title != "" {
if title == "" {
return chapter.Title
}
return fmt.Sprintf("%s %s", title, chapter.Title)
}
if chapter.CurrentEpisode > 0 {
if title == "" {
return fmt.Sprintf("第%d集", chapter.CurrentEpisode)
}
return fmt.Sprintf("%s 第%d集", title, chapter.CurrentEpisode)
}
return title
}
func playletMediaId(playlet *playletmod.Playlet) string {
if playlet == nil || playlet.MediaCenterId == 0 {
return ""
}
return strconv.FormatUint(uint64(playlet.MediaCenterId), 10)
}
func resolvePlayletType(playlet *playletmod.Playlet) (string, string) {
if playlet == nil || len(playlet.ThemeIds) == 0 {
return "default", "默认分类"
}
id := playlet.ThemeIds[0]
if id <= 0 {
return "default", "默认分类"
}
name := strings.TrimSpace(playletcache.ThemeNameMap([]int64{id})[id])
if name == "" {
name = "默认分类"
}
return strconv.FormatInt(id, 10), name
}
func resolvePlayletTags(ids []int64) (string, string) {
keys := make([]string, 0, len(ids))
for _, id := range ids {
if id > 0 {
keys = append(keys, strconv.FormatInt(id, 10))
}
}
if len(keys) == 0 {
return "default", "默认标签"
}
nameMap := playletcache.TagNameMap(ids)
names := make([]string, 0, len(keys))
for _, id := range ids {
if id <= 0 {
continue
}
name := strings.TrimSpace(nameMap[id])
if name == "" {
name = strconv.FormatInt(id, 10)
}
names = append(names, name)
}
return strings.Join(keys, ","), strings.Join(names, ",")
}
func uaAndIPFromCtx(ctx context.Context) (useragent.UA, string) {
gc, ok := ctx.(*gin.Context)
if !ok {
if v := ctx.Value(consts.CtxUA); v != nil {
if ua, ok := v.(useragent.UA); ok {
return ua, ""
}
}
return useragent.UA{}, ""
}
ua, _ := common.GetUA(gc)
return ua, ipaddr.ResolveClientIP(gc)
}
func fillUserUA(user *usermod.User, ua useragent.UA) useragent.UA {
if user == nil {
return ua
}
if ua.DevID == "" {
ua.DevID = user.DevID
}
if ua.DevType == "" {
ua.DevType = user.Device
}
if ua.SysType == "" {
ua.SysType = user.SysType
}
return ua
}
func loginTypeEventValue(loginType pubdef.LoginType) string {
switch loginType {
case pubdef.LoginTypeAccount:
return "username"
case pubdef.LoginTypeEmail:
return "email"
default:
return "deviceid"
}
}
func newEventId(appId string, uid int64, orderId interface{}, createTime int64) string {
input := fmt.Sprintf("%v%v%v%v", appId, uid, orderId, createTime)
return md5String(input)
}
func md5String(input string) string {
h := md5.New()
h.Write([]byte(input))
return hex.EncodeToString(h.Sum(nil))
}
func insertEvent(event dataReport.CommonField) {
b, _ := json.Marshal(event)
if err := eventmod.InsertOneEvent(context.Background(), &eventmod.Event{
EventId: event.EventID,
EventType: event.Event,
Data: string(b),
Status: eventmod.Default,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
log.Error("reportser insertEvent failed", log.Any("eventType", event.Event), log.Any("eventId", event.EventID), log.E(err))
}
}
+21
View File
@@ -0,0 +1,21 @@
package reportser
type Key string
const (
KeyPlayletUnlock Key = "playlet_unlock" // 短剧剧集解锁
KeyCoinConsume Key = "coin_consume" // 金币消耗
)
var keyMap = map[Key]string{
KeyPlayletUnlock: "短剧解锁",
KeyCoinConsume: "金币消耗",
}
func (p Key) Name() string {
return keyMap[p]
}
func (p Key) Key() string {
return string(p)
}
+1
View File
@@ -0,0 +1 @@
package searchser
+61
View File
@@ -0,0 +1,61 @@
package searchser
import (
"context"
"time"
"videoweb/app/api/proto"
"videoweb/app/service/configser"
"videoweb/app/service/translationser"
"videoweb/pub/pubdef"
"github.com/patrickmn/go-cache"
)
var (
Cache *cache.Cache
)
func init() {
Cache = cache.New(5*time.Minute, 5*time.Minute)
}
func Search(ctx context.Context, title string, mediaType int64, page int64, sort string) *proto.SearchResp {
resp := &proto.SearchResp{
TotalSize: 0,
Sort: make([]proto.SearchSortItem, 0),
SearchRecommend: make([]string, 0),
PageNum: page,
PageSize: pubdef.PageSize,
}
resp.InTitle = title
resp.InType = mediaType
resp.Sort = getSearchSort()
resp.SearchRecommend = getSearchRecommend(title)
resp.Config = configser.GetConfigInfo()
resp.Translations = translationser.GetTranslationData()
resp.BottonTranslations = translationser.GetTranslationBottomList()
resp.TotalSize = pubdef.GetTotalPage(resp.TotalSize, pubdef.PageSize)
return resp
}
func getSearchRecommend(title string) []string {
return make([]string, 0)
}
func getSearchSort() []proto.SearchSortItem {
resp := make([]proto.SearchSortItem, 0)
resp = append(resp, proto.SearchSortItem{
Key: string(proto.SearchSortTypeDefault),
Title: "相关度",
})
resp = append(resp, proto.SearchSortItem{
Key: string(proto.SearchSortTypeHot),
Title: "热度最高",
})
resp = append(resp, proto.SearchSortItem{
Key: string(proto.SearchSortTypeNew),
Title: "最新发布",
})
return resp
}
+173
View File
@@ -0,0 +1,173 @@
package translationser
import (
"context"
"sync"
"videoweb/app/api/proto"
"videoweb/pub/common/log"
"videoweb/pub/common/netutil"
"videoweb/pub/models/cfg"
"videoweb/pub/models/translationcategorymod"
"videoweb/pub/models/translationmod"
"videoweb/pub/pubdef"
"github.com/robfig/cron/v3"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
var (
CacheMgr CacheInfo //
skdCron *cron.Cron
)
type CacheInfo struct {
Lock sync.RWMutex // 读写锁
TranslationData proto.AppTranslationInfo // 导航信息缓存
BottomTranslationData proto.AppTranslationInfo // 底部导航信息缓存
TranslationMap map[int64]proto.TranslationInfo
}
func init() {
skdCron = cron.New(cron.WithSeconds()) // 秒级定时任务
CacheMgr.TranslationData = proto.AppTranslationInfo{}
CacheMgr.BottomTranslationData = proto.AppTranslationInfo{}
CacheMgr.TranslationMap = make(map[int64]proto.TranslationInfo)
}
func Start() {
ctx := context.Background()
UpdateTranslationInfo(ctx)
_, _ = skdCron.AddFunc("0 */5 * * * ?", func() {
UpdateTranslationInfo(ctx)
})
skdCron.Start()
}
func UpdateTranslationInfo(ctx context.Context) {
filter := bson.M{cfg.FN.SysCfg.Id: 1}
sysCfg, _ := cfg.FindOneSysCfg(context.Background(), filter)
if sysCfg == nil {
return
}
translation := proto.AppTranslationInfo{}
bottomTranslation := proto.AppTranslationInfo{}
wg := &sync.WaitGroup{}
wg.Add(2)
var translationCategoryList translationcategorymod.TranslationCategoryList
var translationList translationmod.TranslationInfoList
go func() {
defer wg.Done()
categoryFilter := bson.M{
translationcategorymod.FN.TranslationCategory.Status: true,
}
categoryOpt := &options.FindOptions{}
categoryOpt.SetLimit(1000).SetSort(bson.M{translationcategorymod.FN.TranslationCategory.Rank: -1})
translationCategoryList, _ = translationcategorymod.FindManyTranslationCategorys(ctx, categoryFilter, categoryOpt)
}()
go func() {
defer wg.Done()
translationFilter := bson.M{
translationmod.FN.TranslationInfo.Status: true,
}
translationOpt := &options.FindOptions{}
translationOpt.SetLimit(1000).SetSort(bson.M{translationmod.FN.TranslationInfo.Rank: -1})
translationList, _ = translationmod.FindManyTranslationInfos(ctx, translationFilter, translationOpt)
}()
wg.Wait()
tempTranslationMap := make(map[int64]proto.TranslationInfo)
for _, categoryInfo := range translationCategoryList {
categoryTemp := proto.TranslationCategory{
ID: categoryInfo.Id,
Name: categoryInfo.Name,
Icon: netutil.MergeURL(sysCfg.ImgAppCdn, categoryInfo.Icon),
TranslationList: make([]proto.TranslationInfo, 0),
CategoryType: categoryInfo.CategoryType,
SeoInfo: categoryInfo.SeoInfo,
}
if categoryTemp.CategoryType == pubdef.TranslationTypeUrl {
categoryTemp.UrlValue = categoryInfo.UrlValue
} else if categoryInfo.CategoryType == pubdef.TranslationTypeSub {
for _, translationInfo := range translationList {
if categoryInfo.Id == translationInfo.TransCategoryId {
translationTmp := proto.TranslationInfo{
ID: translationInfo.Id,
Name: translationInfo.Name,
}
if translationInfo.IsFriendAds {
translationTmp.IsFriendAds = true
// 友链广告 获取的时候添加
// TODO:顶部暂不支持 。
continue
} else {
translationTmp.UrlValue = translationInfo.Link
}
if translationTmp.UrlValue == "" {
continue
}
tempTranslationMap[translationInfo.Id] = translationTmp
categoryTemp.TranslationList = append(categoryTemp.TranslationList, translationTmp)
}
}
} else if categoryInfo.CategoryType == pubdef.TranslationTypeBottom {
for _, translationInfo := range translationList {
if categoryInfo.Id == translationInfo.TransCategoryId {
translationTmp := proto.TranslationInfo{
ID: translationInfo.Id,
Name: translationInfo.Name,
}
if translationInfo.IsFriendAds {
translationTmp.IsFriendAds = true
// 友链广告 获取的时候添加
} else {
translationTmp.UrlValue = translationInfo.Link
}
tempTranslationMap[translationInfo.Id] = translationTmp
categoryTemp.TranslationList = append(categoryTemp.TranslationList, translationTmp)
}
}
bottomTranslation.CategoryList = append(bottomTranslation.CategoryList, categoryTemp)
continue
} else {
continue
}
translation.CategoryList = append(translation.CategoryList, categoryTemp)
}
log.Info("", log.Any("categoryInfo.Name", translation.CategoryList))
CacheMgr.Lock.Lock()
defer CacheMgr.Lock.Unlock()
CacheMgr.TranslationData = translation
CacheMgr.BottomTranslationData = bottomTranslation
CacheMgr.TranslationMap = tempTranslationMap
}
func GetTranslationData() proto.AppTranslationInfo {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
log.Info("GetTranslationData", log.Any("TranslationData", CacheMgr.TranslationData))
return CacheMgr.TranslationData
}
func GetTranslationBottomList() proto.AppTranslationInfo {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
return CacheMgr.BottomTranslationData
}
func GetTranslationInfo(id int64) *proto.TranslationInfo {
CacheMgr.Lock.RLock()
defer CacheMgr.Lock.RUnlock()
if v, ok := CacheMgr.TranslationMap[id]; ok {
return &v
}
return nil
}
@@ -0,0 +1 @@
package translationser
+127
View File
@@ -0,0 +1,127 @@
package uploadser
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"strconv"
"videoweb/app/service/configser"
"videoweb/pub/common/log"
"videoweb/pub/pubdef"
"videoweb/pub/storage/proxy"
)
const (
// 图片上传路径
imagePath = "/media/web/autopublish/image"
)
// ImageUploadResp 图片上传响应
type ImageUploadResp struct {
Code int `json:"code"`
Data ImageUploadData `json:"data"`
Hash bool `json:"hash"`
Msg string `json:"msg"`
Time string `json:"time"`
Tip string `json:"tip"`
}
type ImageUploadData struct {
Id string `json:"id"`
SourceNameFs string `json:"sourceNameFs"`
}
// ImageParam 图片上传入参
type ImageParam struct {
AppId string
Watermark int64
FileHeader *multipart.FileHeader
}
// targetDomain 获取转发目标域名
func targetDomain() string {
return proxy.DomainAddressRepair("", proxy.BucketVideoAddressV2)
}
// ForwardImage 转发图片上传
func ForwardImage(param *ImageParam) ([]byte, error) {
if param.AppId == "" {
param.AppId = strconv.Itoa(pubdef.AppId)
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if err := mw.WriteField("appId", param.AppId); err != nil {
return nil, err
}
if param.Watermark == 1 {
watermarkImage := configser.GetConfigInfo().WatermarkImage
if err := mw.WriteField("watermarkImage", watermarkImage); err != nil {
return nil, err
}
}
if param.FileHeader != nil {
if err := writeFileField(mw, "file", param.FileHeader); err != nil {
return nil, err
}
}
if err := mw.Close(); err != nil {
return nil, err
}
url := targetDomain() + imagePath
return doMultipartPost(url, mw.FormDataContentType(), &buf)
}
// writeFileField 将 multipart.FileHeader 写入目标 multipart.Writer
func writeFileField(mw *multipart.Writer, fieldName string, fh *multipart.FileHeader) error {
src, err := fh.Open()
if err != nil {
return fmt.Errorf("open file: %w", err)
}
defer src.Close()
// 保留原始文件名和 Content-Type
h := make(textproto.MIMEHeader)
contentType := fh.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fh.Filename))
h.Set("Content-Type", contentType)
part, err := mw.CreatePart(h)
if err != nil {
return fmt.Errorf("create part: %w", err)
}
if _, err = io.Copy(part, src); err != nil {
return fmt.Errorf("copy file data: %w", err)
}
return nil
}
// doMultipartPost 发送 multipart/form-data 请求并返回响应 body
func doMultipartPost(url, contentType string, body io.Reader) ([]byte, error) {
req, err := http.NewRequest(http.MethodPost, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)
cli := &http.Client{}
resp, err := cli.Do(req)
if err != nil {
log.Error("uploadser:doMultipartPost failed", log.Any("url", url), log.E(err))
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
+61
View File
@@ -0,0 +1,61 @@
package usercenterser
import (
"context"
"videoweb/app/api/proto"
"videoweb/pub/common/log"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/playletchapterunlockmod"
"videoweb/pub/models/playletcollectmod"
"videoweb/pub/models/playletwatchhistorymod"
"videoweb/pub/models/playletwatchprogressmod"
"go.mongodb.org/mongo-driver/bson"
)
// Data 查询app个人中心数据
func Data(ctx context.Context, userId int64) (*proto.UserCenterDataResp, *srvdef.SrvErr) {
if userId <= 0 {
return nil, srvdef.New(srvdef.InvalidToken, "请先登录", "请先登录")
}
watchCount, err := playletwatchhistorymod.CountPlayletWatchHistory(ctx, bson.M{
playletwatchhistorymod.FN.PlayletWatchHistory.UserId: userId,
})
if err != nil {
log.Error("统计个人中心观看数量失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
collectCount, err := playletcollectmod.CountPlayletCollect(ctx, bson.M{
playletcollectmod.FN.PlayletCollect.UserId: userId,
})
if err != nil {
log.Error("统计个人中心收藏数量失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
chasingCount, err := playletwatchprogressmod.CountPlayletWatchProgress(ctx, bson.M{
playletwatchprogressmod.FN.PlayletWatchProgress.UserId: userId,
playletwatchprogressmod.FN.PlayletWatchProgress.IsChasing: true,
})
if err != nil {
log.Error("统计个人中心追剧中数量失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
boughtChapterCount, err := playletchapterunlockmod.CountPlayletChapterUnlock(ctx, bson.M{
playletchapterunlockmod.FN.PlayletChapterUnlock.UserId: userId,
})
if err != nil {
log.Error("统计个人中心已购剧集数量失败", log.Any("用户ID", userId), log.E(err))
return nil, err
}
return &proto.UserCenterDataResp{
WatchCount: watchCount,
CollectCount: collectCount,
ChasingCount: chasingCount,
BoughtChapterCount: boughtChapterCount,
}, nil
}
+60
View File
@@ -0,0 +1,60 @@
package videoser
import (
"strings"
"videoweb/app/api/proto"
"videoweb/app/service/configser"
"videoweb/pub/models/videoinformationmod"
)
func toVideoInfoData(videoInfo *videoinformationmod.VideoInformation, imgCdn string) proto.VideoInfoData {
data := proto.VideoInfoToData(videoInfo, imgCdn)
normalizeVideoInfoH265Paths(&data)
configser.ApplyVideoH265Policy(&data)
return data
}
func applyVideoUrlAuth(resp *proto.VideoInfoData, videoId int64, ip, ua string, userId int64) {
normalizeVideoInfoH265Paths(resp)
configser.ApplyVideoH265Policy(resp)
if len(resp.Video.Url) > 0 {
resp.Video.Url = GetM3U8AuthParam(videoId, resp.Video.Url, ip, ua, userId)
}
if len(resp.Video.H265Url) > 0 {
resp.Video.H265Url = GetM3U8AuthParam(videoId, resp.Video.H265Url, ip, ua, userId)
}
for i, node := range resp.Nodes {
for j, video := range node.Videos {
if len(video.Url) > 0 {
video.Url = GetM3U8AuthParam(videoId, video.Url, ip, ua, userId)
}
if len(video.H265Url) > 0 {
video.H265Url = GetM3U8AuthParam(videoId, video.H265Url, ip, ua, userId)
}
node.Videos[j] = video
}
resp.Nodes[i] = node
}
}
func normalizeVideoInfoH265Paths(data *proto.VideoInfoData) {
if data == nil {
return
}
data.Video.H265Url = normalizeLaosijiHEVCPath(data.Video.H265Url)
for i := range data.Nodes {
for j := range data.Nodes[i].Videos {
data.Nodes[i].Videos[j].H265Url = normalizeLaosijiHEVCPath(data.Nodes[i].Videos[j].H265Url)
}
}
}
func normalizeLaosijiHEVCPath(path string) string {
path = strings.TrimSpace(path)
path = strings.TrimPrefix(path, "laosiji/m3m/")
if path != "" && !strings.HasPrefix(path, "laosiji/") {
return "laosiji/" + path
}
return path
}
+255
View File
@@ -0,0 +1,255 @@
package videoser
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"videoweb/pub/common/crypt"
"videoweb/pub/common/enum/redisconst"
"videoweb/pub/common/ipaddr"
"videoweb/pub/common/log"
"videoweb/pub/models/usermod"
"videoweb/pub/redis"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
h5M3U8TicketTTLSeconds = int64(600)
h5M3U8TicketKeyVersion = "v1"
h5M3U8TicketBindIP = true
h5M3U8RoutePrefix = "/h5/m3u8/"
hardcodedH5M3U8TicketKey = "3d058714838683a97c04d7b6ab609fc196f29e55df36197341d87325dc63154a"
H5M3U8FallbackPath = "/v3/av/w0/w6/db/7j/029e2f2df53d4741ad39b471e2ad1806.m3u8"
)
// H5M3U8Ticket 记录业务接口已确认的正片/预览权限,供 m3u8 接口可信计数。
type H5M3U8Ticket struct {
MediaID int64 `json:"mediaId"`
Path string `json:"path"`
ExpireAt int64 `json:"expireAt"`
IPHash string `json:"ipHash,omitempty"`
UserId int64 `json:"userId,omitempty"` // 签发时登录用户,供会员日限识别
VipExpireAt int64 `json:"vipExpireAt,omitempty"` // 签发时会员过期时间(unix),播放侧据此判断会员日限,避免查库
Playable bool `json:"playable"`
Preview bool `json:"preview"`
UseNewKey bool `json:"useNewKey,omitempty"`
KeyVersion string `json:"keyVersion,omitempty"`
}
func h5M3U8TicketKey() string {
return hardcodedH5M3U8TicketKey
}
// BuildH5M3U8LegacyURL 返回不带路由前缀的相对票据地址,兼容 H5 自行拼接 /h5/m3u8/。
func BuildH5M3U8LegacyURL(mediaID int64, path, ip string, playable, preview bool, userId int64) string {
return buildH5M3U8LegacyURL(mediaID, path, ip, playable, preview, false, userId)
}
// BuildH5M3U8PlayletURL 为短剧播放地址生成票据,并在票据中记录原片密钥类型。
func BuildH5M3U8PlayletURL(mediaID int64, path, ip string, useNewKey bool, userId int64) string {
return buildH5M3U8LegacyURL(mediaID, path, ip, true, false, useNewKey, userId)
}
func buildH5M3U8LegacyURL(mediaID int64, path, ip string, playable, preview, useNewKey bool, userId int64) string {
key := h5M3U8TicketKey()
if key == "" || mediaID <= 0 || strings.TrimSpace(ip) == "" {
return ""
}
normalizedPath, ok := normalizeH5M3U8Path(path)
if !ok {
return ""
}
expireAt := time.Now().Add(time.Duration(h5M3U8TicketTTLSeconds) * time.Second).Unix()
ticket := H5M3U8Ticket{
MediaID: mediaID,
Path: normalizedPath,
ExpireAt: expireAt,
UserId: userId,
VipExpireAt: lookupUserVipExpireUnix(userId),
Playable: playable,
Preview: preview,
UseNewKey: useNewKey,
KeyVersion: h5M3U8TicketKeyVersion,
}
if h5M3U8TicketBindIP {
ticket.IPHash = h5M3U8Hash(ip)
}
encodedTicket, err := encodeH5M3U8Ticket(ticket, key)
if err != nil {
log.Error("【BuildH5M3U8LegacyURL】生成H5播放票据失败", log.E(err))
return ""
}
query := url.Values{}
query.Set("id", strconv.FormatInt(mediaID, 10))
query.Set("t", strconv.FormatInt(expireAt, 10))
query.Set("ticket", encodedTicket)
return normalizedPath + "?" + query.Encode()
}
// CheckH5M3U8Ticket 校验 IP、视频、路径、有效期,防止伪造正片计数语义。
func CheckH5M3U8Ticket(mediaID, expireAt int64, path, ip, ticket string) (*H5M3U8Ticket, bool) {
data, ok := decodeAndValidateH5M3U8Ticket(mediaID, expireAt, ip, ticket)
if !ok || data == nil {
return nil, false
}
normalizedPath, pathOK := normalizeH5M3U8Path(path)
if !pathOK {
return nil, false
}
if payloadPath, valid := normalizeH5M3U8Path(data.Path); !valid || payloadPath != normalizedPath {
return nil, false
}
return data, true
}
// CheckH5M3U8TicketForEnkey 校验 /enkey 请求,不校验 m3u8 路径。
func CheckH5M3U8TicketForEnkey(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, bool) {
return decodeAndValidateH5M3U8Ticket(mediaID, expireAt, ip, ticket)
}
func decodeAndValidateH5M3U8Ticket(mediaID, expireAt int64, ip, ticket string) (*H5M3U8Ticket, bool) {
key := h5M3U8TicketKey()
if key == "" || mediaID <= 0 || expireAt <= 0 || strings.TrimSpace(ticket) == "" {
return nil, false
}
data, err := decodeH5M3U8Ticket(ticket, key)
if err != nil {
return nil, false
}
if data.MediaID != mediaID || data.ExpireAt != expireAt {
return nil, false
}
if data.KeyVersion != "" && data.KeyVersion != h5M3U8TicketKeyVersion {
return nil, false
}
if time.Now().Unix() > data.ExpireAt {
return nil, false
}
if h5M3U8TicketBindIP && data.IPHash != h5M3U8Hash(ip) {
return nil, false
}
return data, true
}
func H5M3U8RequestIP(ctx context.Context) string {
if c, ok := ctx.(*gin.Context); ok {
return strings.TrimSpace(ipaddr.ResolveClientIP(c))
}
return ""
}
func normalizeH5M3U8TicketParam(ticket string) string {
ticket = strings.TrimSpace(ticket)
if idx := strings.Index(ticket, "?"); idx >= 0 {
ticket = ticket[:idx]
}
return ticket
}
func normalizeH5M3U8Path(path string) (string, bool) {
path = strings.TrimSpace(path)
if path == "" {
return "", false
}
lowerPath := strings.ToLower(path)
if strings.HasPrefix(lowerPath, "http://") || strings.HasPrefix(lowerPath, "https://") {
return "", false
}
if idx := strings.Index(path, "?"); idx >= 0 {
path = path[:idx]
}
path = strings.TrimLeft(path, "/")
path = strings.TrimPrefix(path, strings.TrimLeft(h5M3U8RoutePrefix, "/"))
lowerPath = strings.ToLower(path)
if path == "" || strings.Contains(path, `\`) || strings.Contains(path, "..") || strings.Contains(lowerPath, "%2e") {
return "", false
}
if !strings.HasSuffix(lowerPath, ".m3u8") {
return "", false
}
return path, true
}
func encodeH5M3U8Ticket(ticket H5M3U8Ticket, key string) (string, error) {
data, err := json.Marshal(ticket)
if err != nil {
return "", err
}
encrypted, err := crypt.CoreAesEncryptEx(data, 12, key)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(encrypted), nil
}
func decodeH5M3U8Ticket(ticket, key string) (*H5M3U8Ticket, error) {
ticket = normalizeH5M3U8TicketParam(ticket)
encrypted, err := base64.URLEncoding.DecodeString(ticket)
if err != nil {
encrypted, err = base64.RawURLEncoding.DecodeString(ticket)
}
if err != nil {
return nil, err
}
decrypted, err := safeCoreAesDecryptEx(encrypted, 12, key)
if err != nil {
return nil, err
}
var data H5M3U8Ticket
if err = json.Unmarshal(decrypted, &data); err != nil {
return nil, err
}
return &data, nil
}
func safeCoreAesDecryptEx(encrypted []byte, nonceLen int, key string) (decrypted []byte, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("decrypt h5 m3u8 ticket panic: %v", recovered)
}
}()
return crypt.CoreAesDecryptEx(encrypted, nonceLen, key)
}
func h5M3U8Hash(value string) string {
hash := sha256.Sum256([]byte(strings.TrimSpace(value)))
return hex.EncodeToString(hash[:])
}
// lookupUserVipExpireUnix 取用户会员过期 unix 时间;优先 Redis 短缓存,未登录返回 0。
// 仅在签发票据时调用,避免 /h5/m3u8 热路径查库。
func lookupUserVipExpireUnix(userId int64) int64 {
if userId <= 0 {
return 0
}
cacheKey := redisconst.M3U8UserVipExpireKey(userId)
if cached, err := redis.Get(cacheKey); err == nil && cached != nil {
if n, parseErr := strconv.ParseInt(*cached, 10, 64); parseErr == nil {
return n
}
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
user, serr := usermod.FindOneUser(ctx, bson.M{
usermod.FN.User.Id: userId,
}, options.FindOne().SetProjection(bson.D{
{Key: usermod.FN.User.Id, Value: 1},
{Key: usermod.FN.User.CurrentVipExpire, Value: 1},
}))
vipExpireAt := int64(0)
if serr == nil && user != nil && !user.CurrentVipExpire.IsZero() {
vipExpireAt = user.CurrentVipExpire.Unix()
}
_ = redis.Set(cacheKey, strconv.FormatInt(vipExpireAt, 10), redisconst.M3U8UserVipExpireCache)
return vipExpireAt
}
+349
View File
@@ -0,0 +1,349 @@
package videoser
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"videoweb/app/appg"
"videoweb/app/service/configser"
"videoweb/pub/common"
"videoweb/pub/common/enum/consts"
"videoweb/pub/common/enum/redisconst"
"videoweb/pub/common/ipaddr"
"videoweb/pub/common/log"
"videoweb/pub/common/m3u8"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/m3u8requestlogmod"
"videoweb/pub/redis"
"github.com/gin-gonic/gin"
)
const m3u8RateLimitFallback = H5M3U8FallbackPath
type fileHandler func(ctx *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error)
func Enkey(ctx *gin.Context) {
handleTicketEnkey(ctx, false)
}
// NewEnkey 返回短剧未完成水印时使用的新 M3U8 密钥。
func NewEnkey(ctx *gin.Context) {
handleTicketEnkey(ctx, true)
}
func handleTicketEnkey(ctx *gin.Context, useNewKey bool) {
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
idStr := c.Query("id")
tStr := c.Query("t")
ticket := c.Query("ticket")
if idStr == "" || tStr == "" || ticket == "" {
err = errors.New("unauthorized")
return
}
videoId, parseErr := strconv.ParseInt(idStr, 10, 64)
if parseErr != nil {
err = errors.New("invalid id")
return
}
tVal, parseErr := strconv.ParseInt(tStr, 10, 64)
if parseErr != nil {
err = errors.New("invalid t")
return
}
ip := ipaddr.ResolveClientIP(c)
ticketInfo, ticketOK := CheckH5M3U8TicketForEnkey(videoId, tVal, ip, ticket)
if !ticketOK || ticketInfo.UseNewKey != useNewKey {
log.Warn("Enkey: ticket invalid", log.Any("ip", ip), log.Any("videoId", videoId))
err = errors.New("ticket check failed")
return
}
var buffer *bytes.Buffer
if useNewKey {
buffer, err = m3u8.NewEnkey(c)
} else {
buffer, err = m3u8.Enkey(c)
}
if err != nil {
return
}
contentLength = int64(buffer.Len())
contentType = "application/octet-stream"
reader = buffer
extraHeaders = map[string]string{"Cache-Control": "private, no-store"}
return
}
handlerFile(ctx, fn)
}
func handlerFile(ctx *gin.Context, h fileHandler) {
contentLength, contentType, reader, extraHeaders, err := h(ctx)
if err != nil {
common.ServerJSON(ctx, srvdef.DownloadM3u8File, err)
return
}
ctx.DataFromReader(http.StatusOK, contentLength, contentType, reader, extraHeaders)
}
func GetM3U8Ex(ctx *gin.Context) {
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
start := time.Now()
m3u8Name := ctx.Param("key")
origM3u8Name := m3u8Name
status := "success"
var mediaID int64
var expireAt int64
var ticketOK bool
var isOverseas bool
var cdnDomain string
ip := ipaddr.ResolveClientIP(c)
isOverseas = configser.IsOverseasIP(ip)
defer func() {
if err != nil && status == "success" {
status = "error"
}
recordM3U8RequestLog(c, m3u8Name, mediaID, expireAt, ticketOK, isOverseas, cdnDomain, status, err, start)
}()
ticket := ctx.Query("ticket")
mediaID = parseInt64(ctx.Query("id"))
expireAt = parseInt64(ctx.Query("t"))
ticketInfo, ticketOK := CheckH5M3U8Ticket(
mediaID, expireAt, origM3u8Name, ip, ticket,
)
if !ticketOK {
m3u8Name = H5M3U8FallbackPath
log.Warn("【GetM3U8Ex】H5播放票据无效",
log.Any("mediaId", mediaID),
log.Any("expireAt", expireAt),
log.Any("ip", ip),
log.Any("hasTicket", ticket != ""))
}
countTowardDailyLimit := ticketOK && ticketInfo.Playable && !ticketInfo.Preview
if countTowardDailyLimit && appg.Conf.RunMod == string(consts.RunModeProd) {
userId := resolveM3U8RateLimitUserId(c, ticketInfo)
vipValid := isM3U8TicketVipValid(ticketInfo)
limit := redisconst.M3U8DayRateLimitMax
var cnt int64
var countErr error
if vipValid {
limit = redisconst.M3U8DayRateLimitVipMax
cnt, countErr = m3u8DailyMediaCountByUser(userId, mediaID)
} else {
cnt, countErr = m3u8DailyMediaCountByIP(ip, mediaID)
}
if countErr != nil {
log.Error("【GetM3U8Ex】统计正片获取次数失败",
log.Any("ip", ip),
log.Any("userId", userId),
log.Any("vipValid", vipValid),
log.E(countErr))
} else if cnt > limit {
m3u8Name = m3u8RateLimitFallback
status = "rate_limited"
log.Warn("GetM3U8Ex daily rate limit, use fallback m3u8",
log.Any("ip", ip),
log.Any("userId", userId),
log.Any("vipValid", vipValid),
log.Any("limit", limit),
log.Any("remoteIP", c.RemoteIP()),
log.Any("clientIP", c.ClientIP()),
log.Any("xff", c.GetHeader("X-Forwarded-For")),
log.Any("cnt", cnt),
log.Any("mediaId", mediaID),
log.Any("orig", origM3u8Name),
log.Any("fallback", m3u8Name))
}
}
var req m3u8.Req
if err = ctx.BindQuery(&req); err != nil {
status = "bind_query_failed"
return
}
req.Name = m3u8Name
cdnDomain = configser.GetRandCDN()
originDomain := appg.Conf.OrigDomain
if isOverseas && len(configser.GetConfigInfo().MediaAppUsCdn) > 0 {
cdnDomain = configser.GetConfigInfo().MediaAppUsCdn
}
log.Info("cdn信息", log.Any("ip:", ip), log.Any("isOverseas", isOverseas), log.Any("cdnDomain", cdnDomain))
enkeyUri := ""
if ticketOK {
enkeyPath := "/enkey"
if ticketInfo.UseNewKey {
enkeyPath = "/newenkey"
}
enkeyUri = fmt.Sprintf("%s?id=%d&t=%d&ticket=%s", enkeyPath, mediaID, expireAt, url.QueryEscape(ticket))
}
var buff *bytes.Buffer
if ticketOK && ticketInfo.UseNewKey {
_, buff, err = m3u8.TransformExOriginalPath(req.Name, cdnDomain, originDomain, 2, appg.Conf.TsAuthKey, appg.Conf.LsjMediaConf.M3u8Yuan, appg.Conf.CdnAuthKey, enkeyUri)
} else {
_, buff, err = m3u8.TransformEx(req.Name, cdnDomain, originDomain, 2, appg.Conf.TsAuthKey, appg.Conf.LsjMediaConf.M3u8Yuan, appg.Conf.CdnAuthKey, enkeyUri)
}
if err != nil {
status = "transform_failed"
return
}
contentType = "application/vnd.apple.mpegurl"
reader = bytes.NewReader(buff.Bytes())
contentLength = int64(buff.Len())
extraHeaders = map[string]string{"Cache-Control": "private, no-store"}
return
}
handlerFile(ctx, fn)
}
func parseInt64(value string) int64 {
n, _ := strconv.ParseInt(value, 10, 64)
return n
}
func resolveM3U8RateLimitUserId(c *gin.Context, ticketInfo *H5M3U8Ticket) int64 {
if ticketInfo != nil && ticketInfo.UserId > 0 {
return ticketInfo.UserId
}
if c == nil {
return 0
}
return common.GetUserId(c)
}
// isM3U8TicketVipValid 用票据内签发时写入的会员过期时间判断,播放热路径不查库。
func isM3U8TicketVipValid(ticketInfo *H5M3U8Ticket) bool {
return ticketInfo != nil && ticketInfo.UserId > 0 && ticketInfo.VipExpireAt > time.Now().Unix()
}
func m3u8DailyMediaCountByIP(ip string, mediaID int64) (int64, error) {
return m3u8DailyPlayableCount(
redisconst.M3U8DayRateLimitIpKey(ip),
redisconst.M3U8DailyMediaIpKey(ip, mediaID),
)
}
func m3u8DailyMediaCountByUser(userId int64, mediaID int64) (int64, error) {
return m3u8DailyPlayableCount(
redisconst.M3U8DayRateLimitUserKey(userId),
redisconst.M3U8DailyMediaUserKey(userId, mediaID),
)
}
func m3u8DailyPlayableCount(countKey, firstMediaKey string) (int64, error) {
expire := redisconst.M3U8DayRateLimitExpire()
first, err := redis.Setnx_NewOK(firstMediaKey, 1, expire)
if err != nil {
return 0, err
}
if first {
return redis.Incr(countKey, expire)
}
value, err := redis.Get(countKey)
if err != nil || value == nil {
return 0, err
}
return strconv.ParseInt(*value, 10, 64)
}
func GetM3U8AuthParam(videoId int64, urlPath, ip, ua string, userId int64) string {
_ = ua
if signed := BuildH5M3U8LegacyURL(videoId, urlPath, ip, true, false, userId); signed != "" {
return signed
}
return urlPath
}
// GetPlayletM3U8AuthParam 为短剧剧集地址增加票据,并指定是否使用原片新密钥。
func GetPlayletM3U8AuthParam(chapterID int64, urlPath, ip string, useNewKey bool, userId int64) string {
if signed := BuildH5M3U8PlayletURL(chapterID, urlPath, ip, useNewKey, userId); signed != "" {
return signed
}
return urlPath
}
func LsjEnkey(ctx *gin.Context) {
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
buffer, err := m3u8.LsjEnkey(c)
if err != nil {
return
}
contentLength = int64(buffer.Len())
contentType = "application/octet-stream"
reader = buffer
extraHeaders = map[string]string{}
return
}
handlerFile(ctx, fn)
}
func recordM3U8RequestLog(ctx *gin.Context, m3u8Name string, videoId int64, t int64, ticketOK bool, isOverseas bool, cdnDomain string, status string, reqErr error, start time.Time) {
doc := &m3u8requestlogmod.M3U8RequestLog{
CreatedAt: time.Now(),
Host: ctx.Request.Host,
RequestURI: ctx.Request.RequestURI,
M3U8Path: m3u8Name,
RawQuery: ctx.Request.URL.RawQuery,
Query: cloneURLValues(ctx.Request.URL.Query()),
Headers: cloneHeader(ctx.Request.Header),
VideoId: videoId,
T: t,
Token: ctx.Query("ticket"),
ClientIP: ipaddr.ResolveClientIP(ctx),
RemoteAddr: ctx.Request.RemoteAddr,
XForwardedFor: ctx.GetHeader("X-Forwarded-For"),
XRealIP: ctx.GetHeader("X-Real-IP"),
CFConnectingIP: ctx.GetHeader("CF-Connecting-IP"),
UserAgent: ctx.Request.UserAgent(),
Referer: ctx.GetHeader("Referer"),
IsOverseas: isOverseas,
CDNDomain: cdnDomain,
TokenOK: ticketOK,
PathOK: true,
Status: status,
CostMs: time.Since(start).Milliseconds(),
}
if reqErr != nil {
doc.Error = reqErr.Error()
}
go func(data *m3u8requestlogmod.M3U8RequestLog) {
c, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if serr := m3u8requestlogmod.InsertOneM3U8RequestLog(c, data); serr != nil {
log.Warn("recordM3U8RequestLog insert failed", log.Any("status", data.Status), log.Any("path", data.M3U8Path))
}
}(doc)
}
func cloneHeader(header http.Header) map[string][]string {
return cloneStringValues(map[string][]string(header))
}
func cloneURLValues(values url.Values) map[string][]string {
return cloneStringValues(map[string][]string(values))
}
func cloneStringValues(values map[string][]string) map[string][]string {
result := make(map[string][]string, len(values))
for key, vals := range values {
copied := make([]string, len(vals))
copy(copied, vals)
result[key] = copied
}
return result
}
+45
View File
@@ -0,0 +1,45 @@
package videoser
import (
"context"
"strings"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/models/videotopicmod"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
func resolveVideoID(ctx context.Context, id int64, slug string, required bool) (int64, *srvdef.SrvErr) {
slug = strings.TrimSpace(slug)
if slug != "" {
item, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, bson.M{
videoinformationmod.FN.VideoInformation.Slug: slug,
}, options.FindOne().SetProjection(bson.D{
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
}))
if err != nil {
return 0, err
}
return item.Id, nil
}
if required && id <= 0 {
return 0, srvdef.New(srvdef.ErrParamError, "视频ID不能为空", "视频ID不能为空")
}
return id, nil
}
func resolveVideoTopicID(ctx context.Context, id int64, slug string) (int64, *srvdef.SrvErr) {
slug = strings.TrimSpace(slug)
if slug == "" {
return id, nil
}
item, err := videotopicmod.FindOneVideoTopicModel(ctx, bson.M{
videotopicmod.FN.VideoTopicModel.Slug: slug,
})
if err != nil {
return 0, err
}
return item.Id, nil
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
package videoser
import (
"context"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videocategorymod"
"go.mongodb.org/mongo-driver/bson"
)
func GetVideoCategoryById(ctx context.Context, id int64) (*videocategorymod.VideoCategory, *srvdef.SrvErr) {
return videocategorymod.FindOneVideoCategory(ctx, bson.M{
videocategorymod.FN.VideoCategory.Id: id,
})
}
+15
View File
@@ -0,0 +1,15 @@
package videoser
import (
"context"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videocreatermod"
"go.mongodb.org/mongo-driver/bson"
)
func GetVideoCreatorById(ctx context.Context, id int64) (*videocreatermod.VideoCreaterInfo, *srvdef.SrvErr) {
return videocreatermod.FindOneVideoCreaterInfo(ctx, bson.M{
videocreatermod.FN.VideoCreaterInfo.Id: id,
})
}
+15
View File
@@ -0,0 +1,15 @@
package videoser
import (
"context"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videotagmod"
"go.mongodb.org/mongo-driver/bson"
)
func GetVideoTagById(ctx context.Context, id int64) (*videotagmod.VideoTagInfo, *srvdef.SrvErr) {
return videotagmod.FindOneVideoTagInfo(ctx, bson.M{
videotagmod.FN.VideoTagInfo.Id: id,
})
}
+15
View File
@@ -0,0 +1,15 @@
package videoser
import (
"context"
"videoweb/pub/common/srvdef"
"videoweb/pub/models/videotopicmod"
"go.mongodb.org/mongo-driver/bson"
)
func GetVideoTopicById(ctx context.Context, id int64) (*videotopicmod.VideoTopicModel, *srvdef.SrvErr) {
return videotopicmod.FindOneVideoTopicModel(ctx, bson.M{
videotopicmod.FN.VideoTopicModel.Id: id,
})
}
+158
View File
@@ -0,0 +1,158 @@
package videoser
import (
"bufio"
"bytes"
"fmt"
"io"
http2 "net/http"
"strings"
"videoweb/app/appg"
"videoweb/app/service/configser"
"videoweb/pub/common"
"videoweb/pub/common/enum/redisconst"
"videoweb/pub/common/errors"
"videoweb/pub/common/http"
"videoweb/pub/common/log"
"videoweb/pub/common/m3u8"
"videoweb/pub/common/srvdef"
"videoweb/pub/common/utils"
"videoweb/pub/redis"
"videoweb/pub/storage/proxy"
"github.com/gin-gonic/gin"
)
func GetVTTEx(ctx *gin.Context) {
fn := func(c *gin.Context) (contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string, err error) {
vttName := ctx.Param("key")
var req m3u8.Req
if err = ctx.BindQuery(&req); err != nil {
return
}
req.Name = vttName
cfg := configser.GetConfigInfo()
cdnDomain := cfg.ImgCdn
originDomain := appg.Conf.OrigDomain
buff, err := TransformVttEx(req.Name, cdnDomain, originDomain)
if err != nil {
return
}
contentType = "application/vnd.apple.mpegurl"
reader = bytes.NewReader(buff)
contentLength = int64(len(buff))
extraHeaders = map[string]string{}
return
}
contentLength, contentType, reader, extraHeaders, err := fn(ctx)
if err != nil {
common.ServerJSON(ctx, srvdef.DownloadM3u8File, err)
return
}
ctx.DataFromReader(http2.StatusOK, contentLength, contentType, reader, extraHeaders)
}
func TransformVttEx(name, cdnDomain, originDomain string) (buff []byte, err error) {
if name == "" {
err = errors.MakeWithMsg(errors.M3u8FileName, "m3u8 file name is empty")
return
}
if cdnDomain == "" || originDomain == "" {
err = errors.MakeWithMsg(errors.DomainErr, "cdnDomain or origDomain is empty")
return
}
// 处理媒资库 桶1(v1) 桶2(v2) 桶3(v3)
mediaVerson := proxy.AppendsMediaS3Version(name)
if len(mediaVerson) > 0 {
name = mediaVerson + name
}
// 原始vtt
srcReader, err := readFromRedis(name, originDomain)
if err != nil {
return
}
raw, err := io.ReadAll(srcReader)
if err != nil {
return nil, errors.MakeWithMsg(errors.M3u8Read, "read vtt reader fail: "+err.Error())
}
// 替换为cdn路径
newData := rewriteVttWithCDN(raw, cdnDomain)
reader := bytes.NewReader(newData)
buff, err = io.ReadAll(reader)
if err != nil {
return nil, errors.MakeWithMsg(errors.M3u8Read, "read vtt fail: "+err.Error())
}
return
}
// 从redis缓存中读取
func readFromRedis(name, domain string) (reader io.Reader, err error) {
vttKey := redisconst.MediaM3U8Key(name)
data, err := redis.GetBytes(vttKey)
if err == nil && len(data) > 0 {
reader = bytes.NewReader(data)
return
}
requestUrl := utils.JoinUrlPath(domain, name)
resp, err := http.GetLargeFile(requestUrl, nil)
if err != nil {
fmt.Println("requestUrl111", requestUrl)
return
}
fmt.Println("requestUrl", requestUrl, resp.StatusCode)
if resp.StatusCode != 200 {
msg := fmt.Sprintf("read vtt fail, url: %s, status code %d", requestUrl, resp.StatusCode)
err = errors.MakeWithMsg(errors.M3u8Read, msg)
return
}
if err := redis.Set(vttKey, resp.Data,
redisconst.MediaM3U8CacheExpire); err != nil {
log.Info("vtt:readFromRedis redis set err", log.E(err))
}
reader = bytes.NewReader(resp.Data)
return
}
// rewriteVttWithCDN 将 vtt 内 "path#xywh=..." 的 path 改为 cdnDomain/path
func rewriteVttWithCDN(vttData []byte, cdnDomain string) []byte {
cdnDomain = strings.TrimSpace(cdnDomain)
cdnDomain = strings.TrimRight(cdnDomain, "/")
var out bytes.Buffer
scanner := bufio.NewScanner(bytes.NewReader(vttData))
for scanner.Scan() {
line := scanner.Text()
trim := strings.TrimSpace(line)
// 只处理缩略图行:xxx#xywh=...
if strings.Contains(trim, "#xywh=") {
parts := strings.SplitN(trim, "#xywh=", 2)
if len(parts) == 2 {
p := strings.TrimSpace(parts[0]) // 原路径
xywh := parts[1]
// 已经是绝对URL则不改
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
out.WriteString(p + "#xywh=" + xywh + "\n")
continue
}
// 处理相对路径,确保只有一个 /
p = strings.TrimLeft(p, "/")
newPath := cdnDomain + "/" + p
out.WriteString(newPath + "#xywh=" + xywh + "\n")
continue
}
}
out.WriteString(line + "\n")
}
// scanner.Err()
return out.Bytes()
}
+104
View File
@@ -0,0 +1,104 @@
package wordsfilterser
import (
"regexp"
"strings"
"golang.org/x/text/unicode/norm"
)
var (
// 只识别带点号和明确后缀的域名,避免把普通英文、拼音和中文昵称当成网址。
advertiseDomainPattern = regexp.MustCompile(`(?i)(?:https?://|www[.,])?[a-z0-9][a-z0-9-]{0,62}(?:\.[a-z0-9][a-z0-9-]{0,62})*[.,](?:ai|app|biz|cc|club|cn|co|com|fun|info|io|link|live|me|net|online|org|pro|shop|site|top|tv|vip|win|work|xyz)(?:[^a-z0-9]|$)`)
// “加”与联系方式必须成组出现,不再通过中文拼音推断,避免误伤正常姓名。
advertiseAddContactPattern = regexp.MustCompile(`(?i)(?:加|\+)[\s._,:,。-]{0,3}(?:v(?:x)?|w(?:x)?|q{1,2}|微(?:信)?|扣{1,2}|群|好友|电报|飞机)`)
// 联系方式名称与账号同时出现时才拦截,单独的英文或数字允许正常发布。
advertiseContactThenAccountPattern = regexp.MustCompile(`(?i)(?:微信|微号|vx|wx|qq|扣扣|电报|telegram|tg|飞机|联系方式|联系我|联系)[\s::=._-]{0,6}[a-z0-9][a-z0-9._-]{3,}`)
advertiseAccountThenContactPattern = regexp.MustCompile(`(?i)[a-z0-9][a-z0-9._-]{3,}[\s()()::=._-]{0,8}(?:微信|微号|vx|wx|qq|扣扣|电报|telegram|tg|飞机)`)
// 明确动作词与长账号同时出现时拦截,单独的数字仍交给AI结合内容上下文判断。
advertiseAddAccountPattern = regexp.MustCompile(`(?i)(?:加我|加(?:v|q{1,2}|微(?:信)?)|\+)[\s::=._-]{0,6}(?:[a-z][a-z0-9._-]{3,}|[1-9][0-9]{6,10})`)
advertiseAccountThenAddPattern = regexp.MustCompile(`(?i)[1-9][0-9]{6,10}[^0-9]{0,16}(?:加我(?:v|q{1,2}|微信)|加(?:v|q{1,2}|微信))`)
advertiseActionAccountPattern = regexp.MustCompile(`(?i)(?:换|留(?:下)?(?:联系方式)?|私(?:我|聊)?|找我)[\s::=+._-]{0,6}[1-9][0-9]{8,10}`)
// 识别用空白或标点拆开的微信号,例如“wxc z n q 3018”。
advertiseBrokenAccountPattern = regexp.MustCompile(`(?i)(?:wx|vx|wxc)(?:[\s._-]*[a-z0-9]){4,}`)
// 这些短语本身已经表达站外引流意图,不依赖账号长度判断。
advertisePromotionPattern = regexp.MustCompile(`(?i)(?:搜(?:索)?(?:网页|网址|网站)|浏览器搜索|免费(?:空降|约炮|一夜情)|同城(?:约炮|空降|上门)|(?:约炮|约妹|骚妹).{0,8}(?:上门|空降)|电鸽|纸飞机|(?:来|去|上)?(?:薇博|微博)[\s::=._-]{0,3}(?:搜|找|关注))`)
advertiseTextReplacer = strings.NewReplacer(
"丶", ".", "、", ".", "。", ".", ".", ".", "。", ".", "﹒", ".", "·", ".", "⢀", ".",
",", ",", "➕", "+", "+", "+",
"匚", "c", "匸", "c", "丅", "t", "卩", "p", "卪", "p", "讠", "i",
)
)
// validateAdvertiseText 只拦截高置信联系方式、网址和站外引流文本。
func validateAdvertiseText(text string) bool {
normalized := normalizeAdvertiseText(text)
// 纯数字6是用户常见的互动表达,无论长度都不作为广告处理。
if normalized != "" && strings.Trim(normalized, "6") == "" {
return true
}
return !advertiseDomainPattern.MatchString(normalized) &&
!advertiseAddContactPattern.MatchString(normalized) &&
!advertiseContactThenAccountPattern.MatchString(normalized) &&
!advertiseAccountThenContactPattern.MatchString(normalized) &&
!advertiseAddAccountPattern.MatchString(normalized) &&
!advertiseAccountThenAddPattern.MatchString(normalized) &&
!advertiseActionAccountPattern.MatchString(normalized) &&
!advertiseBrokenAccountPattern.MatchString(normalized) &&
!advertisePromotionPattern.MatchString(normalized)
}
// normalizeAdvertiseText 将全角和数学字体还原为普通字符,再处理常见分隔符变形。
func normalizeAdvertiseText(text string) string {
normalized := strings.ToLower(norm.NFKC.String(strings.TrimSpace(text)))
normalized = strings.Map(normalizeAdvertiseRune, normalized)
return advertiseTextReplacer.Replace(normalized)
}
// normalizeAdvertiseRune 还原广告中常见的圈字母、圈数字和跨字符集同形字。
func normalizeAdvertiseRune(r rune) rune {
switch {
case r >= '\U0001F150' && r <= '\U0001F169':
return 'a' + r - '\U0001F150'
case r >= '\u2776' && r <= '\u277e':
return '1' + r - '\u2776'
case r >= '\u2780' && r <= '\u2788':
return '1' + r - '\u2780'
case r >= '\u278a' && r <= '\u2792':
return '1' + r - '\u278a'
}
switch r {
case 'а', 'α':
return 'a'
case 'в', 'β':
return 'b'
case 'с':
return 'c'
case 'е', 'ε':
return 'e'
case 'і', 'ι':
return 'i'
case 'ј':
return 'j'
case 'к', 'κ':
return 'k'
case 'м', 'μ':
return 'm'
case 'о', 'ο':
return 'o'
case 'р', 'ρ':
return 'p'
case 'ѕ':
return 's'
case 'т', 'τ':
return 't'
case 'у', 'υ':
return 'y'
case 'х', 'χ':
return 'x'
default:
return r
}
}
+11
View File
@@ -0,0 +1,11 @@
package wordsfilterser
// IsAdvertise 判断文本是否包含广告链接、联系方式或站外引流。
func IsAdvertise(text string) bool {
return !validateAdvertiseText(text)
}
// Validate 校验文本是否可发布。当前仅拦截广告内容。
func Validate(text string) bool {
return !IsAdvertise(text)
}