Initial commit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
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"` // 隐私模式
|
||||
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, "账号已存在", "")
|
||||
}
|
||||
// 检查邮箱是否已存在
|
||||
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)
|
||||
applyChannelIfMissing(setDoc, guest, channel)
|
||||
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)
|
||||
applyChannelIfMissing(setDoc, user, channel)
|
||||
_ = 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.OldPassword == nil || req.NewPassword == nil {
|
||||
return srvdef.New(srvdef.ErrParamError, "修改密码时旧密码和新密码均需提供", "")
|
||||
}
|
||||
if !checkPassword(*req.OldPassword, user.Password) {
|
||||
return srvdef.New(srvdef.AccountErrPasswordError, "旧密码错误", "")
|
||||
}
|
||||
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(),
|
||||
}},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
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)
|
||||
applyChannelIfMissing(setDoc, user, req.Channel)
|
||||
_ = 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,
|
||||
Score: user.Score,
|
||||
Recharge: user.Recharge,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeChannel(channel string) string {
|
||||
return strings.TrimSpace(channel)
|
||||
}
|
||||
|
||||
func hasDistrictCode(code string) bool {
|
||||
return strings.TrimSpace(code) != ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func applyChannelIfMissing(setDoc bson.M, user *usermod.User, channel string) {
|
||||
if user == nil || hasDistrictCode(user.DistrictCode) {
|
||||
return
|
||||
}
|
||||
if ch := normalizeChannel(channel); ch != "" {
|
||||
setDoc[usermod.FN.User.DistrictCode] = ch
|
||||
user.DistrictCode = ch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package coinser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/rechargeser/jiahua"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"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, ip string,
|
||||
districtCode 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 := jiahua.SubmitOrder(ctx, userId, userName, tradeNo, pkg.Price, payMode, sysType, devType, 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,
|
||||
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),
|
||||
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
|
||||
}
|
||||
|
||||
return &BuyCoinResp{
|
||||
TradeNo: tradeNo,
|
||||
PayUrl: payInfo.PayUrl,
|
||||
PayUrlMode: payInfo.PayUrlMode,
|
||||
IsOpenNewBrowser: true,
|
||||
}, 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
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package coinser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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
|
||||
)
|
||||
|
||||
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) (*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,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
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/mongos"
|
||||
"videoweb/pub/redis"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
const (
|
||||
ObjectTypePlaylet int64 = 10 // 短剧评论
|
||||
ObjectTypePlayletChapter int64 = 11 // 短剧剧集评论
|
||||
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 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)
|
||||
// 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 || !isPlayletCommentType(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 || !isPlayletCommentType(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,
|
||||
}, 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
|
||||
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
|
||||
}
|
||||
}
|
||||
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 || !isPlayletCommentType(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},
|
||||
},
|
||||
}, 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 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
|
||||
}
|
||||
@@ -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.DetailTopList = ads
|
||||
case 3: // 横幅
|
||||
resp.NineGridList = ads
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
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,
|
||||
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
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "/"
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
package playletcache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/models/hotsearchmod"
|
||||
"videoweb/pub/models/playletcategorymod"
|
||||
"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
|
||||
categoryByID map[int64]*CategoryItem
|
||||
categoryBySlug map[string]*CategoryItem
|
||||
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
|
||||
}
|
||||
|
||||
// CategoryItem 分类缓存项(含 SEO)
|
||||
type CategoryItem struct {
|
||||
Id int64
|
||||
Name string
|
||||
Slug string
|
||||
Desc string
|
||||
Icon 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)
|
||||
updateCategoryCache(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 updateCategoryCache(ctx context.Context) {
|
||||
list, err := playletcategorymod.FindManyPlayletCategory(ctx, bson.M{}, options.Find().
|
||||
SetProjection(bson.D{
|
||||
{Key: playletcategorymod.FN.PlayletCategory.Id, Value: 1},
|
||||
{Key: playletcategorymod.FN.PlayletCategory.Name, Value: 1},
|
||||
{Key: playletcategorymod.FN.PlayletCategory.Slug, Value: 1},
|
||||
{Key: playletcategorymod.FN.PlayletCategory.Desc, Value: 1},
|
||||
{Key: playletcategorymod.FN.PlayletCategory.Icon, Value: 1},
|
||||
{Key: playletcategorymod.FN.PlayletCategory.Rank, Value: 1},
|
||||
{Key: playletcategorymod.FN.PlayletCategory.Status, Value: 1},
|
||||
{Key: "seoInfo", Value: 1},
|
||||
}).
|
||||
SetSort(bson.D{
|
||||
{Key: playletcategorymod.FN.PlayletCategory.Rank, Value: -1},
|
||||
{Key: playletcategorymod.FN.PlayletCategory.Id, Value: -1},
|
||||
}))
|
||||
if err != nil {
|
||||
log.Warn("playletcache.updateCategoryCache failed", log.E(err))
|
||||
return
|
||||
}
|
||||
temp := make(map[int64]*CategoryItem, len(list))
|
||||
tempBySlug := make(map[string]*CategoryItem, len(list))
|
||||
for _, item := range list {
|
||||
if item == nil || item.Id <= 0 {
|
||||
continue
|
||||
}
|
||||
cached := &CategoryItem{
|
||||
Id: item.Id,
|
||||
Name: item.Name,
|
||||
Slug: item.Slug,
|
||||
Desc: item.Desc,
|
||||
Icon: item.Icon,
|
||||
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.categoryByID = temp
|
||||
cache.categoryBySlug = 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// GetCategory 按 ID 取分类缓存;不存在返回 nil。
|
||||
func GetCategory(id int64) *CategoryItem {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
cache.lock.RLock()
|
||||
defer cache.lock.RUnlock()
|
||||
item := cache.categoryByID[id]
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *item
|
||||
return &cp
|
||||
}
|
||||
|
||||
// GetEnabledCategory 按 ID 取启用分类;未启用或不存在返回 nil。
|
||||
func GetEnabledCategory(id int64) *CategoryItem {
|
||||
item := GetCategory(id)
|
||||
if item == nil || !item.Status {
|
||||
return nil
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// GetCategoryBySlug 按 slug 取分类缓存;不存在返回 nil。
|
||||
func GetCategoryBySlug(slug string) *CategoryItem {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug == "" {
|
||||
return nil
|
||||
}
|
||||
cache.lock.RLock()
|
||||
defer cache.lock.RUnlock()
|
||||
item := cache.categoryBySlug[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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ListEnabledCategories 返回全部启用分类,按 Rank、Id 倒序。
|
||||
func ListEnabledCategories() []*CategoryItem {
|
||||
cache.lock.RLock()
|
||||
defer cache.lock.RUnlock()
|
||||
result := make([]*CategoryItem, 0, len(cache.categoryByID))
|
||||
for _, item := range cache.categoryByID {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// CategoryNameMap 批量取分类名称(含未启用)。
|
||||
func CategoryNameMap(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.categoryByID[id]; item != nil {
|
||||
result[id] = item.Name
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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...)
|
||||
}
|
||||
if item.CategoryIds != nil {
|
||||
cp.CategoryIds = append([]int64(nil), item.CategoryIds...)
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
package playletser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"videoweb/app/service/playletcache"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/playletcategorymod"
|
||||
"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
|
||||
}
|
||||
|
||||
// resolveCategoryID 解析分类ID:slug 非空时先按 slug 查 ID,否则使用传入 ID。
|
||||
func resolveCategoryID(ctx context.Context, id int64, slug string) (int64, *srvdef.SrvErr) {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug != "" {
|
||||
if item := playletcache.GetCategoryBySlug(slug); item != nil {
|
||||
return item.Id, nil
|
||||
}
|
||||
category, err := playletcategorymod.FindOnePlayletCategory(ctx, bson.M{
|
||||
playletcategorymod.FN.PlayletCategory.Slug: slug,
|
||||
}, options.FindOne().SetProjection(bson.D{
|
||||
{Key: playletcategorymod.FN.PlayletCategory.Id, Value: 1},
|
||||
}))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return category.Id, nil
|
||||
}
|
||||
if id <= 0 {
|
||||
return 0, srvdef.New(srvdef.ErrParamError, "分类ID无效", "分类ID无效")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package playletthemeser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/playletcache"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/pubdef"
|
||||
)
|
||||
|
||||
// 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 := "/list?themeId=" + strconv.FormatInt(item.Id, 10)
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package rechargepackageser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/rechargeser/jiahua"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/models/rechargepackagemod"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// GetRechargePackageList 查询购买套餐页所需的会员畅享和金币充值列表,并为各套餐附上可用支付方式。
|
||||
// 内部统一调用一次嘉华通道查询,避免 N+1 请求。
|
||||
func GetRechargePackageList(ctx context.Context, sysType string) (*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, sysType)
|
||||
|
||||
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, sysType string) 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, _ := jiahua.GetPayChannelByMoney(ctx, moneys, sysType)
|
||||
channelMap := make(map[int64][]proto.PayMethodInfo, len(channels))
|
||||
for _, ch := range channels {
|
||||
methods := make([]proto.PayMethodInfo, 0, 3)
|
||||
if ch.Alipay {
|
||||
methods = append(methods, proto.PayMethodInfo{Type: "alipay", TypeName: "支付宝"})
|
||||
}
|
||||
if ch.Wechat {
|
||||
methods = append(methods, proto.PayMethodInfo{Type: "wechat", TypeName: "微信"})
|
||||
}
|
||||
if ch.Union {
|
||||
methods = append(methods, proto.PayMethodInfo{Type: "union", TypeName: "银联"})
|
||||
}
|
||||
channelMap[ch.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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package jiahua
|
||||
|
||||
import "time"
|
||||
|
||||
// PayTypeParam 查询支付通道请求参数
|
||||
type PayTypeParam struct {
|
||||
Moneys []int64 `json:"moneys"`
|
||||
DevType string `json:"devType"`
|
||||
PlatformID string `json:"platformID"`
|
||||
}
|
||||
|
||||
// RchgType 支付通道信息
|
||||
type RchgType struct {
|
||||
Money int64 `json:"money"`
|
||||
Alipay bool `json:"alipay"`
|
||||
Wechat bool `json:"wechat"`
|
||||
Union bool `json:"union"`
|
||||
}
|
||||
|
||||
// PayCenterMsg 查询支付通道响应
|
||||
type PayCenterMsg struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data []RchgType `json:"data"`
|
||||
}
|
||||
|
||||
// PayCenterRchg 下单请求参数
|
||||
type PayCenterRchg struct {
|
||||
TransNo string `json:"transNo"`
|
||||
PlatformID string `json:"platformID"`
|
||||
UID string `json:"uid"`
|
||||
DevID string `json:"devID"`
|
||||
UserIP string `json:"userIP"`
|
||||
Name string `json:"name"`
|
||||
DevType string `json:"devType"`
|
||||
Money int64 `json:"money"`
|
||||
Channel string `json:"channel"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
NotifyUrl string `json:"notifyUrl"`
|
||||
PType string `json:"pType"`
|
||||
PayMethod string `json:"payMethod"`
|
||||
}
|
||||
|
||||
// PayCenterRchgMsg 下单响应
|
||||
type PayCenterRchgMsg struct {
|
||||
Err string `json:"err"`
|
||||
PayUrl string `json:"payUrl"`
|
||||
OID string `json:"oid"`
|
||||
Mode string `json:"mode"`
|
||||
CID string `json:"cid"`
|
||||
Rate string `json:"rate"`
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package jiahua
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/common/utils"
|
||||
"videoweb/pub/pubdef"
|
||||
)
|
||||
|
||||
const (
|
||||
notifyURLSuffix = "/api/web/third/paycenter/bill/callback"
|
||||
)
|
||||
|
||||
// GetPayChannelByMoney 向嘉华支付中心查询指定金额(分)支持的支付方式
|
||||
func GetPayChannelByMoney(ctx context.Context, moneys []int64, devType string) ([]RchgType, *srvdef.SrvErr) {
|
||||
if devType != "android" {
|
||||
devType = "ios"
|
||||
}
|
||||
param := PayTypeParam{
|
||||
Moneys: moneys,
|
||||
DevType: devType,
|
||||
PlatformID: pubdef.AppNameZhifu,
|
||||
}
|
||||
reqBody, err := json.Marshal(param)
|
||||
if err != nil {
|
||||
return nil, srvdef.New(srvdef.Err, "", err.Error())
|
||||
}
|
||||
resp := &PayCenterMsg{}
|
||||
url := appg.Conf.TransactionUrl + "/api/recharge/payType"
|
||||
code, err := utils.POSTJsonWithJResp(resp, url, nil, reqBody)
|
||||
if err != nil {
|
||||
log.Error("jiahua:GetPayChannelByMoney failed", log.Any("url", url), log.E(err))
|
||||
return nil, srvdef.New(srvdef.Err, "查询支付通道失败", err.Error())
|
||||
}
|
||||
if code != http.StatusOK || resp.Code != 200 {
|
||||
log.Error("jiahua:GetPayChannelByMoney bad resp", log.Any("code", code), log.Any("resp.Code", resp.Code))
|
||||
return nil, srvdef.New(srvdef.Err, "查询支付通道失败", "")
|
||||
}
|
||||
return resp.Data, nil
|
||||
}
|
||||
|
||||
// SubmitOrder 向嘉华支付中心提交下单
|
||||
// 返回 SubmitVipOrderInfo(含 PayUrl、Oid 等),供调用方存入本地订单。
|
||||
func SubmitOrder(ctx context.Context,
|
||||
userId int64, userName string,
|
||||
tradeNo string, amount int64,
|
||||
payMode string, sysType string, devType string, ip string,
|
||||
) (*pubdef.SubmitVipOrderInfo, *srvdef.SrvErr) {
|
||||
|
||||
reqParam := &PayCenterRchg{
|
||||
TransNo: tradeNo,
|
||||
PlatformID: pubdef.AppNameZhifu,
|
||||
UID: strconv.FormatInt(userId, 10),
|
||||
Name: userName,
|
||||
UserIP: ip,
|
||||
DevType: sysType,
|
||||
Money: amount,
|
||||
Channel: "self",
|
||||
CreatedAt: time.Now(),
|
||||
PayMethod: payMode,
|
||||
PType: payMode,
|
||||
NotifyUrl: appg.Conf.LocalWebUrl + notifyURLSuffix,
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(reqParam)
|
||||
if err != nil {
|
||||
return nil, srvdef.New(srvdef.Err, "", err.Error())
|
||||
}
|
||||
|
||||
resp := &PayCenterRchgMsg{}
|
||||
url := appg.Conf.TransactionUrl + "/api/recharge/recharge"
|
||||
code, err := utils.POSTJsonWithJResp(resp, url, nil, reqBody)
|
||||
if err != nil {
|
||||
log.Error("jiahua:SubmitOrder POSTJsonWithJResp failed", log.Any("url", url), log.E(err))
|
||||
return nil, srvdef.New(srvdef.Err, "调用支付中心失败", err.Error())
|
||||
}
|
||||
if code != http.StatusOK {
|
||||
log.Error("jiahua:SubmitOrder bad http code", log.Any("code", code))
|
||||
return nil, srvdef.New(srvdef.Err, "调用支付中心失败", "")
|
||||
}
|
||||
if resp.Err != "" {
|
||||
log.Error("jiahua:SubmitOrder resp error", log.Any("err", resp.Err), log.Any("tradeNo", tradeNo))
|
||||
return nil, srvdef.New(srvdef.Err, "支付中心下单失败", resp.Err)
|
||||
}
|
||||
|
||||
return &pubdef.SubmitVipOrderInfo{
|
||||
TradeNo: tradeNo,
|
||||
Oid: resp.OID,
|
||||
PayUrl: resp.PayUrl,
|
||||
PayUrlMode: resp.Mode,
|
||||
PayChannel: resp.CID,
|
||||
PayRate: resp.Rate,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package rechargeser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"videoweb/app/service/rechargeser/jiahua"
|
||||
"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, ip string,
|
||||
districtCode 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 := jiahua.SubmitOrder(ctx, userId, userName, tradeNo, amount, payMode, sysType, devType, 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),
|
||||
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
|
||||
}
|
||||
|
||||
ua := useragent.UA{SysType: 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",
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package reportser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
"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/playletcategorymod"
|
||||
"videoweb/pub/models/playletchaptermod"
|
||||
"videoweb/pub/models/playletmod"
|
||||
"videoweb/pub/models/usermod"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
const videoContentTypePlaylet = "short_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 短剧点赞,复用 video_like
|
||||
func ReportPlayletLike(ctx context.Context, userId, playletId, flag int64) {
|
||||
reportPlayletVideoEvent(ctx, userId, playletId, 0, dataReport.EventTypeVideoLike, flag, "")
|
||||
}
|
||||
|
||||
// ReportPlayletCollect 短剧收藏,复用 video_collect
|
||||
func ReportPlayletCollect(ctx context.Context, userId, playletId, flag int64) {
|
||||
reportPlayletVideoEvent(ctx, userId, playletId, 0, dataReport.EventTypeVideoCollect, flag, "")
|
||||
}
|
||||
|
||||
// ReportPlayletChapterLike 短剧剧集点赞,复用 video_like
|
||||
func ReportPlayletChapterLike(ctx context.Context, userId, playletId, chapterId, flag int64) {
|
||||
reportPlayletVideoEvent(ctx, userId, playletId, chapterId, dataReport.EventTypeVideoLike, flag, "")
|
||||
}
|
||||
|
||||
// ReportPlayletChapterCollect 短剧剧集收藏,复用 video_collect
|
||||
func ReportPlayletChapterCollect(ctx context.Context, userId, playletId, chapterId, flag int64) {
|
||||
reportPlayletVideoEvent(ctx, userId, playletId, chapterId, dataReport.EventTypeVideoCollect, flag, "")
|
||||
}
|
||||
|
||||
// ReportPlayletComment 短剧/剧集评论,复用 video_comment
|
||||
func ReportPlayletComment(ctx context.Context, user *usermod.User, playletId, chapterId int64, content string) {
|
||||
if user == nil || playletId <= 0 {
|
||||
return
|
||||
}
|
||||
pub := buildPlayletVideoPub(ctx, playletId, chapterId)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
reportPlayletPurchase(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(context.Background(), 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 reportPlayletVideoEvent(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 := buildPlayletVideoPub(ctx, playletId, chapterId)
|
||||
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),
|
||||
})
|
||||
case dataReport.EventTypeVideoComment:
|
||||
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoCommentEvent{
|
||||
VideoEventPub: pub,
|
||||
CommentContent: content,
|
||||
})
|
||||
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 reportPlayletPurchase(user *usermod.User, ua useragent.UA, ip string, playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter, amount int64, orderId string) {
|
||||
pub := playletToVideoPub(playlet, chapter)
|
||||
event := dataReport.NewCommonField(dataReport.GenerateGlobalEventID(), dataReport.EventTypeVideoPurchase, user.DistrictCode, ua, strconv.FormatInt(user.Id, 10), ip)
|
||||
event.Payload = dataReport.JsonRawMessage(&dataReport.VideoPurchaseEvent{
|
||||
MediaId: pub.MediaId,
|
||||
VideoID: pub.VideoID,
|
||||
VideoTitle: pub.VideoTitle,
|
||||
VideoTypeID: pub.VideoTypeID,
|
||||
VideoTypeName: pub.VideoTypeName,
|
||||
VideoContentType: pub.VideoContentType,
|
||||
RecommendTraceId: pub.RecommendTraceId,
|
||||
CoinQuantity: dataReport.JsonNumber(amount),
|
||||
OrderID: orderId,
|
||||
})
|
||||
insertEvent(event)
|
||||
}
|
||||
|
||||
func buildPlayletVideoPub(ctx context.Context, playletId, chapterId int64) dataReport.VideoEventPub {
|
||||
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.CategoryIds, Value: 1},
|
||||
{Key: playletmod.FN.Playlet.MediaCenterId, Value: 1},
|
||||
}))
|
||||
if err != nil || playlet == nil {
|
||||
return dataReport.VideoEventPub{}
|
||||
}
|
||||
var chapter *playletchaptermod.PlayletChapter
|
||||
if chapterId > 0 {
|
||||
chapter, _ = playletchaptermod.FindOnePlayletChapter(ctx, bson.M{playletchaptermod.FN.PlayletChapter.Id: chapterId}, options.FindOne().SetProjection(bson.D{
|
||||
{Key: playletchaptermod.FN.PlayletChapter.Id, Value: 1},
|
||||
{Key: playletchaptermod.FN.PlayletChapter.PlayletId, Value: 1},
|
||||
{Key: playletchaptermod.FN.PlayletChapter.Title, Value: 1},
|
||||
{Key: playletchaptermod.FN.PlayletChapter.CurrentEpisode, Value: 1},
|
||||
}))
|
||||
}
|
||||
return playletToVideoPub(playlet, chapter)
|
||||
}
|
||||
|
||||
func playletToVideoPub(playlet *playletmod.Playlet, chapter *playletchaptermod.PlayletChapter) dataReport.VideoEventPub {
|
||||
if playlet == nil {
|
||||
return dataReport.VideoEventPub{}
|
||||
}
|
||||
typeID, typeName := resolvePlayletType(context.Background(), playlet)
|
||||
videoID := strconv.FormatInt(playlet.Id, 10)
|
||||
title := playlet.Title
|
||||
if chapter != nil && chapter.Id > 0 {
|
||||
videoID = strconv.FormatInt(chapter.Id, 10)
|
||||
title = playletUnlockProductName(playlet, chapter)
|
||||
}
|
||||
return dataReport.VideoEventPub{
|
||||
MediaId: playletMediaId(playlet),
|
||||
VideoID: videoID,
|
||||
VideoTitle: title,
|
||||
VideoTypeID: typeID,
|
||||
VideoTypeName: typeName,
|
||||
VideoContentType: videoContentTypePlaylet,
|
||||
}
|
||||
}
|
||||
|
||||
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(ctx context.Context, playlet *playletmod.Playlet) (string, string) {
|
||||
if playlet == nil || len(playlet.CategoryIds) == 0 {
|
||||
return "default", "默认分类"
|
||||
}
|
||||
queryCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
category, err := playletcategorymod.FindOnePlayletCategory(queryCtx, bson.M{
|
||||
playletcategorymod.FN.PlayletCategory.Id: playlet.CategoryIds[0],
|
||||
})
|
||||
if err != nil || category == nil {
|
||||
return "default", "默认分类"
|
||||
}
|
||||
return strconv.FormatInt(category.Id, 10), category.Name
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package searchser
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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{
|
||||
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{
|
||||
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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package videoser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"videoweb/app/api/proto"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/netutil"
|
||||
"videoweb/pub/common/srvdef"
|
||||
"videoweb/pub/common/utils"
|
||||
"videoweb/pub/models/richtextcontentmod"
|
||||
"videoweb/pub/models/videoinformationmod"
|
||||
"videoweb/pub/models/videotagmod"
|
||||
"videoweb/pub/pubdef"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
const (
|
||||
videoListDefaultPageSize int64 = 10
|
||||
videoRecommendLimit int64 = 20
|
||||
videoMaxPageSize int64 = 100
|
||||
// videoH5DetailPath 视频详情Web/H5页面路径,供SEO canonical使用
|
||||
videoH5DetailPath = "/lieyan/av/detail"
|
||||
)
|
||||
|
||||
var (
|
||||
// videoListProjection 只读取列表需要展示的字段,降低MongoDB IO和网络传输
|
||||
videoListProjection = bson.D{
|
||||
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Title, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.CreatorId, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.CreatorName, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.CoverImg, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Keyworld, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.VideoTime, Value: 1},
|
||||
}
|
||||
|
||||
// videoListSort 按上架时间和ID倒序,保证普通分页排序稳定
|
||||
videoListSort = bson.D{
|
||||
{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Id, Value: -1},
|
||||
}
|
||||
|
||||
videoListHint = bson.D{
|
||||
{Key: videoinformationmod.FN.VideoInformation.Status, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Id, Value: -1},
|
||||
}
|
||||
|
||||
videoRecommendHint = bson.D{
|
||||
{Key: videoinformationmod.FN.VideoInformation.Status, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.AddedTime, Value: -1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Id, Value: -1},
|
||||
}
|
||||
|
||||
// videoInfoProjection 只读取视频详情接口需要展示和签名的字段
|
||||
videoInfoProjection = bson.D{
|
||||
{Key: videoinformationmod.FN.VideoInformation.Id, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Title, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.CoverImg, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Keyworld, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Desc, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Tags, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Real.WatchTimes, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Fake.WatchTimes, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.TaskId, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.FileId, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.Url, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.H265Url, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.Cover, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.Height, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.Width, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.VideoTime, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.PreMp4FileNameFs, Value: 1},
|
||||
{Key: videoinformationmod.FN.VideoInformation.Video.UnencryptedPreMp4, Value: 1},
|
||||
}
|
||||
)
|
||||
|
||||
type CategoryDataStruct struct {
|
||||
VideoInfoData []proto.VideoInfoData
|
||||
CategoryData proto.VideoCategoryInfo
|
||||
VideoCount int64
|
||||
}
|
||||
|
||||
type PublishDataStruct struct {
|
||||
VideoInfoData []proto.VideoInfoData
|
||||
CreaterData proto.VideoCreaterInfo
|
||||
VideoCount int64
|
||||
}
|
||||
|
||||
// VideoList 按上架时间倒序普通分页查询已上架视频
|
||||
func VideoList(ctx context.Context, req *proto.VideoReq) (*proto.VideoResp, *srvdef.SrvErr) {
|
||||
if req == nil {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "", "")
|
||||
}
|
||||
pageNum, pageSize := normalizeVideoPage(req.PageNum, req.PageSize, videoListDefaultPageSize)
|
||||
filter := bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
|
||||
}
|
||||
totalCount, err := videoinformationmod.CountVideoInformations(ctx, filter, options.Count().SetHint(videoListHint))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if totalCount == 0 {
|
||||
return emptyVideoPage(pageNum, pageSize), nil
|
||||
}
|
||||
skip := (pageNum - 1) * pageSize
|
||||
if skip >= totalCount {
|
||||
resp := emptyVideoPage(pageNum, pageSize)
|
||||
resp.TotalCount = totalCount
|
||||
return resp, nil
|
||||
}
|
||||
list, err := videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find().
|
||||
SetProjection(videoListProjection).
|
||||
SetSort(videoListSort).
|
||||
SetHint(videoListHint).
|
||||
SetSkip(skip).
|
||||
SetLimit(pageSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*proto.VideoItemResp, 0, len(list))
|
||||
imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx))
|
||||
for _, item := range list {
|
||||
items = append(items, videoToListResp(item, imgCdn))
|
||||
}
|
||||
return &proto.VideoResp{
|
||||
TotalCount: totalCount,
|
||||
PageSize: int(pageSize),
|
||||
PageNum: int(pageNum),
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VideoInfo 查询视频详情
|
||||
func VideoInfo(ctx context.Context, req *proto.VideoInfoReq, userId int64, ip, ua string) (*proto.VideoInfoResp, *srvdef.SrvErr) {
|
||||
if req == nil || req.Id <= 0 {
|
||||
return nil, srvdef.New(srvdef.ErrParamError, "", "")
|
||||
}
|
||||
filter := bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Id: req.Id,
|
||||
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
|
||||
}
|
||||
item, err := videoinformationmod.FindOneVideoInformationWithOptions(ctx, filter, options.FindOne().
|
||||
SetProjection(videoInfoProjection))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go IncWatchTimes(context.Background(), item.Id)
|
||||
imgCdn := configser.GetAppImageCdn(ip)
|
||||
resp := videoToInfoResp(item, imgCdn, ip, ua, true, userId)
|
||||
recommendVideos, err := queryRecommendVideos(ctx, item, imgCdn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.RecommendVideos = recommendVideos
|
||||
resp.Advitise = configser.GetAdvitise()
|
||||
resp.SeoInfo = buildVideoDetailSeoInfo(item)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// queryRecommendVideos 按当前视频标签查询相关推荐
|
||||
func queryRecommendVideos(ctx context.Context, current *videoinformationmod.VideoInformation, imgCdn string) ([]*proto.VideoItemResp, *srvdef.SrvErr) {
|
||||
if current == nil || len(current.Tags) == 0 {
|
||||
return make([]*proto.VideoItemResp, 0), nil
|
||||
}
|
||||
filter := bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
|
||||
videoinformationmod.FN.VideoInformation.Tags: bson.M{"$in": current.Tags},
|
||||
videoinformationmod.FN.VideoInformation.Id: bson.M{"$ne": current.Id},
|
||||
}
|
||||
list, err := videoinformationmod.FindManyVideoInformations(ctx, filter, options.Find().
|
||||
SetProjection(videoListProjection).
|
||||
SetSort(videoListSort).
|
||||
SetHint(videoRecommendHint).
|
||||
SetLimit(videoRecommendLimit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*proto.VideoItemResp, 0, len(list))
|
||||
for _, item := range list {
|
||||
items = append(items, videoToListResp(item, imgCdn))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// normalizeVideoPage 规范化视频普通分页参数,限制单次查询量保护接口和数据库
|
||||
func normalizeVideoPage(pageNum, pageSize, defaultPageSize int64) (int64, int64) {
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if pageSize > videoMaxPageSize {
|
||||
pageSize = videoMaxPageSize
|
||||
}
|
||||
return pageNum, pageSize
|
||||
}
|
||||
|
||||
func emptyVideoPage(pageNum, pageSize int64) *proto.VideoResp {
|
||||
return &proto.VideoResp{
|
||||
PageSize: int(pageSize),
|
||||
PageNum: int(pageNum),
|
||||
List: make([]*proto.VideoItemResp, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// videoToListResp 将视频模型转换为列表项
|
||||
func videoToListResp(item *videoinformationmod.VideoInformation, imgCdn string) *proto.VideoItemResp {
|
||||
return &proto.VideoItemResp{
|
||||
Id: item.Id,
|
||||
Title: item.Title,
|
||||
AddedTime: item.AddedTime,
|
||||
CreatorId: item.CreatorId,
|
||||
CreatorName: item.CreatorName,
|
||||
CoverImg: netutil.MergeURL(imgCdn, item.CoverImg),
|
||||
Keyworld: item.Keyworld,
|
||||
Tags: item.Tags,
|
||||
WatchTimes: item.Real.WatchTimes + item.Fake.WatchTimes,
|
||||
VideoTime: item.Video.VideoTime,
|
||||
}
|
||||
}
|
||||
|
||||
// videoToInfoResp 将视频模型转换为详情响应,并给视频播放地址增加鉴权参数
|
||||
func videoToInfoResp(item *videoinformationmod.VideoInformation, imgCdn, ip, ua string, includeCurrentWatch bool, userId int64) *proto.VideoInfoResp {
|
||||
video := proto.VideoData{
|
||||
TaskId: item.Video.TaskId,
|
||||
FileId: item.Video.FileId,
|
||||
Url: item.Video.Url,
|
||||
Cover: netutil.MergeURL(imgCdn, item.Video.Cover),
|
||||
Height: item.Video.Height,
|
||||
Width: item.Video.Width,
|
||||
VideoTime: item.Video.VideoTime,
|
||||
PreMp4FileNameFs: item.Video.PreMp4FileNameFs,
|
||||
UnencryptedPreMp4: item.Video.UnencryptedPreMp4,
|
||||
}
|
||||
if video.Url != "" {
|
||||
video.Url = GetM3U8AuthParam(item.Id, video.Url, ip, ua, userId)
|
||||
}
|
||||
watchTimes := item.Real.WatchTimes + item.Fake.WatchTimes
|
||||
if includeCurrentWatch {
|
||||
watchTimes++
|
||||
}
|
||||
resp := &proto.VideoInfoResp{
|
||||
Id: item.Id,
|
||||
Title: item.Title,
|
||||
CoverImg: netutil.MergeURL(imgCdn, item.CoverImg),
|
||||
Keyworld: item.Keyworld,
|
||||
Tags: item.Tags,
|
||||
TagsName: make([]string, 0, len(item.Tags)),
|
||||
WatchTimes: watchTimes,
|
||||
Video: video,
|
||||
}
|
||||
for _, tagId := range item.Tags {
|
||||
tagInfo := configser.GetTag(tagId)
|
||||
if tagInfo != nil {
|
||||
resp.TagsName = append(resp.TagsName, tagInfo.Name)
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// buildVideoDetailSeoInfo 组装视频详情页SEO信息
|
||||
//
|
||||
// canonical 统一指向Web/H5真实详情页:/lieyan/av/detail?id=视频ID
|
||||
func buildVideoDetailSeoInfo(item *videoinformationmod.VideoInformation) *proto.SeoInfo {
|
||||
seo := configser.GetSiteSeoInfo()
|
||||
if item == nil {
|
||||
return &seo
|
||||
}
|
||||
if title := strings.TrimSpace(item.Title); title != "" {
|
||||
seo.Title = title
|
||||
}
|
||||
if desc := truncateVideoSEODescription(strings.TrimSpace(item.Desc), 120); desc != "" {
|
||||
seo.Description = desc
|
||||
}
|
||||
seo.Keywords = buildVideoSEOKeywords(item, seo.Keywords)
|
||||
seo.CanonicalURL = buildVideoDetailCanonicalURL(seo.CanonicalURL, item.Id)
|
||||
return &seo
|
||||
}
|
||||
|
||||
// buildVideoDetailCanonicalURL 拼接视频详情页标准URL
|
||||
func buildVideoDetailCanonicalURL(baseURL string, id int64) string {
|
||||
pagePath := videoH5DetailPath + "?id=" + strconv.FormatInt(id, 10)
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
return pagePath
|
||||
}
|
||||
return baseURL + pagePath
|
||||
}
|
||||
|
||||
// buildVideoSEOKeywords 合并视频关键词和全站关键词,并去重
|
||||
func buildVideoSEOKeywords(item *videoinformationmod.VideoInformation, siteKeywords []string) []string {
|
||||
seen := make(map[string]struct{}, len(item.Keyworld)+len(siteKeywords)+1)
|
||||
keywords := make([]string, 0, len(item.Keyworld)+len(siteKeywords)+1)
|
||||
add := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
keywords = append(keywords, value)
|
||||
}
|
||||
add(item.Title)
|
||||
for _, keyword := range item.Keyworld {
|
||||
add(keyword)
|
||||
}
|
||||
for _, keyword := range siteKeywords {
|
||||
add(keyword)
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
// truncateVideoSEODescription 限制meta description长度,避免详情长文本直接写进SEO描述
|
||||
func truncateVideoSEODescription(desc string, maxRunes int) string {
|
||||
if maxRunes <= 0 || desc == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxRunes {
|
||||
return desc
|
||||
}
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
|
||||
func VideoDetails(ctx context.Context, videoId int64, userId int64, ip, ua string) (resp proto.VideoInfoData, serr *srvdef.SrvErr) {
|
||||
imgCdn := configser.GetAppImageCdn(ip)
|
||||
videoFilter := bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Id: videoId,
|
||||
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
|
||||
}
|
||||
|
||||
videoInfo, serr := videoinformationmod.FindOneVideoInformation(ctx, videoFilter)
|
||||
if serr != nil {
|
||||
log.Error("VideoDetails", log.Any("videoFilter", videoFilter), log.E(serr))
|
||||
return
|
||||
}
|
||||
// 查询分类
|
||||
|
||||
resp = toVideoInfoData(videoInfo, imgCdn)
|
||||
for _, tagId := range videoInfo.Tags {
|
||||
tagInfo := configser.GetTag(tagId)
|
||||
if tagInfo != nil {
|
||||
resp.Tags = append(resp.Tags, proto.TagsInfoData{
|
||||
Id: tagInfo.Id,
|
||||
Name: tagInfo.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
if resp.WatchTimes > 0 {
|
||||
resp.WatchTimes = resp.WatchTimes * 100
|
||||
} else {
|
||||
resp.WatchTimes = utils.GetRandom(2000, 10000)
|
||||
}
|
||||
if resp.Content != "" {
|
||||
re := regexp.MustCompile(`data-src="([^"]+?\.m3u8)"`)
|
||||
// 查找所有匹配项
|
||||
matches := re.FindAllStringSubmatch(resp.Content, -1)
|
||||
for _, match := range matches {
|
||||
newUrl := GetM3U8AuthParam(videoId, match[1], ip, ua, userId)
|
||||
resp.Content = strings.ReplaceAll(resp.Content, match[1], newUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.Video.Url) > 0 || len(resp.Nodes) > 0 {
|
||||
applyVideoUrlAuth(&resp, videoId, ip, ua, userId)
|
||||
}
|
||||
go func() {
|
||||
_ = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Id: videoId,
|
||||
}, bson.M{"$inc": bson.M{videoinformationmod.FN.VideoInformation.Real.ShowTimes: 1}})
|
||||
if len(videoInfo.Tags) > 0 {
|
||||
tagIds := videoInfo.Tags
|
||||
if len(tagIds) > 10 {
|
||||
tagIds = tagIds[:10]
|
||||
}
|
||||
_ = videotagmod.UpdateManyVideoTagInfo(ctx, bson.M{
|
||||
videotagmod.FN.VideoTagInfo.Id: bson.M{"$in": tagIds},
|
||||
}, bson.M{"$inc": bson.M{
|
||||
videotagmod.FN.VideoTagInfo.WatchTimes: 1,
|
||||
}})
|
||||
}
|
||||
}()
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func RichtextData(ctx context.Context, richtextId int64) (proto.RichTextInfo, *srvdef.SrvErr) {
|
||||
//cacheKey := fmt.Sprintf("rich_text_data_%d", richtextId)
|
||||
var resp *proto.RichTextInfo
|
||||
//if cacheData, exists := Cache.Get(cacheKey); exists {
|
||||
// if item, ok := cacheData.(proto.RichTextInfo); ok {
|
||||
// resp = &item
|
||||
// }
|
||||
//}
|
||||
//if resp == nil || resp.Id == 0 {
|
||||
filter := bson.M{
|
||||
richtextcontentmod.FN.RichTextContentModel.Id: richtextId,
|
||||
}
|
||||
|
||||
richtextInfo, serr := richtextcontentmod.FindOneRichTextContentModelModel(ctx, filter)
|
||||
if serr != nil {
|
||||
log.Error("VideoTagData", log.Any("videoFilter", filter), log.E(serr))
|
||||
return proto.RichTextInfo{}, serr
|
||||
}
|
||||
data := &proto.RichTextInfo{
|
||||
Id: richtextInfo.Id,
|
||||
Title: richtextInfo.Title,
|
||||
Content: richtextInfo.Content,
|
||||
Desc: richtextInfo.Desc,
|
||||
SeoInfo: richtextInfo.SeoInfo,
|
||||
}
|
||||
resp = data
|
||||
// Cache.Set(cacheKey, data, 17*time.Minute)
|
||||
//}
|
||||
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func IncWatchTimes(ctx context.Context, videoId int64) {
|
||||
_ = videoinformationmod.UpdateOneVideoInformation(ctx, bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Id: videoId,
|
||||
}, bson.M{"$inc": bson.M{
|
||||
videoinformationmod.FN.VideoInformation.Real.WatchTimes: 1,
|
||||
}})
|
||||
}
|
||||
|
||||
func VideoTopicData(ctx context.Context, isAv bool, topicId int64, sortType int64, pageNum int64) (videoCount int64, videoInfos []proto.VideoInfoData, sortItem videoinformationmod.VideoSortItem, err *srvdef.SrvErr) {
|
||||
imgCdn := configser.GetAppImageCdn(configser.ClientIPFromCtx(ctx))
|
||||
videoCount = int64(0)
|
||||
sort := videoinformationmod.VideoSortType(sortType)
|
||||
sortItem = sort.GetSortItem()
|
||||
videoInfos = make([]proto.VideoInfoData, 0)
|
||||
|
||||
filter := bson.M{
|
||||
videoinformationmod.FN.VideoInformation.MediaType: videoinformationmod.MediaType_VideoV2,
|
||||
videoinformationmod.FN.VideoInformation.Status: videoinformationmod.InformationSatusVerifyOK,
|
||||
}
|
||||
opts := &options.FindOptions{}
|
||||
opts.SetLimit(pubdef.PageSize).SetSkip((pageNum - 1) * pubdef.PageSize).SetSort(sort.GetSortBson())
|
||||
videoCount, _ = videoinformationmod.CountVideoInformations(ctx, filter)
|
||||
if videoCount == 0 {
|
||||
return
|
||||
}
|
||||
videoList, err := videoinformationmod.FindManyVideoInformations(ctx, filter, opts)
|
||||
if err != nil {
|
||||
log.Warn("VideoTopicData FindManyVideoInfomations", log.Any("filter", filter), log.E(err))
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < len(videoList); i++ {
|
||||
info := toVideoInfoData(videoList[i], imgCdn)
|
||||
for _, tid := range videoList[i].Tags {
|
||||
tagInfo := configser.GetTag(tid)
|
||||
if tagInfo != nil {
|
||||
info.Tags = append(info.Tags, proto.TagsInfoData{
|
||||
Id: tagInfo.Id,
|
||||
Name: tagInfo.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
if info.WatchTimes > 0 {
|
||||
info.WatchTimes = info.WatchTimes * 100
|
||||
} else {
|
||||
info.WatchTimes = utils.GetRandom(2000, 10000)
|
||||
}
|
||||
videoInfos = append(videoInfos, info)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user