// 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 web import ( "image" "strings" "time" "github.com/alimy/mir/v3" "github.com/disintegration/imaging" "github.com/gin-gonic/gin" "github.com/gofrs/uuid" api "github.com/rocboss/paopao-ce/auto/api/v1" "github.com/rocboss/paopao-ce/internal/conf" "github.com/rocboss/paopao-ce/internal/core" "github.com/rocboss/paopao-ce/internal/core/cs" "github.com/rocboss/paopao-ce/internal/model/web" "github.com/rocboss/paopao-ce/internal/servants/base" "github.com/rocboss/paopao-ce/internal/servants/chain" "github.com/rocboss/paopao-ce/pkg/convert" "github.com/rocboss/paopao-ce/pkg/utils" "github.com/rocboss/paopao-ce/pkg/xerror" "github.com/sirupsen/logrus" ) var ( _ api.Priv = (*privSrv)(nil) _ api.PrivBinding = (*privBinding)(nil) _ api.PrivRender = (*privRender)(nil) _uploadAttachmentTypeMap = map[string]cs.AttachmentType{ "public/image": cs.AttachmentTypeImage, "public/avatar": cs.AttachmentTypeImage, "public/video": cs.AttachmentTypeVideo, "attachment": cs.AttachmentTypeOther, } ) type privSrv struct { api.UnimplementedPrivServant *base.DaoServant oss core.ObjectStorageService } type privBinding struct { *api.UnimplementedPrivBinding } type privRender struct { *api.UnimplementedPrivRender } func (b *privBinding) BindUploadAttachment(c *gin.Context) (_ *web.UploadAttachmentReq, xerr mir.Error) { UserId, exist := base.UserIdFrom(c) if !exist { return nil, xerror.UnauthorizedAuthNotExist } uploadType := c.Request.FormValue("type") file, fileHeader, err := c.Request.FormFile("file") if err != nil { return nil, _errFileUploadFailed } defer func() { if xerr != nil { file.Close() } }() if err := fileCheck(uploadType, fileHeader.Size); err != nil { return nil, err } contentType := fileHeader.Header.Get("Content-Type") fileExt, xerr := getFileExt(contentType) if xerr != nil { return nil, xerr } return &web.UploadAttachmentReq{ SimpleInfo: web.SimpleInfo{ Uid: UserId, }, UploadType: uploadType, ContentType: contentType, File: file, FileSize: fileHeader.Size, FileExt: fileExt, }, nil } func (b *privBinding) BindDownloadAttachmentPrecheck(c *gin.Context) (*web.DownloadAttachmentPrecheckReq, mir.Error) { user, exist := base.UserFrom(c) if !exist { return nil, xerror.UnauthorizedAuthNotExist } return &web.DownloadAttachmentPrecheckReq{ BaseInfo: web.BaseInfo{ User: user, }, ContentID: convert.StrTo(c.Query("id")).MustInt64(), }, nil } func (b *privBinding) BindDownloadAttachment(c *gin.Context) (*web.DownloadAttachmentReq, mir.Error) { user, exist := base.UserFrom(c) if !exist { return nil, xerror.UnauthorizedAuthNotExist } return &web.DownloadAttachmentReq{ BaseInfo: web.BaseInfo{ User: user, }, ContentID: convert.StrTo(c.Query("id")).MustInt64(), }, nil } func (s *privBinding) BindCreateTweet(c *gin.Context) (*web.CreateTweetReq, mir.Error) { v := &web.CreateTweetReq{} err := s.BindAny(c, v) v.ClientIP = c.ClientIP() return v, err } func (s *privBinding) BindCreateCommentReply(c *gin.Context) (*web.CreateCommentReplyReq, mir.Error) { v := &web.CreateCommentReplyReq{} err := s.BindAny(c, v) v.ClientIP = c.ClientIP() return v, err } func (s *privBinding) BindCreateComment(c *gin.Context) (*web.CreateCommentReq, mir.Error) { v := &web.CreateCommentReq{} err := s.BindAny(c, v) v.ClientIP = c.ClientIP() return v, err } func (s *privSrv) Chain() gin.HandlersChain { return gin.HandlersChain{chain.JWT(), chain.Priv()} } func (s *privSrv) UploadAttachment(req *web.UploadAttachmentReq) (*web.UploadAttachmentResp, mir.Error) { defer req.File.Close() // 生成随机路径 randomPath := uuid.Must(uuid.NewV4()).String() ossSavePath := req.UploadType + "/" + generatePath(randomPath[:8]) + "/" + randomPath[9:] + req.FileExt objectUrl, err := s.oss.PutObject(ossSavePath, req.File, req.FileSize, req.ContentType, false) if err != nil { logrus.Errorf("oss.putObject err: %s", err) return nil, _errFileUploadFailed } // 构造附件Model attachment := &cs.Attachment{ UserID: req.Uid, FileSize: req.FileSize, Content: objectUrl, Type: _uploadAttachmentTypeMap[req.UploadType], } if attachment.Type == cs.AttachmentTypeImage { var src image.Image src, err = imaging.Decode(req.File) if err == nil { attachment.ImgWidth, attachment.ImgHeight = getImageSize(src.Bounds()) } } attachment.ID, err = s.Ds.CreateAttachment(attachment) if err != nil { logrus.Errorf("Ds.CreateAttachment err: %s", err) return nil, _errFileUploadFailed } return &web.UploadAttachmentResp{ UserID: req.Uid, FileSize: req.FileSize, ImgWidth: attachment.ImgWidth, ImgHeight: attachment.ImgHeight, Type: attachment.Type, Content: attachment.Content, }, nil } func (s *privSrv) DownloadAttachmentPrecheck(req *web.DownloadAttachmentPrecheckReq) (*web.DownloadAttachmentPrecheckResp, mir.Error) { content, err := s.Ds.GetPostContentByID(req.ContentID) if err != nil { logrus.Errorf("Ds.GetPostContentByID err: %s", err) return nil, _errInvalidDownloadReq } resp := &web.DownloadAttachmentPrecheckResp{Paid: true} if content.Type == core.ContentTypeChargeAttachment { tweet, err := s.GetTweetBy(content.PostID) if err != nil { logrus.Errorf("get tweet err: %v", err) return nil, _errInvalidDownloadReq } // 发布者或管理员免费下载 if tweet.UserID == req.User.ID || req.User.IsAdmin { return resp, nil } // 检测是否有购买记录 resp.Paid = s.checkPostAttachmentIsPaid(req.ContentID, req.User.ID) } return resp, nil } func (s *privSrv) DownloadAttachment(req *web.DownloadAttachmentReq) (*web.DownloadAttachmentResp, mir.Error) { content, err := s.Ds.GetPostContentByID(req.ContentID) if err != nil { logrus.Errorf("s.GetPostContentByID err: %v", err) return nil, _errInvalidDownloadReq } // 收费附件 if content.Type == core.ContentTypeChargeAttachment { post, err := s.GetTweetBy(content.PostID) if err != nil { logrus.Errorf("s.GetTweetBy err: %v", err) return nil, xerror.ServerError } paidFlag := false // 发布者或管理员免费下载 或者 检测是否有购买记录 if post.UserID == req.User.ID || req.User.IsAdmin || s.checkPostAttachmentIsPaid(post.ID, req.User.ID) { paidFlag = true } // 未购买,则尝试购买 if !paidFlag { err := s.buyPostAttachment(&core.Post{ Model: &core.Model{ ID: post.ID, }, UserID: post.UserID, AttachmentPrice: post.AttachmentPrice, }, req.User) if err != nil { return nil, err } } } // 签发附件下载链接 objectKey := s.oss.ObjectKey(content.Content) signedURL, err := s.oss.SignURL(objectKey, 60) if err != nil { logrus.Errorf("client.SignURL err: %v", err) return nil, _errDownloadReqError } return &web.DownloadAttachmentResp{ SignedURL: signedURL, }, nil } func (s *privSrv) CreateTweet(req *web.CreateTweetReq) (_ *web.CreateTweetResp, xerr mir.Error) { var mediaContents []string defer func() { if xerr != nil { deleteOssObjects(s.oss, mediaContents) } }() contents, err := persistMediaContents(s.oss, req.Contents) if err != nil { return nil, _errCreatePostFailed } mediaContents = contents tags := tagsFrom(req.Tags) post := &core.Post{ UserID: req.User.ID, Tags: strings.Join(tags, ","), IP: req.ClientIP, IPLoc: utils.GetIPLoc(req.ClientIP), AttachmentPrice: req.AttachmentPrice, Visibility: req.Visibility, } post, err = s.Ds.CreatePost(post) if err != nil { logrus.Errorf("Ds.CreatePost err: %s", err) return nil, _errCreatePostFailed } // 创建推文内容 for _, item := range req.Contents { if err := item.Check(s.Ds); err != nil { // 属性非法 logrus.Infof("contents check err: %s", err) continue } if item.Type == core.ContentTypeAttachment && req.AttachmentPrice > 0 { item.Type = core.ContentTypeChargeAttachment } postContent := &core.PostContent{ PostID: post.ID, UserID: req.User.ID, Content: item.Content, Type: item.Type, Sort: item.Sort, } if _, err = s.Ds.CreatePostContent(postContent); err != nil { logrus.Infof("Ds.CreatePostContent err: %s", err) return nil, _errCreateCommentFailed } } // 私密推文不创建标签与用户提醒 if post.Visibility != core.PostVisitPrivate { // 创建标签 s.Ds.UpsertTags(req.User.ID, tags) // 创建用户消息提醒 for _, u := range req.Users { user, err := s.Ds.GetUserByUsername(u) if err != nil || user.ID == req.User.ID { continue } // 创建消息提醒 // TODO: 优化消息提醒处理机制 go s.Ds.CreateMessage(&core.Message{ SenderUserID: req.User.ID, ReceiverUserID: user.ID, Type: core.MsgTypePost, Brief: "在新发布的泡泡动态中@了你", PostID: post.ID, }) } } // 推送Search s.PushPostToSearch(post) formatedPosts, err := s.Ds.RevampPosts([]*core.PostFormated{post.Format()}) if err != nil { logrus.Infof("Ds.RevampPosts err: %s", err) return nil, _errCreatePostFailed } return (*web.CreateTweetResp)(formatedPosts[0]), nil } func (s *privSrv) DeleteTweet(req *web.DeleteTweetReq) mir.Error { if req.User == nil { return _errNoPermission } post, err := s.Ds.GetPostByID(req.ID) if err != nil { logrus.Errorf("Ds.GetPostByID err: %s", err) return _errGetPostFailed } if post.UserID != req.User.ID && !req.User.IsAdmin { return _errNoPermission } mediaContents, err := s.Ds.DeletePost(post) if err != nil { logrus.Errorf("Ds.DeletePost delete post failed: %s", err) return _errDeletePostFailed } // 删除推文的媒体内容 deleteOssObjects(s.oss, mediaContents) // 删除索引 s.DeleteSearchPost(post) if err != nil { logrus.Errorf("s.DeleteSearchPost failed: %s", err) return _errDeletePostFailed } return nil } func (s *privSrv) DeleteCommentReply(req *web.DeleteCommentReplyReq) mir.Error { reply, err := s.Ds.GetCommentReplyByID(req.ID) if err != nil { logrus.Errorf("Ds.GetCommentReplyByID err: %s", err) return _errGetReplyFailed } if req.User.ID != reply.UserID && !req.User.IsAdmin { return _errNoPermission } // 执行删除 err = s.deletePostCommentReply(reply) if err != nil { logrus.Errorf("s.deletePostCommentReply err: %s", err) return _errDeleteCommentFailed } return nil } func (s *privSrv) CreateCommentReply(req *web.CreateCommentReplyReq) (*web.CreateCommentReplyResp, mir.Error) { var ( post *core.Post comment *core.Comment atUserID int64 err error ) if post, comment, atUserID, err = s.createPostPreHandler(req.CommentID, req.Uid, req.AtUserID); err != nil { return nil, _errCreateReplyFailed } // 创建评论 reply := &core.CommentReply{ CommentID: req.CommentID, UserID: req.Uid, Content: req.Content, AtUserID: atUserID, IP: req.ClientIP, IPLoc: utils.GetIPLoc(req.ClientIP), } reply, err = s.Ds.CreateCommentReply(reply) if err != nil { return nil, _errCreateReplyFailed } // 更新Post回复数 post.CommentCount++ post.LatestRepliedOn = time.Now().Unix() s.Ds.UpdatePost(post) // 更新索引 s.PushPostToSearch(post) // 创建用户消息提醒 commentMaster, err := s.Ds.GetUserByID(comment.UserID) if err == nil && commentMaster.ID != req.Uid { go s.Ds.CreateMessage(&core.Message{ SenderUserID: req.Uid, ReceiverUserID: commentMaster.ID, Type: core.MsgTypeReply, Brief: "在泡泡评论下回复了你", PostID: post.ID, CommentID: comment.ID, ReplyID: reply.ID, }) } postMaster, err := s.Ds.GetUserByID(post.UserID) if err == nil && postMaster.ID != req.Uid && commentMaster.ID != postMaster.ID { go s.Ds.CreateMessage(&core.Message{ SenderUserID: req.Uid, ReceiverUserID: postMaster.ID, Type: core.MsgTypeReply, Brief: "在泡泡评论下发布了新回复", PostID: post.ID, CommentID: comment.ID, ReplyID: reply.ID, }) } if atUserID > 0 { user, err := s.Ds.GetUserByID(atUserID) if err == nil && user.ID != req.Uid && commentMaster.ID != user.ID && postMaster.ID != user.ID { // 创建消息提醒 go s.Ds.CreateMessage(&core.Message{ SenderUserID: req.Uid, ReceiverUserID: user.ID, Type: core.MsgTypeReply, Brief: "在泡泡评论的回复中@了你", PostID: post.ID, CommentID: comment.ID, ReplyID: reply.ID, }) } } return (*web.CreateCommentReplyResp)(reply), nil } func (s *privSrv) DeleteComment(req *web.DeleteCommentReq) mir.Error { comment, err := s.Ds.GetCommentByID(req.ID) if err != nil { logrus.Errorf("Ds.GetCommentByID err: %v\n", err) return _errGetCommentFailed } if req.User.ID != comment.UserID && !req.User.IsAdmin { return _errNoPermission } // 加载post post, err := s.Ds.GetPostByID(comment.PostID) if err != nil { return _errDeleteCommentFailed } // 更新post回复数 post.CommentCount-- if err := s.Ds.UpdatePost(post); err != nil { logrus.Errorf("Ds.UpdatePost err: %s", err) return _errDeleteCommentFailed } // TODO: 优化删除逻辑,事务化删除comment if err := s.Ds.DeleteComment(comment); err != nil { logrus.Errorf("Ds.DeleteComment err: %s", err) return _errDeleteCommentFailed } return nil } func (s *privSrv) CreateComment(req *web.CreateCommentReq) (_ *web.CreateCommentResp, xerr mir.Error) { var ( mediaContents []string err error ) defer func() { if xerr != nil { deleteOssObjects(s.oss, mediaContents) } }() if mediaContents, err = persistMediaContents(s.oss, req.Contents); err != nil { return nil, xerror.ServerError } // 加载Post post, err := s.Ds.GetPostByID(req.PostID) if err != nil { logrus.Errorf("Ds.GetPostByID err:%s", err) return nil, xerror.ServerError } if post.CommentCount >= conf.AppSetting.MaxCommentCount { return nil, _errMaxCommentCount } comment := &core.Comment{ PostID: post.ID, UserID: req.Uid, IP: req.ClientIP, IPLoc: utils.GetIPLoc(req.ClientIP), } comment, err = s.Ds.CreateComment(comment) if err != nil { logrus.Errorf("Ds.CreateComment err:%s", err) return nil, _errCreateCommentFailed } for _, item := range req.Contents { // 检查附件是否是本站资源 if item.Type == core.ContentTypeImage || item.Type == core.ContentTypeVideo || item.Type == core.ContentTypeAttachment { if err := s.Ds.CheckAttachment(item.Content); err != nil { continue } } postContent := &core.CommentContent{ CommentID: comment.ID, UserID: req.Uid, Content: item.Content, Type: item.Type, Sort: item.Sort, } s.Ds.CreateCommentContent(postContent) } // 更新Post回复数 post.CommentCount++ post.LatestRepliedOn = time.Now().Unix() s.Ds.UpdatePost(post) // 更新索引 s.PushPostToSearch(post) // 创建用户消息提醒 postMaster, err := s.Ds.GetUserByID(post.UserID) if err == nil && postMaster.ID != req.Uid { go s.Ds.CreateMessage(&core.Message{ SenderUserID: req.Uid, ReceiverUserID: postMaster.ID, Type: core.MsgtypeComment, Brief: "在泡泡中评论了你", PostID: post.ID, CommentID: comment.ID, }) } for _, u := range req.Users { user, err := s.Ds.GetUserByUsername(u) if err != nil || user.ID == req.Uid || user.ID == postMaster.ID { continue } // 创建消息提醒 go s.Ds.CreateMessage(&core.Message{ SenderUserID: req.Uid, ReceiverUserID: user.ID, Type: core.MsgtypeComment, Brief: "在泡泡评论中@了你", PostID: post.ID, CommentID: comment.ID, }) } return (*web.CreateCommentResp)(comment), nil } func (s *privSrv) CollectionTweet(req *web.CollectionTweetReq) (*web.CollectionTweetResp, mir.Error) { status := false collection, err := s.Ds.GetUserPostCollection(req.ID, req.Uid) if err != nil { // 创建Star if _, xerr := s.createPostCollection(req.ID, req.Uid); xerr != nil { return nil, xerr } status = true } else { // 取消Star if xerr := s.deletePostCollection(collection); xerr != nil { return nil, xerr } } return &web.CollectionTweetResp{ Status: status, }, nil } func (s *privSrv) StarTweet(req *web.StarTweetReq) (*web.StarTweetResp, mir.Error) { status := false star, err := s.Ds.GetUserPostStar(req.ID, req.Uid) if err != nil { // 创建Star if _, xerr := s.createPostStar(req.ID, req.Uid); xerr != nil { return nil, xerr } status = true } else { // 取消Star if xerr := s.deletePostStar(star); xerr != nil { return nil, xerr } } return &web.StarTweetResp{ Status: status, }, nil } func (s *privSrv) VisibleTweet(req *web.VisibleTweetReq) (*web.VisibleTweetResp, mir.Error) { if req.Visibility >= core.PostVisitInvalid { return nil, xerror.InvalidParams } post, err := s.Ds.GetPostByID(req.ID) if err != nil { return nil, _errVisblePostFailed } if xerr := checkPermision(req.User, post.UserID); xerr != nil { return nil, xerr } if err = s.Ds.VisiblePost(post, req.Visibility); err != nil { logrus.Warnf("s.Ds.VisiblePost: %s", err) return nil, _errVisblePostFailed } // 推送Search post.Visibility = req.Visibility s.PushPostToSearch(post) return &web.VisibleTweetResp{ Visibility: req.Visibility, }, nil } func (s *privSrv) StickTweet(req *web.StickTweetReq) (*web.StickTweetResp, mir.Error) { post, err := s.Ds.GetPostByID(req.ID) if err != nil { logrus.Errorf("Ds.GetPostByID err: %v\n", err) return nil, _errStickPostFailed } if !req.User.IsAdmin { return nil, _errNoPermission } newStatus := 1 - post.IsTop if err = s.Ds.StickPost(post); err != nil { return nil, _errStickPostFailed } return &web.StickTweetResp{ StickStatus: newStatus, }, nil } func (s *privSrv) LockTweet(req *web.LockTweetReq) (*web.LockTweetResp, mir.Error) { post, err := s.Ds.GetPostByID(req.ID) if err != nil { return nil, _errLockPostFailed } if post.UserID != req.User.ID && !req.User.IsAdmin { return nil, _errNoPermission } newStatus := 1 - post.IsLock if err := s.Ds.LockPost(post); err != nil { return nil, _errLockPostFailed } return &web.LockTweetResp{ LockStatus: newStatus, }, nil } func (s *privSrv) deletePostCommentReply(reply *core.CommentReply) error { err := s.Ds.DeleteCommentReply(reply) if err != nil { return err } // 加载Comment comment, err := s.Ds.GetCommentByID(reply.CommentID) if err != nil { return err } // 加载comment的post post, err := s.Ds.GetPostByID(comment.PostID) if err != nil { return err } // 更新Post回复数 post.CommentCount-- post.LatestRepliedOn = time.Now().Unix() s.Ds.UpdatePost(post) // 更新索引 s.PushPostToSearch(post) return nil } func (s *privSrv) createPostPreHandler(commentID int64, userID, atUserID int64) (*core.Post, *core.Comment, int64, error) { // 加载Comment comment, err := s.Ds.GetCommentByID(commentID) if err != nil { return nil, nil, atUserID, err } // 加载comment的post post, err := s.Ds.GetPostByID(comment.PostID) if err != nil { return nil, nil, atUserID, err } if post.CommentCount >= conf.AppSetting.MaxCommentCount { return nil, nil, atUserID, _errMaxCommentCount } if userID == atUserID { atUserID = 0 } if atUserID > 0 { // 检测目前用户是否存在 users, _ := s.Ds.GetUsersByIDs([]int64{atUserID}) if len(users) == 0 { atUserID = 0 } } return post, comment, atUserID, nil } func (s *privSrv) createPostStar(postID, userID int64) (*core.PostStar, mir.Error) { // 加载Post post, err := s.Ds.GetPostByID(postID) if err != nil { return nil, xerror.ServerError } // 私密post不可操作 if post.Visibility == core.PostVisitPrivate { return nil, _errNoPermission } star, err := s.Ds.CreatePostStar(postID, userID) if err != nil { return nil, xerror.ServerError } // 更新Post点赞数 post.UpvoteCount++ s.Ds.UpdatePost(post) // 更新索引 s.PushPostToSearch(post) return star, nil } func (s *privSrv) deletePostStar(star *core.PostStar) mir.Error { err := s.Ds.DeletePostStar(star) if err != nil { return xerror.ServerError } // 加载Post post, err := s.Ds.GetPostByID(star.PostID) if err != nil { return xerror.ServerError } // 私密post不可操作 if post.Visibility == core.PostVisitPrivate { return _errNoPermission } // 更新Post点赞数 post.UpvoteCount-- s.Ds.UpdatePost(post) // 更新索引 s.PushPostToSearch(post) return nil } func (s *privSrv) createPostCollection(postID, userID int64) (*core.PostCollection, mir.Error) { // 加载Post post, err := s.Ds.GetPostByID(postID) if err != nil { return nil, xerror.ServerError } // 私密post不可操作 if post.Visibility == core.PostVisitPrivate { return nil, _errNoPermission } collection, err := s.Ds.CreatePostCollection(postID, userID) if err != nil { return nil, xerror.ServerError } // 更新Post点赞数 post.CollectionCount++ s.Ds.UpdatePost(post) // 更新索引 s.PushPostToSearch(post) return collection, nil } func (s *privSrv) deletePostCollection(collection *core.PostCollection) mir.Error { err := s.Ds.DeletePostCollection(collection) if err != nil { return xerror.ServerError } // 加载Post post, err := s.Ds.GetPostByID(collection.PostID) if err != nil { return xerror.ServerError } // 私密post不可操作 if post.Visibility == core.PostVisitPrivate { return _errNoPermission } // 更新Post点赞数 post.CollectionCount-- s.Ds.UpdatePost(post) // 更新索引 s.PushPostToSearch(post) return nil } func (s *privSrv) checkPostAttachmentIsPaid(postID, userID int64) bool { bill, err := s.Ds.GetPostAttatchmentBill(postID, userID) return err == nil && bill.Model != nil && bill.ID > 0 } func (s *privSrv) buyPostAttachment(post *core.Post, user *core.User) mir.Error { if user.Balance < post.AttachmentPrice { return _errInsuffientDownloadMoney } // 执行购买 if err := s.Ds.HandlePostAttachmentBought(post, user); err != nil { logrus.Errorf("Ds.HandlePostAttachmentBought err: %s", err) return xerror.ServerError } return nil } func newPrivSrv(s *base.DaoServant, oss core.ObjectStorageService) api.Priv { return &privSrv{ DaoServant: s, oss: oss, } } func newPrivBinding() api.PrivBinding { return &privBinding{ UnimplementedPrivBinding: &api.UnimplementedPrivBinding{ BindAny: base.BindAny, }, } } func newPrivRender() api.PrivRender { return &privRender{ UnimplementedPrivRender: &api.UnimplementedPrivRender{ RenderAny: base.RenderAny, }, } }