Merge branch 'feat/1.1.0/report' into test

# Conflicts:
#	internal/migrations/v13.go
This commit is contained in:
LinkinStars 2023-05-24 11:20:51 +08:00
commit 8c395bc306
28 changed files with 896 additions and 32 deletions

View File

@ -1,5 +0,0 @@
{
"recommendations": [
"github.copilot"
]
}

View File

@ -1,6 +1,5 @@
{
"eslint.workingDirectories": [
"ui"
],
"commentTranslate.multiLineMerge": true
]
}

View File

@ -170,7 +170,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
answerActivityRepo := activity.NewAnswerActivityRepo(dataData, activityRepo, userRankRepo)
questionActivityRepo := activity.NewQuestionActivityRepo(dataData, activityRepo, userRankRepo)
answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, questionActivityRepo)
questionService := service.NewQuestionService(questionRepo, tagCommonService, questionCommon, userCommon, revisionService, metaService, collectionCommon, answerActivityService, dataData)
questionService := service.NewQuestionService(questionRepo, tagCommonService, questionCommon, userCommon, userRepo, revisionService, metaService, collectionCommon, answerActivityService, dataData, emailService)
answerService := service.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService)
questionController := controller.NewQuestionController(questionService, answerService, rankService)
dashboardService := dashboard.NewDashboardService(questionRepo, answerRepo, commentCommonRepo, voteRepo, userRepo, reportRepo, configRepo, siteInfoCommonService, serviceConf, dataData)

View File

@ -2841,6 +2841,19 @@ const docTemplate = `{
"name": "type",
"in": "query",
"required": true
},
{
"enum": [
"all",
"posts",
"invites",
"votes"
],
"type": "string",
"description": "inbox_type",
"name": "inbox_type",
"in": "query",
"required": true
}
],
"responses": {
@ -3626,6 +3639,81 @@ const docTemplate = `{
}
}
},
"/answer/api/v1/question/invite": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "get question invite user info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Question"
],
"summary": "get question invite user info",
"parameters": [
{
"type": "string",
"default": "1",
"description": "Question ID",
"name": "id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
},
"put": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "update question invite user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Question"
],
"summary": "update question invite user",
"parameters": [
{
"description": "question",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionUpdateInviteUser"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/question/operation": {
"put": {
"security": [
@ -5025,6 +5113,55 @@ const docTemplate = `{
}
}
},
"/answer/api/v1/user/info/search": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "SearchUserListByName",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "SearchUserListByName",
"parameters": [
{
"type": "string",
"description": "username",
"name": "username",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/handler.RespBody"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/schema.GetOtherUserInfoResp"
}
}
}
]
}
}
}
}
},
"/answer/api/v1/user/interface": {
"put": {
"security": [
@ -7511,6 +7648,12 @@ const docTemplate = `{
"maxLength": 65535,
"minLength": 6
},
"mention_username_list": {
"type": "array",
"items": {
"type": "string"
}
},
"tags": {
"description": "tags",
"type": "array",
@ -7674,6 +7817,12 @@ const docTemplate = `{
"description": "question id",
"type": "string"
},
"invite_user": {
"type": "array",
"items": {
"type": "string"
}
},
"tags": {
"description": "tags",
"type": "array",
@ -7689,6 +7838,23 @@ const docTemplate = `{
}
}
},
"schema.QuestionUpdateInviteUser": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string"
},
"invite_user": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"schema.RemoveAnswerReq": {
"type": "object",
"required": [

View File

@ -2829,6 +2829,19 @@
"name": "type",
"in": "query",
"required": true
},
{
"enum": [
"all",
"posts",
"invites",
"votes"
],
"type": "string",
"description": "inbox_type",
"name": "inbox_type",
"in": "query",
"required": true
}
],
"responses": {
@ -3614,6 +3627,81 @@
}
}
},
"/answer/api/v1/question/invite": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "get question invite user info",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Question"
],
"summary": "get question invite user info",
"parameters": [
{
"type": "string",
"default": "1",
"description": "Question ID",
"name": "id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
},
"put": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "update question invite user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Question"
],
"summary": "update question invite user",
"parameters": [
{
"description": "question",
"name": "data",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.QuestionUpdateInviteUser"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handler.RespBody"
}
}
}
}
},
"/answer/api/v1/question/operation": {
"put": {
"security": [
@ -5013,6 +5101,55 @@
}
}
},
"/answer/api/v1/user/info/search": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "SearchUserListByName",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User"
],
"summary": "SearchUserListByName",
"parameters": [
{
"type": "string",
"description": "username",
"name": "username",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/handler.RespBody"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/schema.GetOtherUserInfoResp"
}
}
}
]
}
}
}
}
},
"/answer/api/v1/user/interface": {
"put": {
"security": [
@ -7499,6 +7636,12 @@
"maxLength": 65535,
"minLength": 6
},
"mention_username_list": {
"type": "array",
"items": {
"type": "string"
}
},
"tags": {
"description": "tags",
"type": "array",
@ -7662,6 +7805,12 @@
"description": "question id",
"type": "string"
},
"invite_user": {
"type": "array",
"items": {
"type": "string"
}
},
"tags": {
"description": "tags",
"type": "array",
@ -7677,6 +7826,23 @@
}
}
},
"schema.QuestionUpdateInviteUser": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "string"
},
"invite_user": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"schema.RemoveAnswerReq": {
"type": "object",
"required": [

View File

@ -1197,6 +1197,10 @@ definitions:
maxLength: 65535
minLength: 6
type: string
mention_username_list:
items:
type: string
type: array
tags:
description: tags
items:
@ -1313,6 +1317,10 @@ definitions:
id:
description: question id
type: string
invite_user:
items:
type: string
type: array
tags:
description: tags
items:
@ -1329,6 +1337,17 @@ definitions:
- tags
- title
type: object
schema.QuestionUpdateInviteUser:
properties:
id:
type: string
invite_user:
items:
type: string
type: array
required:
- id
type: object
schema.RemoveAnswerReq:
properties:
id:
@ -3957,6 +3976,16 @@ paths:
name: type
required: true
type: string
- description: inbox_type
enum:
- all
- posts
- invites
- votes
in: query
name: inbox_type
required: true
type: string
produces:
- application/json
responses:
@ -4439,6 +4468,53 @@ paths:
summary: get question details
tags:
- Question
/answer/api/v1/question/invite:
get:
consumes:
- application/json
description: get question invite user info
parameters:
- default: "1"
description: Question ID
in: query
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
security:
- ApiKeyAuth: []
summary: get question invite user info
tags:
- Question
put:
consumes:
- application/json
description: update question invite user
parameters:
- description: question
in: body
name: data
required: true
schema:
$ref: '#/definitions/schema.QuestionUpdateInviteUser'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handler.RespBody'
security:
- ApiKeyAuth: []
summary: update question invite user
tags:
- Question
/answer/api/v1/question/operation:
put:
consumes:
@ -5291,6 +5367,34 @@ paths:
summary: UserUpdateInfo update user info
tags:
- User
/answer/api/v1/user/info/search:
get:
consumes:
- application/json
description: SearchUserListByName
parameters:
- description: username
in: query
name: username
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/handler.RespBody'
- properties:
data:
$ref: '#/definitions/schema.GetOtherUserInfoResp'
type: object
security:
- ApiKeyAuth: []
summary: SearchUserListByName
tags:
- User
/answer/api/v1/user/interface:
put:
consumes:

View File

@ -388,6 +388,8 @@ backend:
other: downvoted answer
up_voted_comment:
other: upvoted comment
invited_you_to_answer:
other: invited you to answer
email_tpl:
change_email:
title:
@ -399,6 +401,11 @@ backend:
other: "[{{.SiteName}}] {{.DisplayName}} answered your question"
body:
other: "<strong><a href='{{.AnswerUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.AnswerSummary}}</blockquote><br>\n<a href='{{.AnswerUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"
invited_you_to_answer:
title:
other: "[{{.SiteName}}] {{.DisplayName}} invited you to answer"
body:
other: "<strong><a href='{{.InviteUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>I think you may know the answer.</blockquote><br>\n<a href='{{.InviteUrl}}'>View it on {{.SiteName}}</a><br><br>\n\n<small>You are receiving this because you authored the thread. <a href='{{.UnsubscribeUrl}}'>Unsubscribe</a></small>"
new_comment:
title:
other: "[{{.SiteName}}] {{.DisplayName}} commented on your post"

View File

@ -378,6 +378,8 @@ backend:
other: 踩了答案
up_voted_comment:
other: 赞了评论
invited_you_to_answer:
other: 邀请你回答问题
email_tpl:
change_email:
title:
@ -388,12 +390,17 @@ backend:
title:
other: "[{{.SiteName}}] {{.DisplayName}} 回答了您的问题"
body:
other: "<strong><a href='{{.AnswerUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.AnswerSummary}}</blockquote><br>\n<a href='{{.AnswerUrl}}'>在 {{.SiteName}} 上查看</a><br><br>\n\n<small>您会收到此邮件是因为您是该讨论的作者。<a href='{{.UnsubscribeUrl}}'>取消订阅</a></small>"
other: "<strong><a href='{{.AnswerUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.AnswerSummary}}</blockquote><br>\n<a href='{{.AnswerUrl}}'>在 {{.SiteName}} 上查看</a><br><br>\n\n<small>您会收到此邮件是因为您开启了订阅。<a href='{{.UnsubscribeUrl}}'>取消订阅</a></small>"
invited_you_to_answer:
title:
other: "[{{.SiteName}}] {{.DisplayName}} 邀请您回答问题"
body:
other: "<strong><a href='{{.InviteUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>我想你可能知道答案。</blockquote><br>\n<a href='{{.InviteUrl}}'>在 {{.SiteName}} 上查看</a><br><br>\n\n<small>您会收到此邮件是因为您开启了订阅. <a href='{{.UnsubscribeUrl}}'>取消订阅</a></small>"
new_comment:
title:
other: "[{{.SiteName}}] {{.DisplayName}} 评论了您的帖子"
body:
other: "<strong><a href='{{.CommentUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.CommentSummary}}</blockquote><br>\n<a href='{{.CommentUrl}}'>在 {{.SiteName}} 上查看</a><br><br>\n\n<small>您会收到此邮件是因为您是该讨论的作者。<a href='{{.UnsubscribeUrl}}'>取消订阅</a></small>"
other: "<strong><a href='{{.CommentUrl}}'>{{.QuestionTitle}}</a></strong><br><br>\n\n<small>{{.DisplayName}}:</small><br>\n<blockquote>{{.CommentSummary}}</blockquote><br>\n<a href='{{.CommentUrl}}'>在 {{.SiteName}} 上查看</a><br><br>\n\n<small>您会收到此邮件是因为您开启了订阅。<a href='{{.UnsubscribeUrl}}'>取消订阅</a></small>"
pass_reset:
title:
other: "[{{.SiteName }}] 重置密码"

View File

@ -18,4 +18,7 @@ const (
EmailTplKeyTestTitle = "email_tpl.test.title"
EmailTplKeyTestBody = "email_tpl.test.body"
EmailTplKeyInvitedAnswerTitle = "email_tpl.invited_you_to_answer.title"
EmailTplKeyInvitedAnswerBody = "email_tpl.invited_you_to_answer.body"
)

View File

@ -35,4 +35,6 @@ const (
NotificationYourAnswerWasDeleted = "notification.action.your_answer_was_deleted"
// NotificationYourCommentWasDeleted your comment was deleted
NotificationYourCommentWasDeleted = "notification.action.your_comment_was_deleted"
// NotificationInvitedYouToAnswer invited you to answer
NotificationInvitedYouToAnswer = "notification.action.invited_you_to_answer"
)

View File

@ -109,7 +109,7 @@ func (cc *ConnectorController) ConnectorRedirect(connector plugin.Connector) (fn
commonRouterPrefix, ConnectorRedirectRouterPrefix, connector.ConnectorSlugName())
userInfo, err := connector.ConnectorReceiver(ctx, receiverURL)
if err != nil {
log.Errorf("connector received failed: %v", err)
log.Errorf("connector received failed, error info: %v, response data is: %s", err, userInfo.MetaInfo)
ctx.Redirect(http.StatusFound, "/50x")
return
}

View File

@ -143,6 +143,7 @@ func (nc *NotificationController) ClearIDUnRead(ctx *gin.Context) {
// @Param page query int false "page size"
// @Param page_size query int false "page size"
// @Param type query string true "type" Enums(inbox,achievement)
// @Param inbox_type query string true "inbox_type" Enums(all,posts,invites,votes)
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/notification/page [get]
func (nc *NotificationController) GetList(ctx *gin.Context) {

View File

@ -221,6 +221,23 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, info)
}
// GetQuestionInviteUserInfo get question invite user info
// @Summary get question invite user info
// @Description get question invite user info
// @Tags Question
// @Security ApiKeyAuth
// @Accept json
// @Produce json
// @Param id query string true "Question ID" default(1)
// @Success 200 {string} string ""
// @Router /answer/api/v1/question/invite [get]
func (qc *QuestionController) GetQuestionInviteUserInfo(ctx *gin.Context) {
questionID := uid.DeShortID(ctx.Query("id"))
resp, err := qc.questionService.InviteUserInfo(ctx, questionID)
handler.HandleResponse(ctx, err, resp)
}
// SimilarQuestion godoc
// @Summary Search Similar Question
// @Description Search Similar Question
@ -500,6 +517,51 @@ func (qc *QuestionController) UpdateQuestion(ctx *gin.Context) {
handler.HandleResponse(ctx, nil, &schema.UpdateQuestionResp{WaitForReview: !req.NoNeedReview})
}
// UpdateQuestionInviteUser update question invite user
// @Summary update question invite user
// @Description update question invite user
// @Tags Question
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.QuestionUpdateInviteUser true "question"
// @Success 200 {object} handler.RespBody
// @Router /answer/api/v1/question/invite [put]
func (qc *QuestionController) UpdateQuestionInviteUser(ctx *gin.Context) {
req := &schema.QuestionUpdateInviteUser{}
errFields := handler.BindAndCheckReturnErr(ctx, req)
if ctx.IsAborted() {
return
}
req.ID = uid.DeShortID(req.ID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
canList, err := qc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerInviteSomeoneToAnswer,
})
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
objectOwner := qc.rankService.CheckOperationObjectOwner(ctx, req.UserID, req.ID)
req.CanEdit = canList[0] || objectOwner
if !req.CanEdit {
handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil)
return
}
if len(errFields) > 0 {
handler.HandleResponse(ctx, errors.BadRequest(reason.RequestFormatError), errFields)
return
}
err = qc.questionService.UpdateQuestionInviteUser(ctx, req)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
}
handler.HandleResponse(ctx, nil, nil)
}
// SearchByTitleLike add question title like
// @Summary add question title like
// @Description add question title like

View File

@ -607,3 +607,22 @@ func (uc *UserController) UserUnsubscribeEmailNotification(ctx *gin.Context) {
err := uc.userService.UserUnsubscribeEmailNotification(ctx, req)
handler.HandleResponse(ctx, err, nil)
}
// SearchUserListByName godoc
// @Summary SearchUserListByName
// @Description SearchUserListByName
// @Tags User
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param username query string true "username"
// @Success 200 {object} handler.RespBody{data=schema.GetOtherUserInfoResp}
// @Router /answer/api/v1/user/info/search [get]
func (uc *UserController) SearchUserListByName(ctx *gin.Context) {
req := &schema.GetOtherUserInfoByUsernameReq{}
if handler.BindAndCheck(ctx, req) {
return
}
resp, err := uc.userService.SearchUserListByName(ctx, req.Username)
handler.HandleResponse(ctx, err, resp)
}

View File

@ -32,6 +32,7 @@ type Question struct {
CreatedAt time.Time `xorm:"not null default CURRENT_TIMESTAMP TIMESTAMP created_at"`
UpdatedAt time.Time `xorm:"updated_at TIMESTAMP"`
UserID string `xorm:"not null default 0 BIGINT(20) INDEX user_id"`
InviteUserID string `xorm:"TEXT invite_user_id"`
LastEditUserID string `xorm:"not null default 0 BIGINT(20) last_edit_user_id"`
Title string `xorm:"not null default '' VARCHAR(150) title"`
OriginalText string `xorm:"not null MEDIUMTEXT original_text"`

View File

@ -2,14 +2,44 @@ package migrations
import (
"fmt"
"time"
"github.com/answerdev/answer/internal/entity"
"github.com/segmentfault/pacman/log"
"xorm.io/xorm"
)
type QuestionPostTime struct {
ID string `xorm:"not null pk BIGINT(20) id"`
CreatedAt time.Time `xorm:"not null default CURRENT_TIMESTAMP TIMESTAMP created_at"`
UpdatedAt time.Time `xorm:"updated_at TIMESTAMP"`
UserID string `xorm:"not null default 0 BIGINT(20) INDEX user_id"`
InviteUserID string `xorm:"TEXT invite_user_id"`
LastEditUserID string `xorm:"not null default 0 BIGINT(20) last_edit_user_id"`
Title string `xorm:"not null default '' VARCHAR(150) title"`
OriginalText string `xorm:"not null MEDIUMTEXT original_text"`
ParsedText string `xorm:"not null MEDIUMTEXT parsed_text"`
Status int `xorm:"not null default 1 INT(11) status"`
Pin int `xorm:"not null default 1 INT(11) pin"`
Show int `xorm:"not null default 1 INT(11) show"`
ViewCount int `xorm:"not null default 0 INT(11) view_count"`
UniqueViewCount int `xorm:"not null default 0 INT(11) unique_view_count"`
VoteCount int `xorm:"not null default 0 INT(11) vote_count"`
AnswerCount int `xorm:"not null default 0 INT(11) answer_count"`
CollectionCount int `xorm:"not null default 0 INT(11) collection_count"`
FollowCount int `xorm:"not null default 0 INT(11) follow_count"`
AcceptedAnswerID string `xorm:"not null default 0 BIGINT(20) accepted_answer_id"`
LastAnswerID string `xorm:"not null default 0 BIGINT(20) last_answer_id"`
PostUpdateTime time.Time `xorm:"post_update_time TIMESTAMP"`
RevisionID string `xorm:"not null default 0 BIGINT(20) revision_id"`
}
func (QuestionPostTime) TableName() string {
return "question"
}
func updateQuestionPostTime(x *xorm.Engine) error {
questionList := make([]entity.Question, 0)
questionList := make([]QuestionPostTime, 0)
err := x.Find(&questionList, &entity.Question{})
if err != nil {
return fmt.Errorf("get questions failed: %w", err)
@ -21,7 +51,7 @@ func updateQuestionPostTime(x *xorm.Engine) error {
} else if !item.CreatedAt.IsZero() {
item.PostUpdateTime = item.CreatedAt
}
if _, err = x.Update(item, &entity.Question{ID: item.ID}); err != nil {
if _, err = x.Update(item, &QuestionPostTime{ID: item.ID}); err != nil {
log.Errorf("update %+v config failed: %s", item, err)
return fmt.Errorf("update question failed: %w", err)
}

View File

@ -3,12 +3,13 @@ package migrations
import (
"encoding/json"
"fmt"
"time"
"github.com/answerdev/answer/internal/base/constant"
"github.com/answerdev/answer/internal/entity"
"github.com/answerdev/answer/internal/schema"
"github.com/segmentfault/pacman/log"
"github.com/answerdev/answer/internal/service/permission"
"github.com/segmentfault/pacman/log"
"xorm.io/xorm"
)
@ -19,6 +20,7 @@ func updateCount(x *xorm.Engine) error {
updateTagCount(x)
updateUserQuestionCount(x)
updateUserAnswerCount(x)
inviteAnswer(x)
return nil
}
@ -302,3 +304,36 @@ func updateUserAnswerCount(x *xorm.Engine) error {
}
return nil
}
func inviteAnswer(x *xorm.Engine) error {
type Question struct {
ID string `xorm:"not null pk BIGINT(20) id"`
CreatedAt time.Time `xorm:"not null default CURRENT_TIMESTAMP TIMESTAMP created_at"`
UpdatedAt time.Time `xorm:"updated_at TIMESTAMP"`
UserID string `xorm:"not null default 0 BIGINT(20) INDEX user_id"`
InviteUserID string `xorm:"TEXT invite_user_id"`
LastEditUserID string `xorm:"not null default 0 BIGINT(20) last_edit_user_id"`
Title string `xorm:"not null default '' VARCHAR(150) title"`
OriginalText string `xorm:"not null MEDIUMTEXT original_text"`
ParsedText string `xorm:"not null MEDIUMTEXT parsed_text"`
Status int `xorm:"not null default 1 INT(11) status"`
Pin int `xorm:"not null default 1 INT(11) pin"`
Show int `xorm:"not null default 1 INT(11) show"`
ViewCount int `xorm:"not null default 0 INT(11) view_count"`
UniqueViewCount int `xorm:"not null default 0 INT(11) unique_view_count"`
VoteCount int `xorm:"not null default 0 INT(11) vote_count"`
AnswerCount int `xorm:"not null default 0 INT(11) answer_count"`
CollectionCount int `xorm:"not null default 0 INT(11) collection_count"`
FollowCount int `xorm:"not null default 0 INT(11) follow_count"`
AcceptedAnswerID string `xorm:"not null default 0 BIGINT(20) accepted_answer_id"`
LastAnswerID string `xorm:"not null default 0 BIGINT(20) last_answer_id"`
PostUpdateTime time.Time `xorm:"post_update_time TIMESTAMP"`
RevisionID string `xorm:"not null default 0 BIGINT(20) revision_id"`
}
err := x.Sync(new(Question))
if err != nil {
return err
}
return nil
}

View File

@ -195,6 +195,17 @@ func (ur *userRepo) GetByUsername(ctx context.Context, username string) (userInf
return
}
func (ur *userRepo) GetByUsernames(ctx context.Context, usernames []string) ([]*entity.User, error) {
list := make([]*entity.User, 0)
err := ur.data.DB.Where("status =?", entity.UserStatusAvailable).In("username", usernames).Find(&list)
if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
return list, err
}
tryToDecorateUserListFromUserCenter(ctx, ur.data, list)
return list, nil
}
// GetByEmail get user by email
func (ur *userRepo) GetByEmail(ctx context.Context, email string) (userInfo *entity.User, exist bool, err error) {
userInfo = &entity.User{}
@ -215,6 +226,23 @@ func (ur *userRepo) GetUserCount(ctx context.Context) (count int64, err error) {
return
}
func (ur *userRepo) SearchUserListByName(ctx context.Context, name string) (userList []*entity.User, err error) {
userList = make([]*entity.User, 0)
if name == "" {
return userList, nil
}
session := ur.data.DB.Where("")
session.Where("username LIKE LOWER(?) or display_name LIKE ?", name+"%", name+"%").And("status =?", entity.UserStatusAvailable)
session.Asc("username")
session = session.Limit(5, 0)
err = session.OrderBy("id desc").Find(&userList)
if err != nil {
err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
}
tryToDecorateUserListFromUserCenter(ctx, ur.data, userList)
return
}
func tryToDecorateUserInfoFromUserCenter(ctx context.Context, data *data.Data, original *entity.User) (err error) {
if original == nil {
return nil

View File

@ -112,6 +112,7 @@ func (a *AnswerAPIRouter) RegisterMustUnAuthAnswerAPIRouter(r *gin.RouterGroup)
routerGroup.POST("/user/password/reset", a.userController.RetrievePassWord)
routerGroup.POST("/user/password/replacement", a.userController.UseRePassWord)
routerGroup.PUT("/user/email/notification", a.userController.UserUnsubscribeEmailNotification)
routerGroup.GET("/user/info/search", a.userController.SearchUserListByName)
}
func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
@ -129,6 +130,7 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
//question
r.GET("/question/info", a.questionController.GetQuestion)
r.GET("/question/invite", a.questionController.GetQuestionInviteUserInfo)
r.GET("/question/page", a.questionController.QuestionPage)
r.GET("/question/similar/tag", a.questionController.SimilarQuestion)
r.GET("/personal/qa/top", a.questionController.UserTop)
@ -193,6 +195,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
r.POST("/question", a.questionController.AddQuestion)
r.POST("/question/answer", a.questionController.AddQuestionByAnswer)
r.PUT("/question", a.questionController.UpdateQuestion)
r.PUT("/question/invite", a.questionController.UpdateQuestionInviteUser)
r.DELETE("/question", a.questionController.RemoveQuestion)
r.PUT("/question/status", a.questionController.CloseQuestion)
r.PUT("/question/operation", a.questionController.OperationQuestion)

View File

@ -47,6 +47,21 @@ type NewAnswerTemplateData struct {
UnsubscribeUrl string
}
type NewInviteAnswerTemplateRawData struct {
InviterDisplayName string
QuestionTitle string
QuestionID string
UnsubscribeCode string
}
type NewInviteAnswerTemplateData struct {
SiteName string
DisplayName string
QuestionTitle string
InviteUrl string
UnsubscribeUrl string
}
type NewCommentTemplateRawData struct {
CommentUserDisplayName string
QuestionTitle string

View File

@ -7,6 +7,10 @@ const (
NotificationRead = 2
NotificationStatusNormal = 1
NotificationStatusDelete = 10
NotificationInboxTypeAll = 1
NotificationInboxTypePosts = 2
NotificationInboxTypeInvites = 3
NotificationInboxTypeVotes = 4
)
var NotificationType = map[string]int{
@ -14,6 +18,13 @@ var NotificationType = map[string]int{
"achievement": NotificationTypeAchievement,
}
var NotificationInboxType = map[string]int{
"all": NotificationInboxTypeAll,
"posts": NotificationInboxTypePosts,
"invites": NotificationInboxTypeInvites,
"votes": NotificationInboxTypeVotes,
}
type NotificationContent struct {
ID string `json:"id"`
TriggerUserID string `json:"-"` //show userid
@ -73,6 +84,8 @@ type NotificationSearch struct {
PageSize int `json:"page_size" form:"page_size"` //Search page size
Type int `json:"-" form:"-"`
TypeStr string `json:"type" form:"type"` // inbox achievement
InboxTypeStr string `json:"inbox_type" form:"inbox_type"` // inbox achievement
InboxType int `json:"-" form:"-"` // inbox achievement
UserID string `json:"-"`
}

View File

@ -88,6 +88,7 @@ type QuestionAddByAnswer struct {
Tags []*TagItem `validate:"required,dive" json:"tags"`
// user id
UserID string `json:"-"`
MentionUsernameList []string `validate:"omitempty" json:"mention_username_list"`
QuestionPermission
}
@ -140,6 +141,7 @@ type QuestionUpdate struct {
Content string `validate:"required,notblank,gte=6,lte=65535" json:"content"`
// html
HTML string `json:"-"`
InviteUser []string `validate:"omitempty" json:"invite_user"`
// tags
Tags []*TagItem `validate:"required,dive" json:"tags"`
// edit summary
@ -150,6 +152,13 @@ type QuestionUpdate struct {
QuestionPermission
}
type QuestionUpdateInviteUser struct {
ID string `validate:"required" json:"id"`
InviteUser []string `validate:"omitempty" json:"invite_user"`
UserID string `json:"-"`
QuestionPermission
}
func (req *QuestionUpdate) Check() (errFields []*validator.FormErrorField, err error) {
req.HTML = converter.Markdown2HTML(req.Content)
return nil, nil

View File

@ -242,7 +242,6 @@ func (es *EmailService) NewAnswerTemplate(ctx context.Context, raw *schema.NewAn
AnswerSummary: raw.AnswerSummary,
UnsubscribeUrl: fmt.Sprintf("%s/users/unsubscribe?code=%s", siteInfo.SiteUrl, raw.UnsubscribeCode),
}
templateData.SiteName = siteInfo.Name
lang := handler.GetLangByCtx(ctx)
title = translator.TrWithData(lang, constant.EmailTplKeyNewAnswerTitle, templateData)
@ -250,6 +249,27 @@ func (es *EmailService) NewAnswerTemplate(ctx context.Context, raw *schema.NewAn
return title, body, nil
}
// NewInviteAnswerTemplate new invite answer template
func (es *EmailService) NewInviteAnswerTemplate(ctx context.Context, raw *schema.NewInviteAnswerTemplateRawData) (
title, body string, err error) {
siteInfo, err := es.GetSiteGeneral(ctx)
if err != nil {
return
}
templateData := &schema.NewInviteAnswerTemplateData{
SiteName: siteInfo.Name,
DisplayName: raw.InviterDisplayName,
QuestionTitle: raw.QuestionTitle,
InviteUrl: fmt.Sprintf("%s/questions/%s", siteInfo.SiteUrl, raw.QuestionID),
UnsubscribeUrl: fmt.Sprintf("%s/users/unsubscribe?code=%s", siteInfo.SiteUrl, raw.UnsubscribeCode),
}
lang := handler.GetLangByCtx(ctx)
title = translator.TrWithData(lang, constant.EmailTplKeyInvitedAnswerTitle, templateData)
body = translator.TrWithData(lang, constant.EmailTplKeyInvitedAnswerBody, templateData)
return title, body, nil
}
// NewCommentTemplate new comment template
func (es *EmailService) NewCommentTemplate(ctx context.Context, raw *schema.NewCommentTemplateRawData) (
title, body string, err error) {
@ -271,7 +291,6 @@ func (es *EmailService) NewCommentTemplate(ctx context.Context, raw *schema.NewC
templateData.CommentUrl = fmt.Sprintf("%s/questions/%s?commentId=%s", siteInfo.SiteUrl,
raw.QuestionID, raw.CommentID)
}
templateData.SiteName = siteInfo.Name
lang := handler.GetLangByCtx(ctx)
title = translator.TrWithData(lang, constant.EmailTplKeyNewCommentTitle, templateData)

View File

@ -123,7 +123,12 @@ func (ns *NotificationService) GetNotificationPage(ctx context.Context, searchCo
if !ok {
return pager.NewPageModel(0, resp), nil
}
searchInboxType, ok := schema.NotificationInboxType[searchCond.InboxTypeStr]
if !ok {
return pager.NewPageModel(0, resp), nil
}
searchCond.Type = searchType
searchCond.InboxType = searchInboxType
notifications, total, err := ns.notificationRepo.GetNotificationPage(ctx, searchCond)
if err != nil {
return nil, err

View File

@ -150,6 +150,34 @@ func (qs *QuestionCommon) FindInfoByID(ctx context.Context, questionIDs []string
return list, nil
}
func (qs *QuestionCommon) InviteUserInfo(ctx context.Context, questionID string) (inviteList []*schema.UserBasicInfo, err error) {
InviteUserInfo := make([]*schema.UserBasicInfo, 0)
dbinfo, has, err := qs.questionRepo.GetQuestion(ctx, questionID)
if err != nil {
return InviteUserInfo, err
}
if !has {
return InviteUserInfo, errors.NotFound(reason.QuestionNotFound)
}
//InviteUser
if dbinfo.InviteUserID != "" {
InviteUserIDs := make([]string, 0)
err := json.Unmarshal([]byte(dbinfo.InviteUserID), &InviteUserIDs)
if err == nil {
inviteUserInfoMap, err := qs.userCommon.BatchUserBasicInfoByID(ctx, InviteUserIDs)
if err == nil {
for _, userid := range InviteUserIDs {
_, ok := inviteUserInfoMap[userid]
if ok {
InviteUserInfo = append(InviteUserInfo, inviteUserInfoMap[userid])
}
}
}
}
}
return InviteUserInfo, nil
}
func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUserID string) (showinfo *schema.QuestionInfo, err error) {
dbinfo, has, err := qs.questionRepo.GetQuestion(ctx, questionID)
if err != nil {
@ -186,9 +214,7 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser
operation.Level = schema.OperationLevelInfo
showinfo.Operation = operation
}
}
}
}

View File

@ -19,6 +19,7 @@ import (
"github.com/answerdev/answer/internal/service/activity"
"github.com/answerdev/answer/internal/service/activity_queue"
collectioncommon "github.com/answerdev/answer/internal/service/collection_common"
"github.com/answerdev/answer/internal/service/export"
"github.com/answerdev/answer/internal/service/meta"
"github.com/answerdev/answer/internal/service/notice_queue"
"github.com/answerdev/answer/internal/service/permission"
@ -26,10 +27,12 @@ import (
"github.com/answerdev/answer/internal/service/revision_common"
tagcommon "github.com/answerdev/answer/internal/service/tag_common"
usercommon "github.com/answerdev/answer/internal/service/user_common"
"github.com/answerdev/answer/pkg/encryption"
"github.com/answerdev/answer/pkg/htmltext"
"github.com/answerdev/answer/pkg/uid"
"github.com/jinzhu/copier"
"github.com/segmentfault/pacman/errors"
"github.com/segmentfault/pacman/i18n"
"github.com/segmentfault/pacman/log"
"golang.org/x/net/context"
)
@ -42,11 +45,13 @@ type QuestionService struct {
tagCommon *tagcommon.TagCommonService
questioncommon *questioncommon.QuestionCommon
userCommon *usercommon.UserCommon
userRepo usercommon.UserRepo
revisionService *revision_common.RevisionService
metaService *meta.MetaService
collectionCommon *collectioncommon.CollectionCommon
answerActivityService *activity.AnswerActivityService
data *data.Data
emailService *export.EmailService
}
func NewQuestionService(
@ -54,23 +59,26 @@ func NewQuestionService(
tagCommon *tagcommon.TagCommonService,
questioncommon *questioncommon.QuestionCommon,
userCommon *usercommon.UserCommon,
userRepo usercommon.UserRepo,
revisionService *revision_common.RevisionService,
metaService *meta.MetaService,
collectionCommon *collectioncommon.CollectionCommon,
answerActivityService *activity.AnswerActivityService,
data *data.Data,
emailService *export.EmailService,
) *QuestionService {
return &QuestionService{
questionRepo: questionRepo,
tagCommon: tagCommon,
questioncommon: questioncommon,
userCommon: userCommon,
userRepo: userRepo,
revisionService: revisionService,
metaService: metaService,
collectionCommon: collectionCommon,
answerActivityService: answerActivityService,
data: data,
emailService: emailService,
}
}
@ -535,6 +543,115 @@ func (qs *QuestionService) UpdateQuestionCheckTags(ctx context.Context, req *sch
return nil, nil
}
func (qs *QuestionService) UpdateQuestionInviteUser(ctx context.Context, req *schema.QuestionUpdateInviteUser) (err error) {
originQuestion, exist, err := qs.questionRepo.GetQuestion(ctx, req.ID)
if err != nil {
return err
}
if !exist {
return errors.NotFound(reason.ObjectNotFound)
}
//verify invite user
inviteUserInfoList, err := qs.userCommon.BatchGetUserBasicInfoByUserNames(ctx, req.InviteUser)
if err != nil {
log.Error("BatchGetUserBasicInfoByUserNames error", err.Error())
}
inviteUserIDs := make([]string, 0)
for _, item := range req.InviteUser {
_, ok := inviteUserInfoList[item]
if ok {
inviteUserIDs = append(inviteUserIDs, inviteUserInfoList[item].ID)
}
}
inviteUserStr := ""
inviteUserByte, err := json.Marshal(inviteUserIDs)
if err != nil {
log.Error("json.Marshal error", err.Error())
inviteUserStr = "[]"
} else {
inviteUserStr = string(inviteUserByte)
}
question := &entity.Question{}
question.ID = uid.DeShortID(req.ID)
question.InviteUserID = inviteUserStr
saveerr := qs.questionRepo.UpdateQuestion(ctx, question, []string{"invite_user_id"})
if saveerr != nil {
return saveerr
}
go qs.notificationInviteUser(ctx, inviteUserIDs, originQuestion.ID, originQuestion.Title, req.UserID)
return nil
}
func (qs *QuestionService) notificationInviteUser(
ctx context.Context, invitedUserIDs []string, questionID, questionTitle, questionUserID string) {
inviter, exist, err := qs.userCommon.GetUserBasicInfoByID(ctx, questionUserID)
if err != nil {
log.Error(err)
return
}
if !exist {
log.Warnf("user %s not found", questionUserID)
return
}
users, err := qs.userRepo.BatchGetByID(ctx, invitedUserIDs)
if err != nil {
log.Error(err)
return
}
invitee := make(map[string]*entity.User, len(users))
for _, user := range users {
invitee[user.ID] = user
}
for _, userID := range invitedUserIDs {
msg := &schema.NotificationMsg{
ReceiverUserID: userID,
TriggerUserID: questionUserID,
Type: schema.NotificationTypeInbox,
ObjectID: questionID,
}
msg.ObjectType = constant.QuestionObjectType
msg.NotificationAction = constant.NotificationInvitedYouToAnswer
notice_queue.AddNotification(msg)
userInfo, ok := invitee[userID]
if !ok {
log.Warnf("user %s not found", userID)
return
}
if userInfo.NoticeStatus == schema.NoticeStatusOff || len(userInfo.EMail) == 0 {
return
}
rawData := &schema.NewInviteAnswerTemplateRawData{
InviterDisplayName: inviter.DisplayName,
QuestionTitle: questionTitle,
QuestionID: questionID,
UnsubscribeCode: encryption.MD5(userInfo.Pass),
}
codeContent := &schema.EmailCodeContent{
SourceType: schema.UnsubscribeSourceType,
Email: userInfo.EMail,
UserID: userInfo.ID,
}
// If receiver has set language, use it to send email.
if len(userInfo.Language) > 0 {
ctx = context.WithValue(ctx, constant.AcceptLanguageFlag, i18n.Language(userInfo.Language))
}
title, body, err := qs.emailService.NewInviteAnswerTemplate(ctx, rawData)
if err != nil {
log.Error(err)
return
}
go qs.emailService.SendAndSaveCodeWithTime(
ctx, userInfo.EMail, title, body, rawData.UnsubscribeCode, codeContent.ToJSONString(), 7*24*time.Hour)
}
}
// UpdateQuestion update question
func (qs *QuestionService) UpdateQuestion(ctx context.Context, req *schema.QuestionUpdate) (questionInfo any, err error) {
var canUpdate bool
@ -756,6 +873,10 @@ func (qs *QuestionService) GetQuestionAndAddPV(ctx context.Context, questionID,
return qs.GetQuestion(ctx, questionID, loginUserID, per)
}
func (qs *QuestionService) InviteUserInfo(ctx context.Context, questionID string) (inviteList []*schema.UserBasicInfo, err error) {
return qs.questioncommon.InviteUserInfo(ctx, questionID)
}
func (qs *QuestionService) ChangeTag(ctx context.Context, objectTagData *schema.TagChange) error {
return qs.tagCommon.ObjectChangeTag(ctx, objectTagData)
}

View File

@ -32,8 +32,10 @@ type UserRepo interface {
GetByUserID(ctx context.Context, userID string) (userInfo *entity.User, exist bool, err error)
BatchGetByID(ctx context.Context, ids []string) ([]*entity.User, error)
GetByUsername(ctx context.Context, username string) (userInfo *entity.User, exist bool, err error)
GetByUsernames(ctx context.Context, usernames []string) ([]*entity.User, error)
GetByEmail(ctx context.Context, email string) (userInfo *entity.User, exist bool, err error)
GetUserCount(ctx context.Context) (count int64, err error)
SearchUserListByName(ctx context.Context, name string) (userList []*entity.User, err error)
}
// UserCommon user service
@ -74,6 +76,19 @@ func (us *UserCommon) GetUserBasicInfoByUserName(ctx context.Context, username s
return info, exist, nil
}
func (us *UserCommon) BatchGetUserBasicInfoByUserNames(ctx context.Context, usernames []string) (map[string]*schema.UserBasicInfo, error) {
infomap := make(map[string]*schema.UserBasicInfo)
list, err := us.userRepo.GetByUsernames(ctx, usernames)
if err != nil {
return infomap, err
}
for _, user := range list {
info := us.FormatUserBasicInfo(ctx, user)
infomap[user.Username] = info
}
return infomap, nil
}
func (us *UserCommon) UpdateAnswerCount(ctx context.Context, userID string, num int) error {
return us.userRepo.UpdateAnswerCount(ctx, userID, num)
}

View File

@ -814,6 +814,19 @@ func (us *UserService) getUserInfoMapping(ctx context.Context, userIDs []string)
return userInfoMapping, nil
}
func (us *UserService) SearchUserListByName(ctx context.Context, name string) ([]*schema.UserBasicInfo, error) {
userinfolist := make([]*schema.UserBasicInfo, 0)
list, err := us.userRepo.SearchUserListByName(ctx, name)
if err != nil {
return userinfolist, err
}
for _, user := range list {
userinfo := us.userCommonService.FormatUserBasicInfo(ctx, user)
userinfolist = append(userinfolist, userinfo)
}
return userinfolist, nil
}
func (us *UserService) warpStatRankingResp(
userInfoMapping map[string]*entity.User,
rankStat []*entity.ActivityUserRankStat,