450 lines
15 KiB
Go
450 lines
15 KiB
Go
/*
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
|
|
package activity
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/apache/answer/internal/service/activity_common"
|
|
metacommon "github.com/apache/answer/internal/service/meta_common"
|
|
|
|
"github.com/apache/answer/internal/base/constant"
|
|
"github.com/apache/answer/internal/base/handler"
|
|
"github.com/apache/answer/internal/base/reason"
|
|
"github.com/apache/answer/internal/entity"
|
|
"github.com/apache/answer/internal/schema"
|
|
"github.com/apache/answer/internal/service/comment_common"
|
|
"github.com/apache/answer/internal/service/config"
|
|
"github.com/apache/answer/internal/service/object_info"
|
|
"github.com/apache/answer/internal/service/revision_common"
|
|
"github.com/apache/answer/internal/service/tag_common"
|
|
usercommon "github.com/apache/answer/internal/service/user_common"
|
|
"github.com/apache/answer/pkg/converter"
|
|
"github.com/apache/answer/pkg/obj"
|
|
"github.com/apache/answer/pkg/uid"
|
|
"github.com/segmentfault/pacman/errors"
|
|
"github.com/segmentfault/pacman/log"
|
|
)
|
|
|
|
// ActivityRepo activity repository
|
|
type ActivityRepo interface {
|
|
GetObjectAllActivity(ctx context.Context, objectID string, showVote bool) (activityList []*entity.Activity, err error)
|
|
}
|
|
|
|
// ActivityService activity service
|
|
type ActivityService struct {
|
|
activityRepo ActivityRepo
|
|
userCommon *usercommon.UserCommon
|
|
activityCommonService *activity_common.ActivityCommon
|
|
tagCommonService *tag_common.TagCommonService
|
|
objectInfoService *object_info.ObjService
|
|
commentCommonService *comment_common.CommentCommonService
|
|
revisionService *revision_common.RevisionService
|
|
metaService *metacommon.MetaCommonService
|
|
configService *config.ConfigService
|
|
}
|
|
|
|
// NewActivityService new activity service
|
|
func NewActivityService(
|
|
activityRepo ActivityRepo,
|
|
userCommon *usercommon.UserCommon,
|
|
activityCommonService *activity_common.ActivityCommon,
|
|
tagCommonService *tag_common.TagCommonService,
|
|
objectInfoService *object_info.ObjService,
|
|
commentCommonService *comment_common.CommentCommonService,
|
|
revisionService *revision_common.RevisionService,
|
|
metaService *metacommon.MetaCommonService,
|
|
configService *config.ConfigService,
|
|
) *ActivityService {
|
|
return &ActivityService{
|
|
objectInfoService: objectInfoService,
|
|
activityRepo: activityRepo,
|
|
userCommon: userCommon,
|
|
activityCommonService: activityCommonService,
|
|
tagCommonService: tagCommonService,
|
|
commentCommonService: commentCommonService,
|
|
revisionService: revisionService,
|
|
metaService: metaService,
|
|
configService: configService,
|
|
}
|
|
}
|
|
|
|
// GetObjectTimeline get object timeline
|
|
func (as *ActivityService) GetObjectTimeline(ctx context.Context, req *schema.GetObjectTimelineReq) (
|
|
resp *schema.GetObjectTimelineResp, err error) {
|
|
if err = as.ensureTimelineObjectVisible(ctx, req.ObjectID, req.UserID, req.IsAdminModerator); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp = &schema.GetObjectTimelineResp{
|
|
ObjectInfo: &schema.ActObjectInfo{},
|
|
Timeline: make([]*schema.ActObjectTimeline, 0),
|
|
}
|
|
|
|
resp.ObjectInfo, err = as.getTimelineMainObjInfo(ctx, req.ObjectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
activityList, err := as.activityRepo.GetObjectAllActivity(ctx, req.ObjectID, req.ShowVote)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, act := range activityList {
|
|
item := &schema.ActObjectTimeline{
|
|
ActivityID: act.ID,
|
|
RevisionID: converter.IntToString(act.RevisionID),
|
|
CreatedAt: act.CreatedAt.Unix(),
|
|
Cancelled: act.Cancelled == entity.ActivityCancelled,
|
|
ObjectID: act.ObjectID,
|
|
UserInfo: &schema.UserBasicInfo{},
|
|
}
|
|
item.ObjectType, _ = obj.GetObjectTypeStrByObjectID(act.ObjectID)
|
|
if item.Cancelled {
|
|
item.CancelledAt = act.CancelledAt.Unix()
|
|
}
|
|
|
|
if item.ObjectType == constant.QuestionObjectType || item.ObjectType == constant.AnswerObjectType {
|
|
if handler.GetEnableShortID(ctx) {
|
|
item.ObjectID = uid.EnShortID(act.ObjectID)
|
|
}
|
|
}
|
|
|
|
cfg, err := as.configService.GetConfigByID(ctx, act.ActivityType)
|
|
if err != nil {
|
|
log.Errorf("fail to get config by id: %d, err: %v, act id is: %s", act.ActivityType, err, act.ID)
|
|
} else {
|
|
// database save activity type is number, change to activity type string is like "question.asked".
|
|
// so we need to cut the front part of '.', only need string like 'asked'
|
|
_, item.ActivityType, _ = strings.Cut(cfg.Key, ".")
|
|
// format activity type string to show
|
|
if isHidden, formattedActivityType := formatActivity(item.ActivityType); isHidden {
|
|
continue
|
|
} else {
|
|
item.ActivityType = formattedActivityType
|
|
}
|
|
}
|
|
|
|
// if activity is down vote, only admin can see who does it.
|
|
if item.ActivityType == constant.ActDownVote && !req.IsAdmin {
|
|
item.UserInfo.Username = "N/A"
|
|
item.UserInfo.DisplayName = "N/A"
|
|
} else {
|
|
if act.TriggerUserID > 0 {
|
|
item.UserInfo.ID = fmt.Sprintf("%d", act.TriggerUserID)
|
|
} else {
|
|
item.UserInfo.ID = act.UserID
|
|
}
|
|
}
|
|
|
|
item.Comment = as.getTimelineActivityComment(ctx, item.ObjectID, item.ObjectType, item.ActivityType, item.RevisionID)
|
|
resp.Timeline = append(resp.Timeline, item)
|
|
}
|
|
as.formatTimelineUserInfo(ctx, resp.Timeline)
|
|
return
|
|
}
|
|
|
|
func (as *ActivityService) getTimelineMainObjInfo(ctx context.Context, objectID string) (
|
|
resp *schema.ActObjectInfo, err error) {
|
|
resp = &schema.ActObjectInfo{}
|
|
objInfo, err := as.objectInfoService.GetInfo(ctx, objectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp.Title = objInfo.Title
|
|
if objInfo.ObjectType == constant.TagObjectType {
|
|
tag, exist, _ := as.tagCommonService.GetTagByID(ctx, objInfo.TagID)
|
|
if exist {
|
|
resp.Title = tag.SlugName
|
|
resp.MainTagSlugName = tag.MainTagSlugName
|
|
}
|
|
}
|
|
resp.ObjectType = objInfo.ObjectType
|
|
resp.QuestionID = objInfo.QuestionID
|
|
resp.AnswerID = objInfo.AnswerID
|
|
if len(objInfo.ObjectCreatorUserID) > 0 {
|
|
// get object creator user info
|
|
userBasicInfo, exist, err := as.userCommon.GetUserBasicInfoByID(ctx, objInfo.ObjectCreatorUserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if exist {
|
|
resp.Username = userBasicInfo.Username
|
|
resp.DisplayName = userBasicInfo.DisplayName
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (as *ActivityService) getTimelineActivityComment(ctx context.Context, objectID, objectType,
|
|
activityType, revisionID string) (comment string) {
|
|
if objectType == constant.CommentObjectType {
|
|
commentInfo, err := as.commentCommonService.GetComment(ctx, objectID)
|
|
if err != nil {
|
|
log.Error(err)
|
|
} else {
|
|
return commentInfo.ParsedText
|
|
}
|
|
return
|
|
}
|
|
|
|
if activityType == constant.ActEdited {
|
|
revision, err := as.revisionService.GetRevision(ctx, revisionID)
|
|
if err != nil {
|
|
log.Error(err)
|
|
} else {
|
|
return converter.Markdown2HTML(revision.Log)
|
|
}
|
|
return
|
|
}
|
|
if activityType == constant.ActClosed {
|
|
// only question can be closed
|
|
metaInfo, err := as.metaService.GetMetaByObjectIdAndKey(ctx, objectID, entity.QuestionCloseReasonKey)
|
|
if err != nil {
|
|
log.Error(err)
|
|
} else {
|
|
closeMsg := &schema.CloseQuestionMeta{}
|
|
if err := json.Unmarshal([]byte(metaInfo.Value), closeMsg); err == nil {
|
|
return converter.Markdown2HTML(closeMsg.CloseMsg)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (as *ActivityService) formatTimelineUserInfo(ctx context.Context, timeline []*schema.ActObjectTimeline) {
|
|
userExist := make(map[string]bool)
|
|
userIDs := make([]string, 0)
|
|
for _, info := range timeline {
|
|
if len(info.UserInfo.ID) == 0 || userExist[info.UserInfo.ID] {
|
|
continue
|
|
}
|
|
userIDs = append(userIDs, info.UserInfo.ID)
|
|
}
|
|
if len(userIDs) == 0 {
|
|
return
|
|
}
|
|
userInfoMapping, err := as.userCommon.BatchUserBasicInfoByID(ctx, userIDs)
|
|
if err != nil {
|
|
log.Error(err)
|
|
return
|
|
}
|
|
for _, info := range timeline {
|
|
if len(info.UserInfo.ID) == 0 {
|
|
continue
|
|
}
|
|
info.UserInfo = userInfoMapping[info.UserInfo.ID]
|
|
}
|
|
}
|
|
|
|
// GetObjectTimelineDetail get object timeline
|
|
func (as *ActivityService) GetObjectTimelineDetail(ctx context.Context, req *schema.GetObjectTimelineDetailReq) (
|
|
resp *schema.GetObjectTimelineDetailResp, err error) {
|
|
if err = as.ensureTimelineRevisionVisible(ctx, req.NewRevisionID, req.UserID, req.IsAdminModerator); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = as.ensureTimelineRevisionVisible(ctx, req.OldRevisionID, req.UserID, req.IsAdminModerator); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp = &schema.GetObjectTimelineDetailResp{}
|
|
resp.OldRevision, _ = as.getOneObjectDetail(ctx, req.OldRevisionID)
|
|
resp.NewRevision, _ = as.getOneObjectDetail(ctx, req.NewRevisionID)
|
|
return resp, nil
|
|
}
|
|
|
|
func (as *ActivityService) ensureTimelineRevisionVisible(ctx context.Context, revisionID, userID string,
|
|
isAdminModerator bool) error {
|
|
if revisionID == "0" {
|
|
return nil
|
|
}
|
|
revisionInfo, err := as.revisionService.GetRevision(ctx, revisionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return as.ensureTimelineObjectVisible(ctx, revisionInfo.ObjectID, userID, isAdminModerator)
|
|
}
|
|
|
|
func (as *ActivityService) ensureTimelineObjectVisible(ctx context.Context, objectID, userID string,
|
|
isAdminModerator bool) error {
|
|
objInfo, err := as.objectInfoService.GetInfo(ctx, objectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var parentQuestionInfo *schema.SimpleObjectInfo
|
|
if objInfo.ObjectType != constant.QuestionObjectType && len(objInfo.QuestionID) > 0 && objInfo.QuestionID != "0" {
|
|
parentQuestionInfo, err = as.objectInfoService.GetInfo(ctx, objInfo.QuestionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return validateTimelineObjectVisibility(objInfo, parentQuestionInfo, userID, isAdminModerator)
|
|
}
|
|
|
|
func validateTimelineObjectVisibility(objInfo, parentQuestionInfo *schema.SimpleObjectInfo,
|
|
userID string, isAdminModerator bool) error {
|
|
if objInfo == nil {
|
|
return errors.NotFound(reason.ObjectNotFound)
|
|
}
|
|
if isTimelineObjectRestricted(objInfo) &&
|
|
!canViewRestrictedTimelineObject(objInfo.ObjectType, objInfo.ObjectCreatorUserID, userID, isAdminModerator) {
|
|
return errors.NotFound(timelineNotFoundReasonByObjectType(objInfo.ObjectType))
|
|
}
|
|
if parentQuestionInfo != nil && isTimelineQuestionRestricted(parentQuestionInfo) &&
|
|
!canViewRestrictedTimelineObject(parentQuestionInfo.ObjectType, parentQuestionInfo.ObjectCreatorUserID,
|
|
userID, isAdminModerator) {
|
|
return errors.NotFound(reason.QuestionNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func canViewRestrictedTimelineObject(objectType, creatorUserID, userID string, isAdminModerator bool) bool {
|
|
if isAdminModerator {
|
|
return true
|
|
}
|
|
switch objectType {
|
|
case constant.QuestionObjectType, constant.AnswerObjectType, constant.CommentObjectType:
|
|
return creatorUserID == userID
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isTimelineObjectRestricted(objInfo *schema.SimpleObjectInfo) bool {
|
|
switch objInfo.ObjectType {
|
|
case constant.QuestionObjectType:
|
|
return isTimelineQuestionRestricted(objInfo)
|
|
case constant.AnswerObjectType:
|
|
return objInfo.AnswerStatus == entity.AnswerStatusDeleted || objInfo.AnswerStatus == entity.AnswerStatusPending
|
|
case constant.CommentObjectType:
|
|
return objInfo.CommentStatus == entity.CommentStatusDeleted || objInfo.CommentStatus == entity.CommentStatusPending
|
|
case constant.TagObjectType:
|
|
return objInfo.TagStatus == entity.TagStatusDeleted
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isTimelineQuestionRestricted(questionInfo *schema.SimpleObjectInfo) bool {
|
|
return questionInfo.QuestionStatus == entity.QuestionStatusDeleted ||
|
|
questionInfo.QuestionStatus == entity.QuestionStatusPending ||
|
|
questionInfo.QuestionShow == entity.QuestionHide
|
|
}
|
|
|
|
func timelineNotFoundReasonByObjectType(objectType string) string {
|
|
switch objectType {
|
|
case constant.QuestionObjectType:
|
|
return reason.QuestionNotFound
|
|
case constant.AnswerObjectType:
|
|
return reason.AnswerNotFound
|
|
case constant.CommentObjectType:
|
|
return reason.CommentNotFound
|
|
case constant.TagObjectType:
|
|
return reason.TagNotFound
|
|
default:
|
|
return reason.ObjectNotFound
|
|
}
|
|
}
|
|
|
|
// getOneObjectDetail get object detail
|
|
func (as *ActivityService) getOneObjectDetail(ctx context.Context, revisionID string) (
|
|
resp *schema.ObjectTimelineDetail, err error) {
|
|
resp = &schema.ObjectTimelineDetail{Tags: make([]*schema.ObjectTimelineTag, 0)}
|
|
|
|
// if request revision is 0, return null object detail.
|
|
if revisionID == "0" {
|
|
return nil, nil
|
|
}
|
|
|
|
revision, err := as.revisionService.GetRevision(ctx, revisionID)
|
|
if err != nil {
|
|
log.Warn(err)
|
|
return nil, nil
|
|
}
|
|
objInfo, err := as.objectInfoService.GetInfo(ctx, revision.ObjectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch objInfo.ObjectType {
|
|
case constant.QuestionObjectType:
|
|
data := &entity.QuestionWithTagsRevision{}
|
|
if err = json.Unmarshal([]byte(revision.Content), data); err != nil {
|
|
log.Errorf("revision parsing error %s", err)
|
|
return resp, nil
|
|
}
|
|
for _, tag := range data.Tags {
|
|
resp.Tags = append(resp.Tags, &schema.ObjectTimelineTag{
|
|
SlugName: tag.SlugName,
|
|
DisplayName: tag.DisplayName,
|
|
MainTagSlugName: tag.MainTagSlugName,
|
|
Recommend: tag.Recommend,
|
|
Reserved: tag.Reserved,
|
|
})
|
|
}
|
|
resp.Title = data.Title
|
|
resp.OriginalText = data.OriginalText
|
|
case constant.AnswerObjectType:
|
|
data := &entity.Answer{}
|
|
if err = json.Unmarshal([]byte(revision.Content), data); err != nil {
|
|
log.Errorf("revision parsing error %s", err)
|
|
return resp, nil
|
|
}
|
|
resp.Title = objInfo.Title // answer show question title
|
|
resp.OriginalText = data.OriginalText
|
|
case constant.TagObjectType:
|
|
data := &entity.Tag{}
|
|
if err = json.Unmarshal([]byte(revision.Content), data); err != nil {
|
|
log.Errorf("revision parsing error %s", err)
|
|
return resp, nil
|
|
}
|
|
resp.Title = data.DisplayName
|
|
resp.OriginalText = data.OriginalText
|
|
resp.SlugName = data.SlugName
|
|
resp.MainTagSlugName = data.MainTagSlugName
|
|
default:
|
|
log.Errorf("unknown object type %s", objInfo.ObjectType)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func formatActivity(activityType string) (isHidden bool, formattedActivityType string) {
|
|
if activityType == constant.ActVotedUp ||
|
|
activityType == constant.ActVotedDown ||
|
|
activityType == constant.ActFollow {
|
|
return true, ""
|
|
}
|
|
if activityType == constant.ActVoteUp {
|
|
return false, constant.ActUpVote
|
|
}
|
|
if activityType == constant.ActVoteDown {
|
|
return false, constant.ActDownVote
|
|
}
|
|
if activityType == constant.ActAccepted {
|
|
return false, constant.ActAccept
|
|
}
|
|
return false, activityType
|
|
}
|