|
|
// Copyright 2022 ROC. All rights reserved.
|
|
|
// Use of this source code is governed by a MIT style
|
|
|
// license that can be found in the LICENSE file.
|
|
|
|
|
|
package broker
|
|
|
|
|
|
import (
|
|
|
"errors"
|
|
|
"fmt"
|
|
|
"math"
|
|
|
"strings"
|
|
|
"time"
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
"github.com/rocboss/paopao-ce/internal/conf"
|
|
|
"github.com/rocboss/paopao-ce/internal/core"
|
|
|
"github.com/rocboss/paopao-ce/pkg/errcode"
|
|
|
"github.com/rocboss/paopao-ce/pkg/util"
|
|
|
"github.com/sirupsen/logrus"
|
|
|
)
|
|
|
|
|
|
type TagType string
|
|
|
|
|
|
const TagTypeHot TagType = "hot"
|
|
|
const TagTypeNew TagType = "new"
|
|
|
|
|
|
type PostListReq struct {
|
|
|
Conditions *core.ConditionsT
|
|
|
Offset int
|
|
|
Limit int
|
|
|
}
|
|
|
type PostTagsReq struct {
|
|
|
Type TagType `json:"type" form:"type" binding:"required"`
|
|
|
Num int `json:"num" form:"num" binding:"required"`
|
|
|
}
|
|
|
|
|
|
type PostCreationReq struct {
|
|
|
Contents []*PostContentItem `json:"contents" binding:"required"`
|
|
|
Tags []string `json:"tags" binding:"required"`
|
|
|
Users []string `json:"users" binding:"required"`
|
|
|
AttachmentPrice int64 `json:"attachment_price"`
|
|
|
Visibility core.PostVisibleT `json:"visibility"`
|
|
|
}
|
|
|
|
|
|
type PostDelReq struct {
|
|
|
ID int64 `json:"id" binding:"required"`
|
|
|
}
|
|
|
|
|
|
type PostLockReq struct {
|
|
|
ID int64 `json:"id" binding:"required"`
|
|
|
}
|
|
|
|
|
|
type PostStickReq struct {
|
|
|
ID int64 `json:"id" binding:"required"`
|
|
|
}
|
|
|
|
|
|
type PostVisibilityReq struct {
|
|
|
ID int64 `json:"id" binding:"required"`
|
|
|
Visibility core.PostVisibleT `json:"visibility"`
|
|
|
}
|
|
|
|
|
|
type PostStarReq struct {
|
|
|
ID int64 `json:"id" binding:"required"`
|
|
|
}
|
|
|
|
|
|
type PostCollectionReq struct {
|
|
|
ID int64 `json:"id" binding:"required"`
|
|
|
}
|
|
|
|
|
|
type PostContentItem struct {
|
|
|
Content string `json:"content" binding:"required"`
|
|
|
Type core.PostContentT `json:"type" binding:"required"`
|
|
|
Sort int64 `json:"sort" binding:"required"`
|
|
|
}
|
|
|
|
|
|
// Check 检查PostContentItem属性
|
|
|
func (p *PostContentItem) Check() error {
|
|
|
// 检查附件是否是本站资源
|
|
|
if p.Type == core.ContentTypeImage || p.Type == core.ContentTypeVideo || p.Type == core.ContentTypeAttachment {
|
|
|
if err := ds.CheckAttachment(p.Content); err != nil {
|
|
|
return err
|
|
|
}
|
|
|
}
|
|
|
// 检查链接是否合法
|
|
|
if p.Type == core.ContentTypeLink {
|
|
|
if strings.Index(p.Content, "http://") != 0 && strings.Index(p.Content, "https://") != 0 {
|
|
|
return fmt.Errorf("链接不合法")
|
|
|
}
|
|
|
}
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
func tagsFrom(originTags []string) []string {
|
|
|
tags := make([]string, 0, len(originTags))
|
|
|
for _, tag := range originTags {
|
|
|
// TODO: 优化tag有效性检测
|
|
|
if tag = strings.TrimSpace(tag); len(tag) > 0 {
|
|
|
tags = append(tags, tag)
|
|
|
}
|
|
|
}
|
|
|
return tags
|
|
|
}
|
|
|
|
|
|
// CreatePost 创建文章
|
|
|
// TODO: 推文+推文内容需要在一个事务中添加,后续优化
|
|
|
func CreatePost(c *gin.Context, userID int64, param PostCreationReq) (_ *core.PostFormated, err error) {
|
|
|
var mediaContents []string
|
|
|
|
|
|
defer func() {
|
|
|
if err != nil {
|
|
|
deleteOssObjects(mediaContents)
|
|
|
}
|
|
|
}()
|
|
|
|
|
|
if mediaContents, err = persistMediaContents(param.Contents); err != nil {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
ip := c.ClientIP()
|
|
|
tags := tagsFrom(param.Tags)
|
|
|
post := &core.Post{
|
|
|
UserID: userID,
|
|
|
Tags: strings.Join(tags, ","),
|
|
|
IP: ip,
|
|
|
IPLoc: util.GetIPLoc(ip),
|
|
|
AttachmentPrice: param.AttachmentPrice,
|
|
|
Visibility: param.Visibility,
|
|
|
}
|
|
|
post, err = ds.CreatePost(post)
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
|
|
|
for _, item := range param.Contents {
|
|
|
if err := item.Check(); err != nil {
|
|
|
// 属性非法
|
|
|
logrus.Infof("contents check err: %v", err)
|
|
|
continue
|
|
|
}
|
|
|
|
|
|
if item.Type == core.ContentTypeAttachment && param.AttachmentPrice > 0 {
|
|
|
item.Type = core.ContentTypeChargeAttachment
|
|
|
}
|
|
|
|
|
|
postContent := &core.PostContent{
|
|
|
PostID: post.ID,
|
|
|
UserID: userID,
|
|
|
Content: item.Content,
|
|
|
Type: item.Type,
|
|
|
Sort: item.Sort,
|
|
|
}
|
|
|
if _, err = ds.CreatePostContent(postContent); err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 私密推文不创建标签与用户提醒
|
|
|
if post.Visibility != core.PostVisitPrivate {
|
|
|
// 创建标签
|
|
|
for _, t := range tags {
|
|
|
tag := &core.Tag{
|
|
|
UserID: userID,
|
|
|
Tag: t,
|
|
|
}
|
|
|
ds.CreateTag(tag)
|
|
|
}
|
|
|
|
|
|
// 创建用户消息提醒
|
|
|
for _, u := range param.Users {
|
|
|
user, err := ds.GetUserByUsername(u)
|
|
|
if err != nil || user.ID == userID {
|
|
|
continue
|
|
|
}
|
|
|
|
|
|
// 创建消息提醒
|
|
|
// TODO: 优化消息提醒处理机制
|
|
|
go ds.CreateMessage(&core.Message{
|
|
|
SenderUserID: userID,
|
|
|
ReceiverUserID: user.ID,
|
|
|
Type: core.MsgTypePost,
|
|
|
Brief: "在新发布的泡泡动态中@了你",
|
|
|
PostID: post.ID,
|
|
|
})
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 推送Search
|
|
|
PushPostToSearch(post)
|
|
|
|
|
|
formatedPosts, err := ds.RevampPosts([]*core.PostFormated{post.Format()})
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
return formatedPosts[0], nil
|
|
|
}
|
|
|
|
|
|
func DeletePost(user *core.User, id int64) *errcode.Error {
|
|
|
if user == nil {
|
|
|
return errcode.NoPermission
|
|
|
}
|
|
|
|
|
|
post, err := ds.GetPostByID(id)
|
|
|
if err != nil {
|
|
|
return errcode.GetPostFailed
|
|
|
}
|
|
|
if post.UserID != user.ID && !user.IsAdmin {
|
|
|
return errcode.NoPermission
|
|
|
}
|
|
|
|
|
|
mediaContents, err := ds.DeletePost(post)
|
|
|
if err != nil {
|
|
|
logrus.Errorf("service.DeletePost delete post failed: %s", err)
|
|
|
return errcode.DeletePostFailed
|
|
|
}
|
|
|
|
|
|
// 删除推文的媒体内容
|
|
|
deleteOssObjects(mediaContents)
|
|
|
|
|
|
// 删除索引
|
|
|
DeleteSearchPost(post)
|
|
|
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
// deleteOssObjects 删除推文的媒体内容, 宽松处理错误(就是不处理), 后续完善
|
|
|
func deleteOssObjects(mediaContents []string) {
|
|
|
mediaContentsSize := len(mediaContents)
|
|
|
if mediaContentsSize > 1 {
|
|
|
objectKeys := make([]string, 0, mediaContentsSize)
|
|
|
for _, cUrl := range mediaContents {
|
|
|
objectKeys = append(objectKeys, oss.ObjectKey(cUrl))
|
|
|
}
|
|
|
// TODO: 优化处理尽量使用channel传递objectKeys使用可控数量的Goroutine集中处理object删除动作,后续完善
|
|
|
go oss.DeleteObjects(objectKeys)
|
|
|
} else if mediaContentsSize == 1 {
|
|
|
oss.DeleteObject(oss.ObjectKey(mediaContents[0]))
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func LockPost(id int64) error {
|
|
|
post, _ := ds.GetPostByID(id)
|
|
|
|
|
|
err := ds.LockPost(post)
|
|
|
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
func StickPost(id int64) error {
|
|
|
post, _ := ds.GetPostByID(id)
|
|
|
|
|
|
err := ds.StickPost(post)
|
|
|
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
func VisiblePost(user *core.User, postId int64, visibility core.PostVisibleT) *errcode.Error {
|
|
|
if visibility >= core.PostVisitInvalid {
|
|
|
return errcode.InvalidParams
|
|
|
}
|
|
|
|
|
|
post, err := ds.GetPostByID(postId)
|
|
|
if err != nil {
|
|
|
return errcode.GetPostFailed
|
|
|
}
|
|
|
|
|
|
if err := checkPermision(user, post.UserID); err != nil {
|
|
|
return err
|
|
|
}
|
|
|
|
|
|
if err = ds.VisiblePost(post, visibility); err != nil {
|
|
|
logrus.Warnf("update post failure: %v", err)
|
|
|
return errcode.VisblePostFailed
|
|
|
}
|
|
|
|
|
|
// 推送Search
|
|
|
post.Visibility = visibility
|
|
|
PushPostToSearch(post)
|
|
|
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
func GetPostStar(postID, userID int64) (*core.PostStar, error) {
|
|
|
return ds.GetUserPostStar(postID, userID)
|
|
|
}
|
|
|
|
|
|
func CreatePostStar(postID, userID int64) (*core.PostStar, error) {
|
|
|
// 加载Post
|
|
|
post, err := ds.GetPostByID(postID)
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
|
|
|
// 私密post不可操作
|
|
|
if post.Visibility == core.PostVisitPrivate {
|
|
|
return nil, errors.New("no permision")
|
|
|
}
|
|
|
|
|
|
star, err := ds.CreatePostStar(postID, userID)
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
|
|
|
// 更新Post点赞数
|
|
|
post.UpvoteCount++
|
|
|
ds.UpdatePost(post)
|
|
|
|
|
|
// 更新索引
|
|
|
PushPostToSearch(post)
|
|
|
|
|
|
return star, nil
|
|
|
}
|
|
|
|
|
|
func DeletePostStar(star *core.PostStar) error {
|
|
|
err := ds.DeletePostStar(star)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
// 加载Post
|
|
|
post, err := ds.GetPostByID(star.PostID)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
|
|
|
// 私密post不可操作
|
|
|
if post.Visibility == core.PostVisitPrivate {
|
|
|
return errors.New("no permision")
|
|
|
}
|
|
|
|
|
|
// 更新Post点赞数
|
|
|
post.UpvoteCount--
|
|
|
ds.UpdatePost(post)
|
|
|
|
|
|
// 更新索引
|
|
|
PushPostToSearch(post)
|
|
|
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
func GetPostCollection(postID, userID int64) (*core.PostCollection, error) {
|
|
|
return ds.GetUserPostCollection(postID, userID)
|
|
|
}
|
|
|
|
|
|
func CreatePostCollection(postID, userID int64) (*core.PostCollection, error) {
|
|
|
// 加载Post
|
|
|
post, err := ds.GetPostByID(postID)
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
|
|
|
// 私密post不可操作
|
|
|
if post.Visibility == core.PostVisitPrivate {
|
|
|
return nil, errors.New("no permision")
|
|
|
}
|
|
|
|
|
|
collection, err := ds.CreatePostCollection(postID, userID)
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
|
|
|
// 更新Post点赞数
|
|
|
post.CollectionCount++
|
|
|
ds.UpdatePost(post)
|
|
|
|
|
|
// 更新索引
|
|
|
PushPostToSearch(post)
|
|
|
|
|
|
return collection, nil
|
|
|
}
|
|
|
|
|
|
func DeletePostCollection(collection *core.PostCollection) error {
|
|
|
err := ds.DeletePostCollection(collection)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
// 加载Post
|
|
|
post, err := ds.GetPostByID(collection.PostID)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
|
|
|
// 私密post不可操作
|
|
|
if post.Visibility == core.PostVisitPrivate {
|
|
|
return errors.New("no permision")
|
|
|
}
|
|
|
|
|
|
// 更新Post点赞数
|
|
|
post.CollectionCount--
|
|
|
ds.UpdatePost(post)
|
|
|
|
|
|
// 更新索引
|
|
|
PushPostToSearch(post)
|
|
|
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
func GetPost(id int64) (*core.PostFormated, error) {
|
|
|
post, err := ds.GetPostByID(id)
|
|
|
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
|
|
|
postContents, err := ds.GetPostContentsByIDs([]int64{post.ID})
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
|
|
|
users, err := ds.GetUsersByIDs([]int64{post.UserID})
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
|
|
|
// 数据整合
|
|
|
postFormated := post.Format()
|
|
|
for _, user := range users {
|
|
|
postFormated.User = user.Format()
|
|
|
}
|
|
|
for _, content := range postContents {
|
|
|
if content.PostID == post.ID {
|
|
|
postFormated.Contents = append(postFormated.Contents, content.Format())
|
|
|
}
|
|
|
}
|
|
|
return postFormated, nil
|
|
|
}
|
|
|
|
|
|
func GetPostContentByID(id int64) (*core.PostContent, error) {
|
|
|
return ds.GetPostContentByID(id)
|
|
|
}
|
|
|
|
|
|
func GetIndexPosts(user *core.User, offset int, limit int) (*core.IndexTweetList, error) {
|
|
|
return ds.IndexPosts(user, offset, limit)
|
|
|
}
|
|
|
|
|
|
func GetPostList(req *PostListReq) ([]*core.Post, []*core.PostFormated, error) {
|
|
|
posts, err := ds.GetPosts(req.Conditions, req.Offset, req.Limit)
|
|
|
if err != nil {
|
|
|
return nil, nil, err
|
|
|
}
|
|
|
postFormated, err := ds.MergePosts(posts)
|
|
|
return posts, postFormated, err
|
|
|
}
|
|
|
|
|
|
func GetPostCount(conditions *core.ConditionsT) (int64, error) {
|
|
|
return ds.GetPostCount(conditions)
|
|
|
}
|
|
|
|
|
|
func GetPostListFromSearch(user *core.User, q *core.QueryReq, offset, limit int) ([]*core.PostFormated, int64, error) {
|
|
|
resp, err := ts.Search(user, q, offset, limit)
|
|
|
if err != nil {
|
|
|
return nil, 0, err
|
|
|
}
|
|
|
posts, err := ds.RevampPosts(resp.Items)
|
|
|
if err != nil {
|
|
|
return nil, 0, err
|
|
|
}
|
|
|
return posts, resp.Total, nil
|
|
|
}
|
|
|
|
|
|
func GetPostListFromSearchByQuery(user *core.User, query string, offset, limit int) ([]*core.PostFormated, int64, error) {
|
|
|
q := &core.QueryReq{
|
|
|
Query: query,
|
|
|
Type: "search",
|
|
|
}
|
|
|
return GetPostListFromSearch(user, q, offset, limit)
|
|
|
}
|
|
|
|
|
|
func PushPostToSearch(post *core.Post) {
|
|
|
postFormated := post.Format()
|
|
|
postFormated.User = &core.UserFormated{
|
|
|
ID: post.UserID,
|
|
|
}
|
|
|
contents, _ := ds.GetPostContentsByIDs([]int64{post.ID})
|
|
|
for _, content := range contents {
|
|
|
postFormated.Contents = append(postFormated.Contents, content.Format())
|
|
|
}
|
|
|
|
|
|
contentFormated := ""
|
|
|
for _, content := range postFormated.Contents {
|
|
|
if content.Type == core.ContentTypeText || content.Type == core.ContentTypeTitle {
|
|
|
contentFormated = contentFormated + content.Content + "\n"
|
|
|
}
|
|
|
}
|
|
|
|
|
|
docs := []core.TsDocItem{{
|
|
|
Post: post,
|
|
|
Content: contentFormated,
|
|
|
}}
|
|
|
ts.AddDocuments(docs, fmt.Sprintf("%d", post.ID))
|
|
|
}
|
|
|
|
|
|
func DeleteSearchPost(post *core.Post) error {
|
|
|
return ts.DeleteDocuments([]string{fmt.Sprintf("%d", post.ID)})
|
|
|
}
|
|
|
|
|
|
func PushPostsToSearch(c *gin.Context) {
|
|
|
if ok, _ := conf.Redis.SetNX(c, "JOB_PUSH_TO_SEARCH", 1, time.Hour).Result(); ok {
|
|
|
defer conf.Redis.Del(c, "JOB_PUSH_TO_SEARCH")
|
|
|
|
|
|
splitNum := 1000
|
|
|
totalRows, _ := GetPostCount(&core.ConditionsT{
|
|
|
"visibility IN ?": []core.PostVisibleT{core.PostVisitPublic, core.PostVisitFriend},
|
|
|
})
|
|
|
|
|
|
pages := math.Ceil(float64(totalRows) / float64(splitNum))
|
|
|
nums := int(pages)
|
|
|
|
|
|
for i := 0; i < nums; i++ {
|
|
|
posts, postsFormated, err := GetPostList(&PostListReq{
|
|
|
Conditions: &core.ConditionsT{},
|
|
|
Offset: i * splitNum,
|
|
|
Limit: splitNum,
|
|
|
})
|
|
|
if err != nil || len(posts) != len(postsFormated) {
|
|
|
continue
|
|
|
}
|
|
|
for i, pf := range postsFormated {
|
|
|
contentFormated := ""
|
|
|
for _, content := range pf.Contents {
|
|
|
if content.Type == core.ContentTypeText || content.Type == core.ContentTypeTitle {
|
|
|
contentFormated = contentFormated + content.Content + "\n"
|
|
|
}
|
|
|
}
|
|
|
docs := []core.TsDocItem{{
|
|
|
Post: posts[i],
|
|
|
Content: contentFormated,
|
|
|
}}
|
|
|
ts.AddDocuments(docs, fmt.Sprintf("%d", posts[i].ID))
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func GetPostTags(param *PostTagsReq) ([]*core.TagFormated, error) {
|
|
|
num := param.Num
|
|
|
if num > conf.AppSetting.MaxPageSize {
|
|
|
num = conf.AppSetting.MaxPageSize
|
|
|
}
|
|
|
|
|
|
conditions := &core.ConditionsT{}
|
|
|
if param.Type == TagTypeHot {
|
|
|
// 热门标签
|
|
|
conditions = &core.ConditionsT{
|
|
|
"ORDER": "quote_num DESC",
|
|
|
}
|
|
|
}
|
|
|
if param.Type == TagTypeNew {
|
|
|
// 热门标签
|
|
|
conditions = &core.ConditionsT{
|
|
|
"ORDER": "id DESC",
|
|
|
}
|
|
|
}
|
|
|
|
|
|
tags, err := ds.GetTags(conditions, 0, num)
|
|
|
if err != nil {
|
|
|
return nil, err
|
|
|
}
|
|
|
|
|
|
// 获取创建者User IDs
|
|
|
userIds := []int64{}
|
|
|
for _, tag := range tags {
|
|
|
userIds = append(userIds, tag.UserID)
|
|
|
}
|
|
|
|
|
|
users, _ := ds.GetUsersByIDs(userIds)
|
|
|
|
|
|
tagsFormated := []*core.TagFormated{}
|
|
|
for _, tag := range tags {
|
|
|
tagFormated := tag.Format()
|
|
|
for _, user := range users {
|
|
|
if user.ID == tagFormated.UserID {
|
|
|
tagFormated.User = user.Format()
|
|
|
}
|
|
|
}
|
|
|
tagsFormated = append(tagsFormated, tagFormated)
|
|
|
}
|
|
|
|
|
|
return tagsFormated, nil
|
|
|
}
|
|
|
|
|
|
func CheckPostAttachmentIsPaid(postID, userID int64) bool {
|
|
|
bill, err := ds.GetPostAttatchmentBill(postID, userID)
|
|
|
|
|
|
return err == nil && bill.Model != nil && bill.ID > 0
|
|
|
}
|