This is an automated email from the ASF dual-hosted git repository.

linkinstar pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/incubator-answer.git

commit 3eb9822c0906aa67a08a62f03bb9b10b841b420f
Author: Sonui <[email protected]>
AuthorDate: Fri Sep 27 23:42:28 2024 +0800

    feat(question): support linking question
---
 docs/docs.go                                       |  84 +++++++++++
 docs/swagger.json                                  |  84 +++++++++++
 docs/swagger.yaml                                  |  51 +++++++
 i18n/en_US.yaml                                    |   6 +-
 i18n/zh_CN.yaml                                    |   5 +-
 internal/controller/question_controller.go         |  22 +++
 .../entity/question_link_entity.go                 |  42 +++---
 internal/migrations/init_data.go                   |   1 +
 internal/migrations/migrations.go                  |   1 +
 .../index.tsx => internal/migrations/v23.go        |  29 ++--
 internal/repo/answer/answer_repo.go                |  18 +++
 internal/repo/question/question_repo.go            | 167 +++++++++++++++++++++
 internal/router/answer_api_router.go               |   1 +
 internal/schema/question_schema.go                 |  14 ++
 internal/service/answer_common/answer.go           |   1 +
 internal/service/content/answer_service.go         | 114 +++++++++++++-
 internal/service/content/question_service.go       | 145 +++++++++++++++++-
 internal/service/question_common/question.go       |   5 +
 pkg/checker/question_link.go                       | 107 +++++++++++++
 pkg/checker/question_link_test.go                  | 141 +++++++++++++++++
 ui/src/components/QuestionList/index.tsx           |   2 +-
 .../Detail/components/LinkedQuestions/index.tsx    | 126 ++++++++++++++++
 ui/src/pages/Questions/Detail/components/index.tsx |   2 +
 ui/src/pages/Questions/Detail/index.tsx            |   3 +
 ui/src/pages/Questions/Linked/index.tsx            |  95 ++++++++++++
 ui/src/router/routes.ts                            |   4 +
 ui/src/services/client/question.ts                 |  17 +++
 27 files changed, 1245 insertions(+), 42 deletions(-)

diff --git a/docs/docs.go b/docs/docs.go
index 80f4a0d8..424bd669 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -4462,6 +4462,90 @@ const docTemplate = `{
                 }
             }
         },
+        "/answer/api/v1/question/link": {
+            "get": {
+                "description": "get question link",
+                "tags": [
+                    "Question"
+                ],
+                "summary": "get question link",
+                "parameters": [
+                    {
+                        "minimum": 1,
+                        "type": "integer",
+                        "name": "in_days",
+                        "in": "query"
+                    },
+                    {
+                        "enum": [
+                            "newest",
+                            "active",
+                            "hot",
+                            "score",
+                            "unanswered",
+                            "recommend"
+                        ],
+                        "type": "string",
+                        "name": "order",
+                        "in": "query"
+                    },
+                    {
+                        "minimum": 1,
+                        "type": "integer",
+                        "name": "page",
+                        "in": "query"
+                    },
+                    {
+                        "maximum": 100,
+                        "minimum": 1,
+                        "type": "integer",
+                        "name": "page_size",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "name": "question_id",
+                        "in": "query",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "allOf": [
+                                {
+                                    "$ref": "#/definitions/handler.RespBody"
+                                },
+                                {
+                                    "type": "object",
+                                    "properties": {
+                                        "data": {
+                                            "allOf": [
+                                                {
+                                                    "$ref": 
"#/definitions/pager.PageModel"
+                                                },
+                                                {
+                                                    "type": "object",
+                                                    "properties": {
+                                                        "list": {
+                                                            "type": "array",
+                                                            "items": {
+                                                                "$ref": 
"#/definitions/schema.QuestionPageResp"
+                                                            }
+                                                        }
+                                                    }
+                                                }
+                                            ]
+                                        }
+                                    }
+                                }
+                            ]
+                        }
+                    }
+                }
+            }
+        },
         "/answer/api/v1/question/operation": {
             "put": {
                 "security": [
diff --git a/docs/swagger.json b/docs/swagger.json
index 5690e67b..7ec493b2 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -4435,6 +4435,90 @@
                 }
             }
         },
+        "/answer/api/v1/question/link": {
+            "get": {
+                "description": "get question link",
+                "tags": [
+                    "Question"
+                ],
+                "summary": "get question link",
+                "parameters": [
+                    {
+                        "minimum": 1,
+                        "type": "integer",
+                        "name": "in_days",
+                        "in": "query"
+                    },
+                    {
+                        "enum": [
+                            "newest",
+                            "active",
+                            "hot",
+                            "score",
+                            "unanswered",
+                            "recommend"
+                        ],
+                        "type": "string",
+                        "name": "order",
+                        "in": "query"
+                    },
+                    {
+                        "minimum": 1,
+                        "type": "integer",
+                        "name": "page",
+                        "in": "query"
+                    },
+                    {
+                        "maximum": 100,
+                        "minimum": 1,
+                        "type": "integer",
+                        "name": "page_size",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "name": "question_id",
+                        "in": "query",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "allOf": [
+                                {
+                                    "$ref": "#/definitions/handler.RespBody"
+                                },
+                                {
+                                    "type": "object",
+                                    "properties": {
+                                        "data": {
+                                            "allOf": [
+                                                {
+                                                    "$ref": 
"#/definitions/pager.PageModel"
+                                                },
+                                                {
+                                                    "type": "object",
+                                                    "properties": {
+                                                        "list": {
+                                                            "type": "array",
+                                                            "items": {
+                                                                "$ref": 
"#/definitions/schema.QuestionPageResp"
+                                                            }
+                                                        }
+                                                    }
+                                                }
+                                            ]
+                                        }
+                                    }
+                                }
+                            ]
+                        }
+                    }
+                }
+            }
+        },
         "/answer/api/v1/question/operation": {
             "put": {
                 "security": [
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 6a0ab449..4622e964 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -5586,6 +5586,57 @@ paths:
       summary: update question invite user
       tags:
       - Question
+  /answer/api/v1/question/link:
+    get:
+      description: get question link
+      parameters:
+      - in: query
+        minimum: 1
+        name: in_days
+        type: integer
+      - enum:
+        - newest
+        - active
+        - hot
+        - score
+        - unanswered
+        - recommend
+        in: query
+        name: order
+        type: string
+      - in: query
+        minimum: 1
+        name: page
+        type: integer
+      - in: query
+        maximum: 100
+        minimum: 1
+        name: page_size
+        type: integer
+      - in: query
+        name: question_id
+        required: true
+        type: string
+      responses:
+        "200":
+          description: OK
+          schema:
+            allOf:
+            - $ref: '#/definitions/handler.RespBody'
+            - properties:
+                data:
+                  allOf:
+                  - $ref: '#/definitions/pager.PageModel'
+                  - properties:
+                      list:
+                        items:
+                          $ref: '#/definitions/schema.QuestionPageResp'
+                        type: array
+                    type: object
+              type: object
+      summary: get question link
+      tags:
+      - Question
   /answer/api/v1/question/operation:
     put:
       consumes:
diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml
index dea08198..f47bca72 100644
--- a/i18n/en_US.yaml
+++ b/i18n/en_US.yaml
@@ -789,7 +789,8 @@ ui:
   how_to_format:
     title: How to Format
     desc: >-
-      <ul class="mb-0"><li><p class="mb-2">to make links</p><pre
+      <ul class="mb-0"><li><p class="mb-2">link question or answer: 
<code>#10010000000000001</code></p></li>
+      <li><p class="mb-2">to make links</p><pre
       
class="mb-2"><code>&lt;https://url.com&gt;<br/><br/>[Title](https://url.com)</code></pre></li><li><p
       class="mb-2">put returns between paragraphs</p></li><li><p
       class="mb-2"><em>_italic_</em> or 
**<strong>bold</strong>**</p></li><li><p
@@ -1359,6 +1360,9 @@ ui:
   related_question:
     title: Related Questions
     answers: answers
+  linked_question:
+    title: Linked Questions
+    no_linked_question: No questions linked from this question.
   invite_to_answer:
     title: Invite People
     desc: Invite people you think can answer.
diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml
index 889d4da2..f6777d89 100644
--- a/i18n/zh_CN.yaml
+++ b/i18n/zh_CN.yaml
@@ -794,7 +794,7 @@ ui:
   how_to_format:
     title: 如何排版
     desc: >-
-      <ul class="mb-0"><li><p class="mb-2">添加链接</p><pre 
class="mb-2"><code>&lt;https://url.com&gt;<br/><br/>[标题](https://url.com)</code></pre></li><li><p
 class="mb-2">段落之间使用空行分隔</p></li><li><p class="mb-2"><em>_斜体_</em> 或者 
**<strong>粗体</strong>**</p></li><li><p class="mb-2">使用 4 个空格缩进代码</p></li><li><p 
class="mb-2">在行首添加 <code>&gt;</code> 表示引用</p></li><li><p class="mb-2">反引号进行转义 
<code>`像 _这样_`</code></p></li><li><p class="mb-2">使用 <code>```</code> 
创建代码块</p><pre class="mb-0"><code>```<br/> [...]
+      <ul class="mb-0"><li><p class="mb-2">引用问题或答案: 
<code>#10010000000000001</code></p></li><li><p class="mb-2">添加链接</p><pre 
class="mb-2"><code>&lt;https://url.com&gt;<br/><br/>[标题](https://url.com)</code></pre></li><li><p
 class="mb-2">段落之间使用空行分隔</p></li><li><p class="mb-2"><em>_斜体_</em> 或者 
**<strong>粗体</strong>**</p></li><li><p class="mb-2">使用 4 个空格缩进代码</p></li><li><p 
class="mb-2">在行首添加 <code>&gt;</code> 表示引用</p></li><li><p class="mb-2">反引号进行转义 
<code>`像 _这样_`</code></p></li><li><p class [...]
   pagination:
     prev: 上一页
     next: 下一页
@@ -1341,6 +1341,9 @@ ui:
   related_question:
     title: 相关问题
     answers: 个回答
+  linked_question:
+    title: 关联问题
+    no_linked_question: 没有关联的问题。
   invite_to_answer:
     title: 受邀人
     desc: 邀请你认为可能知道答案的人。
diff --git a/internal/controller/question_controller.go 
b/internal/controller/question_controller.go
index 5ddc33f3..8f349855 100644
--- a/internal/controller/question_controller.go
+++ b/internal/controller/question_controller.go
@@ -974,3 +974,25 @@ func (qc *QuestionController) 
AdminUpdateQuestionStatus(ctx *gin.Context) {
        err := qc.questionService.AdminSetQuestionStatus(ctx, req)
        handler.HandleResponse(ctx, err, nil)
 }
+
+// GetQuestionLink get question link
+// @Summary get question link
+// @Description get question link
+// @Tags Question
+// @Param data query schema.GetQuestionLinkReq  true "GetQuestionLinkReq"
+// @Success 200 {object} 
handler.RespBody{data=pager.PageModel{list=[]schema.QuestionPageResp}}
+// @Router /answer/api/v1/question/link [get]
+func (qc *QuestionController) GetQuestionLink(ctx *gin.Context) {
+       req := &schema.GetQuestionLinkReq{}
+       if handler.BindAndCheck(ctx, req) {
+               return
+       }
+       req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
+       req.QuestionID = uid.DeShortID(req.QuestionID)
+       questions, total, err := qc.questionService.GetQuestionLink(ctx, req)
+       if err != nil {
+               handler.HandleResponse(ctx, err, nil)
+               return
+       }
+       handler.HandleResponse(ctx, nil, pager.NewPageModel(total, questions))
+}
diff --git a/ui/src/pages/Questions/Detail/components/index.tsx 
b/internal/entity/question_link_entity.go
similarity index 50%
copy from ui/src/pages/Questions/Detail/components/index.tsx
copy to internal/entity/question_link_entity.go
index 1cfe75ee..825e7595 100644
--- a/ui/src/pages/Questions/Detail/components/index.tsx
+++ b/internal/entity/question_link_entity.go
@@ -17,22 +17,28 @@
  * under the License.
  */
 
-import Question from './Question';
-import Answer from './Answer';
-import AnswerHead from './AnswerHead';
-import RelatedQuestions from './RelatedQuestions';
-import WriteAnswer from './WriteAnswer';
-import Alert from './Alert';
-import ContentLoader from './ContentLoader';
-import InviteToAnswer from './InviteToAnswer';
+package entity
 
-export {
-  Question,
-  Answer,
-  AnswerHead,
-  RelatedQuestions,
-  WriteAnswer,
-  Alert,
-  ContentLoader,
-  InviteToAnswer,
-};
+import (
+       "time"
+)
+
+const (
+       QuestionLinkStatusAvailable = 1
+       QuestionLinkStatusDeleted   = 2
+)
+
+type QuestionLink struct {
+       ID             string    `xorm:"not null pk autoincr BIGINT(20) id"`
+       CreatedAt      time.Time `xorm:"not null default CURRENT_TIMESTAMP 
TIMESTAMP created_at"`
+       UpdatedAt      time.Time `xorm:"updated_at TIMESTAMP"`
+       FromQuestionID string    `xorm:"not null default 0 BIGINT(20) index 
from_question_id"`
+       FromAnswerID   string    `xorm:"BIGINT(20) from_answer_id"`
+       ToQuestionID   string    `xorm:"not null default 0 BIGINT(20) index 
to_question_id"`
+       ToAnswerID     string    `xorm:"BIGINT(20) to_answer_id"`
+       Status         int       `xorm:"not null default 1 INT(11) status"`
+}
+
+func (QuestionLink) TableName() string {
+       return "question_link"
+}
diff --git a/internal/migrations/init_data.go b/internal/migrations/init_data.go
index 50a5651b..b0143b33 100644
--- a/internal/migrations/init_data.go
+++ b/internal/migrations/init_data.go
@@ -52,6 +52,7 @@ var (
                &entity.Meta{},
                &entity.Notification{},
                &entity.Question{},
+               &entity.QuestionLink{},
                &entity.Report{},
                &entity.Revision{},
                &entity.SiteInfo{},
diff --git a/internal/migrations/migrations.go 
b/internal/migrations/migrations.go
index a32e851a..81a54ae7 100644
--- a/internal/migrations/migrations.go
+++ b/internal/migrations/migrations.go
@@ -98,6 +98,7 @@ var migrations = []Migration{
        NewMigration("v1.3.0", "add review", addReview, false),
        NewMigration("v1.3.6", "add hot score to question table", 
addQuestionHotScore, true),
        NewMigration("v1.4.0", "add badge/badge_group/badge_award table", 
addBadges, true),
+       NewMigration("v1.4.1", "add question link", addQuestionLink, true),
 }
 
 func GetMigrations() []Migration {
diff --git a/ui/src/pages/Questions/Detail/components/index.tsx 
b/internal/migrations/v23.go
similarity index 64%
copy from ui/src/pages/Questions/Detail/components/index.tsx
copy to internal/migrations/v23.go
index 1cfe75ee..8b8a3370 100644
--- a/ui/src/pages/Questions/Detail/components/index.tsx
+++ b/internal/migrations/v23.go
@@ -17,22 +17,15 @@
  * under the License.
  */
 
-import Question from './Question';
-import Answer from './Answer';
-import AnswerHead from './AnswerHead';
-import RelatedQuestions from './RelatedQuestions';
-import WriteAnswer from './WriteAnswer';
-import Alert from './Alert';
-import ContentLoader from './ContentLoader';
-import InviteToAnswer from './InviteToAnswer';
+package migrations
 
-export {
-  Question,
-  Answer,
-  AnswerHead,
-  RelatedQuestions,
-  WriteAnswer,
-  Alert,
-  ContentLoader,
-  InviteToAnswer,
-};
+import (
+       "context"
+
+       "github.com/apache/incubator-answer/internal/entity"
+       "xorm.io/xorm"
+)
+
+func addQuestionLink(ctx context.Context, x *xorm.Engine) (err error) {
+       return x.Context(ctx).Sync(new(entity.QuestionLink))
+}
diff --git a/internal/repo/answer/answer_repo.go 
b/internal/repo/answer/answer_repo.go
index 79dac140..f59d6006 100644
--- a/internal/repo/answer/answer_repo.go
+++ b/internal/repo/answer/answer_repo.go
@@ -270,6 +270,24 @@ func (ar *answerRepo) GetByID(ctx context.Context, 
answerID string) (*entity.Ans
        return &resp, has, nil
 }
 
+func (ar *answerRepo) GetByIDs(ctx context.Context, answerIDs ...string) 
([]*entity.Answer, error) {
+       for idx, answerID := range answerIDs {
+               answerIDs[idx] = uid.DeShortID(answerID)
+       }
+       var resp = make([]*entity.Answer, 0)
+       err := ar.data.DB.Context(ctx).In("id", answerIDs).Find(&resp)
+       if err != nil {
+               return nil, 
errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
+       }
+       if handler.GetEnableShortID(ctx) {
+               for _, item := range resp {
+                       item.ID = uid.EnShortID(item.ID)
+                       item.QuestionID = uid.EnShortID(item.QuestionID)
+               }
+       }
+       return resp, nil
+}
+
 func (ar *answerRepo) GetCountByQuestionID(ctx context.Context, questionID 
string) (int64, error) {
        questionID = uid.DeShortID(questionID)
        var resp = new(entity.Answer)
diff --git a/internal/repo/question/question_repo.go 
b/internal/repo/question/question_repo.go
index 2c6e595a..30370281 100644
--- a/internal/repo/question/question_repo.go
+++ b/internal/repo/question/question_repo.go
@@ -611,3 +611,170 @@ func (qr *questionRepo) RemoveAllUserQuestion(ctx 
context.Context, userID string
        }
        return nil
 }
+
+// LinkQuestion batch insert question link
+func (qr *questionRepo) LinkQuestion(ctx context.Context, link 
...*entity.QuestionLink) (err error) {
+       // Batch retrieve all links
+       var links []*entity.QuestionLink
+       for _, l := range link {
+               l.FromQuestionID = uid.DeShortID(l.FromQuestionID)
+               l.ToQuestionID = uid.DeShortID(l.ToQuestionID)
+               l.FromAnswerID = uid.DeShortID(l.FromAnswerID)
+               l.ToAnswerID = uid.DeShortID(l.ToAnswerID)
+               links = append(links, l)
+       }
+       // Retrieve existing records from the database
+       var existLinks []*entity.QuestionLink
+       session := qr.data.DB.Context(ctx)
+       for _, link := range links {
+               session = session.Or(builder.Eq{
+                       "from_question_id": link.FromQuestionID,
+                       "to_question_id":   link.ToQuestionID,
+                       "from_answer_id":   link.FromAnswerID,
+                       "to_answer_id":     link.ToAnswerID,
+               })
+       }
+       err = session.Find(&existLinks)
+       if err != nil {
+               return 
errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
+       }
+
+       // Optimize separation of records that need to be updated or inserted 
using a map
+       existMap := make(map[string]*entity.QuestionLink)
+       for _, el := range existLinks {
+               key := fmt.Sprintf("%s:%s:%s:%s", el.FromQuestionID, 
el.ToQuestionID, el.FromAnswerID, el.ToAnswerID)
+               existMap[key] = el
+       }
+
+       var updateLinks []*entity.QuestionLink
+       var insertLinks []*entity.QuestionLink
+       for _, link := range links {
+               key := fmt.Sprintf("%s:%s:%s:%s", link.FromQuestionID, 
link.ToQuestionID, link.FromAnswerID, link.ToAnswerID)
+               if el, exist := existMap[key]; exist {
+                       if el.Status == entity.QuestionLinkStatusDeleted {
+                               el.Status = entity.QuestionLinkStatusAvailable
+                               el.UpdatedAt = time.Now()
+                               updateLinks = append(updateLinks, el)
+                       }
+               } else {
+                       link.Status = entity.QuestionLinkStatusAvailable
+                       link.CreatedAt = time.Now()
+                       link.UpdatedAt = time.Now()
+                       insertLinks = append(insertLinks, link)
+               }
+       }
+
+       // Batch update
+       if len(updateLinks) > 0 {
+               for _, link := range updateLinks {
+                       _, err = 
qr.data.DB.Context(ctx).ID(link.ID).Cols("status").Update(&entity.QuestionLink{Status:
 entity.QuestionLinkStatusAvailable})
+                       if err != nil {
+                               return 
errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
+                       }
+               }
+       }
+
+       // Batch insert
+       if len(insertLinks) > 0 {
+               _, err = qr.data.DB.Context(ctx).Insert(insertLinks)
+               if err != nil {
+                       return 
errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
+               }
+       }
+
+       return
+}
+
+// RecoverQuestionLink batch recover question link
+func (qr *questionRepo) RecoverQuestionLink(ctx context.Context, links 
...*entity.QuestionLink) (err error) {
+       return qr.UpdateQuestionLinkStatus(ctx, 
entity.QuestionLinkStatusAvailable, links...)
+}
+
+// RemoveQuestionLink batch remove question link
+func (qr *questionRepo) RemoveQuestionLink(ctx context.Context, links 
...*entity.QuestionLink) (err error) {
+       return qr.UpdateQuestionLinkStatus(ctx, 
entity.QuestionLinkStatusDeleted, links...)
+}
+
+// UpdateQuestionLinkStatus update question link status
+func (qr *questionRepo) UpdateQuestionLinkStatus(ctx context.Context, status 
int, links ...*entity.QuestionLink) (err error) {
+       if len(links) == 0 {
+               return nil
+       }
+
+       session := qr.data.DB.Context(ctx).Cols("status")
+       for _, link := range links {
+               eq := builder.Eq{}
+               if link.FromQuestionID != "" {
+                       eq["from_question_id"] = 
uid.DeShortID(link.FromQuestionID)
+               }
+               if link.FromAnswerID != "" {
+                       eq["from_answer_id"] = uid.DeShortID(link.FromAnswerID)
+               }
+               if link.ToQuestionID != "" {
+                       eq["to_question_id"] = uid.DeShortID(link.ToQuestionID)
+               }
+               if link.ToAnswerID != "" {
+                       eq["to_answer_id"] = uid.DeShortID(link.ToAnswerID)
+               }
+               session = session.Or(eq)
+       }
+       _, err = session.Update(&entity.QuestionLink{Status: status})
+       if err != nil {
+               return 
errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
+       }
+       return
+}
+
+// GetQuestionLink get linked question to questionID
+func (qr *questionRepo) GetQuestionLink(ctx context.Context, page, pageSize 
int, questionID string, orderCond string, inDays int) (questionList 
[]*entity.Question, total int64, err error) {
+       questionList = make([]*entity.Question, 0)
+       questionID = uid.DeShortID(questionID)
+       questionStatus := []int{entity.QuestionStatusAvailable, 
entity.QuestionStatusPending}
+       if questionID == "0" {
+               return nil, 0, 
errors.InternalServer(reason.DatabaseError).WithError(
+                       fmt.Errorf("questionID is empty"),
+               ).WithStack()
+       }
+
+       session := qr.data.DB.Context(ctx).
+               Table("question_link").
+               Join("INNER", "question", "question_link.from_question_id = 
question.id").
+               Where("question_link.to_question_id = ? AND question.show = ?", 
questionID, entity.QuestionShow).
+               Distinct("question.id").
+               Where("question_link.status = ?", 
entity.QuestionLinkStatusAvailable).
+               Select("question.*").
+               In("question.status", questionStatus)
+
+       switch orderCond {
+       case "newest":
+               session.OrderBy("question.pin desc,question.created_at DESC")
+       case "active":
+               if inDays == 0 {
+                       session.And("question.created_at > ?", 
time.Now().AddDate(0, 0, -180))
+               }
+               session.And("question.post_update_time > ?", 
time.Now().AddDate(0, 0, -90))
+               session.OrderBy("question.pin desc,question.post_update_time 
DESC, question.updated_at DESC")
+       case "hot":
+               session.OrderBy("question.pin desc,question.hot_score DESC")
+       case "score":
+               session.OrderBy("question.pin desc,question.vote_count DESC, 
question.view_count DESC")
+       case "unanswered":
+               session.Where("question.answer_count = 0")
+               session.OrderBy("question.pin desc,question.created_at DESC")
+       }
+
+       if page > 0 && pageSize > 0 {
+               session.Limit(pageSize, (page-1)*pageSize)
+       }
+
+       total, err = pager.Help(page, pageSize, &questionList, 
&entity.Question{}, session)
+       if err != nil {
+               err = 
errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
+       }
+       if handler.GetEnableShortID(ctx) {
+               for _, item := range questionList {
+                       item.ID = uid.EnShortID(item.ID)
+               }
+       }
+       return
+}
diff --git a/internal/router/answer_api_router.go 
b/internal/router/answer_api_router.go
index cca997bd..2927afef 100644
--- a/internal/router/answer_api_router.go
+++ b/internal/router/answer_api_router.go
@@ -169,6 +169,7 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r 
*gin.RouterGroup) {
        r.GET("/question/similar/tag", a.questionController.SimilarQuestion)
        r.GET("/personal/qa/top", a.questionController.UserTop)
        r.GET("/personal/question/page", 
a.questionController.PersonalQuestionPage)
+       r.GET("/question/link", a.questionController.GetQuestionLink)
 
        // comment
        r.GET("/comment/page", a.commentController.GetCommentWithPage)
diff --git a/internal/schema/question_schema.go 
b/internal/schema/question_schema.go
index 2a6e5f12..7c605765 100644
--- a/internal/schema/question_schema.go
+++ b/internal/schema/question_schema.go
@@ -498,3 +498,17 @@ type PersonalCollectionPageReq struct {
        PageSize int    `validate:"omitempty,min=1" form:"page_size"`
        UserID   string `json:"-"`
 }
+
+type GetQuestionLinkReq struct {
+       Page       int    `validate:"omitempty,min=1" form:"page"`
+       PageSize   int    `validate:"omitempty,min=1,max=100" form:"page_size"`
+       QuestionID string `validate:"required" form:"question_id"`
+       OrderCond  string `validate:"omitempty,oneof=newest active hot score 
unanswered recommend" form:"order"`
+       InDays     int    `validate:"omitempty,min=1" form:"in_days"`
+
+       LoginUserID string `json:"-"`
+}
+
+type GetQuestionLinkResp struct {
+       QuestionPageResp
+}
diff --git a/internal/service/answer_common/answer.go 
b/internal/service/answer_common/answer.go
index 1517b22d..45b11886 100644
--- a/internal/service/answer_common/answer.go
+++ b/internal/service/answer_common/answer.go
@@ -39,6 +39,7 @@ type AnswerRepo interface {
        GetAnswerPage(ctx context.Context, page, pageSize int, answer 
*entity.Answer) (answerList []*entity.Answer, total int64, err error)
        UpdateAcceptedStatus(ctx context.Context, acceptedAnswerID string, 
questionID string) error
        GetByID(ctx context.Context, answerID string) (*entity.Answer, bool, 
error)
+       GetByIDs(ctx context.Context, answerIDs ...string) ([]*entity.Answer, 
error)
        GetCountByQuestionID(ctx context.Context, questionID string) (int64, 
error)
        GetCountByUserID(ctx context.Context, userID string) (int64, error)
        GetIDsByUserIDAndQuestionID(ctx context.Context, userID string, 
questionID string) ([]string, error)
diff --git a/internal/service/content/answer_service.go 
b/internal/service/content/answer_service.go
index c34e9081..e8842891 100644
--- a/internal/service/content/answer_service.go
+++ b/internal/service/content/answer_service.go
@@ -22,9 +22,11 @@ package content
 import (
        "context"
        "encoding/json"
-       "github.com/apache/incubator-answer/internal/service/event_queue"
+       "strings"
        "time"
 
+       "github.com/apache/incubator-answer/internal/service/event_queue"
+
        "github.com/apache/incubator-answer/internal/base/constant"
        "github.com/apache/incubator-answer/internal/base/reason"
        "github.com/apache/incubator-answer/internal/entity"
@@ -42,6 +44,7 @@ import (
        "github.com/apache/incubator-answer/internal/service/revision_common"
        "github.com/apache/incubator-answer/internal/service/role"
        usercommon 
"github.com/apache/incubator-answer/internal/service/user_common"
+       "github.com/apache/incubator-answer/pkg/checker"
        "github.com/apache/incubator-answer/pkg/converter"
        "github.com/apache/incubator-answer/pkg/htmltext"
        "github.com/apache/incubator-answer/pkg/token"
@@ -166,6 +169,17 @@ func (as *AnswerService) RemoveAnswer(ctx context.Context, 
req *schema.RemoveAns
        if err != nil {
                log.Error("user IncreaseAnswerCount error", err.Error())
        }
+       err = as.questionRepo.RemoveQuestionLink(ctx, &entity.QuestionLink{
+               FromQuestionID: answerInfo.QuestionID,
+               FromAnswerID:   answerInfo.ID,
+       }, &entity.QuestionLink{
+               ToQuestionID: answerInfo.QuestionID,
+               ToAnswerID:   answerInfo.ID,
+       })
+       if err != nil {
+               log.Error("RemoveQuestionLink error", err.Error())
+       }
+
        // #2372 In order to simplify the process and complexity, as well as to 
consider if it is in-house,
        // facing the problem of recovery.
        //err = as.answerActivityService.DeleteAnswer(ctx, answerInfo.ID, 
answerInfo.CreatedAt, answerInfo.VoteCount)
@@ -199,6 +213,15 @@ func (as *AnswerService) RecoverAnswer(ctx 
context.Context, req *schema.RecoverA
        if err = as.answerRepo.RecoverAnswer(ctx, req.AnswerID); err != nil {
                return err
        }
+       if err = as.questionRepo.RecoverQuestionLink(ctx, &entity.QuestionLink{
+               FromQuestionID: answerInfo.QuestionID,
+               FromAnswerID:   answerInfo.ID,
+       }, &entity.QuestionLink{
+               ToQuestionID: answerInfo.QuestionID,
+               ToAnswerID:   answerInfo.ID,
+       }); err != nil {
+               return err
+       }
 
        if err = as.questionCommon.UpdateAnswerCount(ctx, 
answerInfo.QuestionID); err != nil {
                log.Errorf("update answer count failed: %s", err.Error())
@@ -251,6 +274,15 @@ func (as *AnswerService) Insert(ctx context.Context, req 
*schema.AnswerAddReq) (
        if err := as.answerRepo.UpdateAnswerStatus(ctx, insertData.ID, 
insertData.Status); err != nil {
                return "", err
        }
+       if insertData.Status == entity.AnswerStatusAvailable {
+               insertData.ParsedText, err = as.updateAnswerLink(ctx, 
insertData)
+               if err != nil {
+                       return "", err
+               }
+               if err = as.answerRepo.UpdateAnswer(ctx, insertData, 
[]string{"parsed_text"}); err != nil {
+                       return "", err
+               }
+       }
        err = as.questionCommon.UpdateAnswerCount(ctx, req.QuestionID)
        if err != nil {
                log.Error("IncreaseAnswerCount error", err.Error())
@@ -366,6 +398,10 @@ func (as *AnswerService) Update(ctx context.Context, req 
*schema.AnswerUpdateReq
        if !canUpdate {
                revisionDTO.Status = entity.RevisionUnreviewedStatus
        } else {
+               insertData.ParsedText, err = as.updateAnswerLink(ctx, 
insertData)
+               if err != nil {
+                       return "", err
+               }
                if err = as.answerRepo.UpdateAnswer(ctx, insertData, 
[]string{"original_text", "parsed_text", "updated_at", "last_edit_user_id"}); 
err != nil {
                        return "", err
                }
@@ -710,3 +746,79 @@ func (as *AnswerService) notificationAnswerTheQuestion(ctx 
context.Context,
        externalNotificationMsg.NewAnswerTemplateRawData = rawData
        as.externalNotificationQueueService.Send(ctx, externalNotificationMsg)
 }
+
+func (as *AnswerService) updateAnswerLink(ctx context.Context, answer 
*entity.Answer) (string, error) {
+       err := as.questionRepo.RemoveQuestionLink(ctx, &entity.QuestionLink{
+               FromQuestionID: uid.DeShortID(answer.QuestionID),
+               FromAnswerID:   uid.DeShortID(answer.ID),
+       })
+       retParsedText := answer.ParsedText
+       if err != nil {
+               return retParsedText, err
+       }
+       links := checker.GetQuestionLink(answer.OriginalText)
+       // validate links
+       questionLinks := make([]*entity.QuestionLink, 0)
+       answerCache := make(map[string]string)
+       questionCache := make(map[string]string)
+       answerIDList := make([]string, 0)
+       questionIDList := make([]string, 0)
+       for _, link := range links {
+               if link.AnswerID != "" {
+                       answerIDList = append(answerIDList, link.AnswerID)
+               }
+               if link.QuestionID != "" {
+                       questionIDList = append(questionIDList, link.QuestionID)
+               }
+       }
+       answerInfoList, err := as.answerRepo.GetByIDs(ctx, answerIDList...)
+       if err != nil {
+               return answer.ParsedText, err
+       }
+       for _, answer := range answerInfoList {
+               answerCache[answer.ID] = answer.QuestionID
+       }
+       questionInfoList, err := as.questionRepo.FindByID(ctx, questionIDList)
+       if err != nil {
+               return answer.ParsedText, err
+       }
+       for _, question := range questionInfoList {
+               questionCache[question.ID] = question.ParsedText
+       }
+
+       for _, link := range links {
+               if link.QuestionID != "" {
+                       if _, ok := questionCache[link.QuestionID]; !ok {
+                               continue
+                       }
+               }
+               if link.AnswerID != "" {
+                       if _, ok := answerCache[link.AnswerID]; !ok {
+                               continue
+                       }
+                       if link.QuestionID == "" {
+                               link.QuestionID = answerCache[link.AnswerID]
+                       }
+               }
+
+               questionLinks = append(questionLinks, &entity.QuestionLink{
+                       FromQuestionID: uid.DeShortID(answer.QuestionID),
+                       FromAnswerID:   uid.DeShortID(answer.ID),
+                       ToQuestionID:   uid.DeShortID(link.QuestionID),
+                       ToAnswerID:     uid.DeShortID(link.AnswerID),
+               })
+
+               if link.QuestionID != "" {
+                       retParsedText = strings.Replace(retParsedText, 
"#"+link.QuestionID, "<a 
href=\"/questions/"+link.QuestionID+"\">#"+link.QuestionID+"</a>", -1)
+               }
+               if link.AnswerID != "" {
+                       questionID := answerCache[link.AnswerID]
+                       retParsedText = strings.Replace(retParsedText, 
"#"+link.AnswerID, "<a 
href=\"/questions/"+questionID+"/"+link.AnswerID+"\">#"+link.AnswerID+"</a>", 
-1)
+               }
+       }
+       if err = as.questionRepo.LinkQuestion(ctx, questionLinks...); err != 
nil {
+               return retParsedText, err
+       }
+
+       return retParsedText, nil
+}
diff --git a/internal/service/content/question_service.go 
b/internal/service/content/question_service.go
index 9a6dd280..a57d3fac 100644
--- a/internal/service/content/question_service.go
+++ b/internal/service/content/question_service.go
@@ -22,10 +22,11 @@ package content
 import (
        "encoding/json"
        "fmt"
-       "github.com/apache/incubator-answer/internal/service/event_queue"
        "strings"
        "time"
 
+       "github.com/apache/incubator-answer/internal/service/event_queue"
+
        "github.com/apache/incubator-answer/internal/base/constant"
        "github.com/apache/incubator-answer/internal/base/handler"
        "github.com/apache/incubator-answer/internal/base/pager"
@@ -347,6 +348,16 @@ func (qs *QuestionService) AddQuestion(ctx 
context.Context, req *schema.Question
        if err := qs.questionRepo.UpdateQuestionStatus(ctx, question.ID, 
question.Status); err != nil {
                return nil, err
        }
+       if question.Status == entity.QuestionStatusAvailable {
+               question.ParsedText, err = qs.updateQuestionLink(ctx, question)
+               if err != nil {
+                       return nil, err
+               }
+               err = qs.questionRepo.UpdateQuestion(ctx, question, 
[]string{"parsed_text"})
+               if err != nil {
+                       return nil, err
+               }
+       }
        objectTagData := schema.TagChange{}
        objectTagData.ObjectID = question.ID
        objectTagData.Tags = req.Tags
@@ -425,6 +436,14 @@ func (qs *QuestionService) OperationQuestion(ctx 
context.Context, req *schema.Op
        switch req.Operation {
        case schema.QuestionOperationHide:
                questionInfo.Show = entity.QuestionHide
+               err = qs.questionRepo.RemoveQuestionLink(ctx, 
&entity.QuestionLink{
+                       FromQuestionID: questionInfo.ID,
+               }, &entity.QuestionLink{
+                       ToQuestionID: questionInfo.ID,
+               })
+               if err != nil {
+                       return
+               }
                err = qs.tagCommon.HideTagRelListByObjectID(ctx, req.ID)
                if err != nil {
                        return err
@@ -435,6 +454,14 @@ func (qs *QuestionService) OperationQuestion(ctx 
context.Context, req *schema.Op
                }
        case schema.QuestionOperationShow:
                questionInfo.Show = entity.QuestionShow
+               err = qs.questionRepo.RecoverQuestionLink(ctx, 
&entity.QuestionLink{
+                       FromQuestionID: questionInfo.ID,
+               }, &entity.QuestionLink{
+                       ToQuestionID: questionInfo.ID,
+               })
+               if err != nil {
+                       return
+               }
                err = qs.tagCommon.ShowTagRelListByObjectID(ctx, req.ID)
                if err != nil {
                        return err
@@ -553,6 +580,14 @@ func (qs *QuestionService) RemoveQuestion(ctx 
context.Context, req *schema.Remov
        // if err != nil {
        //       log.Errorf("user DeleteQuestion rank rollback error %s", 
err.Error())
        // }
+       err = qs.questionRepo.RemoveQuestionLink(ctx, &entity.QuestionLink{
+               FromQuestionID: questionInfo.ID,
+       }, &entity.QuestionLink{
+               ToQuestionID: questionInfo.ID,
+       })
+       if err != nil {
+               return
+       }
        qs.activityQueueService.Send(ctx, &schema.ActivityMsg{
                UserID:           questionInfo.UserID,
                TriggerUserID:    converter.StringToInt64(req.UserID),
@@ -677,6 +712,14 @@ func (qs *QuestionService) RecoverQuestion(ctx 
context.Context, req *schema.Ques
                        log.Errorf("update tag's question count failed, %v", 
err)
                }
        }
+       err = qs.questionRepo.RecoverQuestionLink(ctx, &entity.QuestionLink{
+               FromQuestionID: questionInfo.ID,
+       }, &entity.QuestionLink{
+               ToQuestionID: questionInfo.ID,
+       })
+       if err != nil {
+               return
+       }
 
        qs.activityQueueService.Send(ctx, &schema.ActivityMsg{
                UserID:           req.UserID,
@@ -921,6 +964,10 @@ func (qs *QuestionService) UpdateQuestion(ctx 
context.Context, req *schema.Quest
                //Direct modification
                revisionDTO.Status = entity.RevisionReviewPassStatus
                //update question to db
+               question.ParsedText, err = qs.updateQuestionLink(ctx, question)
+               if err != nil {
+                       return questionInfo, err
+               }
                saveerr := qs.questionRepo.UpdateQuestion(ctx, question, 
[]string{"title", "original_text", "parsed_text", "updated_at", 
"post_update_time", "last_edit_user_id"})
                if saveerr != nil {
                        return questionInfo, saveerr
@@ -930,7 +977,7 @@ func (qs *QuestionService) UpdateQuestion(ctx 
context.Context, req *schema.Quest
                objectTagData.Tags = req.Tags
                objectTagData.UserID = req.UserID
                tagerr := qs.ChangeTag(ctx, &objectTagData)
-               if err != nil {
+               if tagerr != nil {
                        return questionInfo, tagerr
                }
        }
@@ -1573,3 +1620,97 @@ func (qs *QuestionService) SitemapCron(ctx 
context.Context) {
        ctx = context.WithValue(ctx, constant.ShortIDFlag, 
siteSeo.IsShortLink())
        qs.questioncommon.SitemapCron(ctx)
 }
+
+func (qs *QuestionService) updateQuestionLink(ctx context.Context, 
questionInfo *entity.Question) (string, error) {
+       err := qs.questionRepo.RemoveQuestionLink(ctx, &entity.QuestionLink{
+               FromQuestionID: questionInfo.ID,
+               FromAnswerID:   "0",
+       })
+       if err != nil {
+               return questionInfo.ParsedText, err
+       }
+       retParsedText := questionInfo.ParsedText
+       links := checker.GetQuestionLink(questionInfo.OriginalText)
+       // validate links
+       questionLinks := make([]*entity.QuestionLink, 0)
+       answerCache := make(map[string]string)
+       questionCache := make(map[string]string)
+       answerIDList := make([]string, 0)
+       questionIDList := make([]string, 0)
+       for _, link := range links {
+               if link.AnswerID != "" {
+                       answerIDList = append(answerIDList, link.AnswerID)
+               }
+               if link.QuestionID != "" {
+                       questionIDList = append(questionIDList, link.QuestionID)
+               }
+       }
+       answerInfoList, err := qs.answerRepo.GetByIDs(ctx, answerIDList...)
+       if err != nil {
+               return questionInfo.ParsedText, err
+       }
+       for _, answer := range answerInfoList {
+               answerCache[answer.ID] = answer.QuestionID
+       }
+       questionInfoList, err := qs.questionRepo.FindByID(ctx, questionIDList)
+       if err != nil {
+               return questionInfo.ParsedText, err
+       }
+       for _, question := range questionInfoList {
+               questionCache[question.ID] = question.ParsedText
+       }
+
+       for _, link := range links {
+               if link.QuestionID != "" {
+                       if _, ok := questionCache[link.QuestionID]; !ok {
+                               continue
+                       }
+               }
+               if link.AnswerID != "" {
+                       if _, ok := answerCache[link.AnswerID]; !ok {
+                               continue
+                       }
+                       if link.QuestionID == "" {
+                               link.QuestionID = answerCache[link.AnswerID]
+                       }
+               }
+
+               questionLinks = append(questionLinks, &entity.QuestionLink{
+                       FromQuestionID: uid.DeShortID(questionInfo.ID),
+                       FromAnswerID:   "0",
+                       ToQuestionID:   uid.DeShortID(link.QuestionID),
+                       ToAnswerID:     uid.DeShortID(link.AnswerID),
+               })
+
+               if link.QuestionID != "" {
+                       retParsedText = strings.Replace(retParsedText, 
"#"+link.QuestionID, "<a 
href=\"/questions/"+link.QuestionID+"\">#"+link.QuestionID+"</a>", -1)
+               }
+               if link.AnswerID != "" {
+                       questionID := answerCache[link.AnswerID]
+                       retParsedText = strings.Replace(retParsedText, 
"#"+link.AnswerID, "<a 
href=\"/questions/"+questionID+"/"+link.AnswerID+"\">#"+link.AnswerID+"</a>", 
-1)
+               }
+       }
+       if err = qs.questionRepo.LinkQuestion(ctx, questionLinks...); err != 
nil {
+               return retParsedText, err
+       }
+
+       return retParsedText, nil
+}
+
+func (qs *QuestionService) GetQuestionLink(ctx context.Context, req 
*schema.GetQuestionLinkReq) (
+       questions []*schema.QuestionPageResp, total int64, err error) {
+       if req.OrderCond == schema.QuestionOrderCondHot {
+               req.InDays = schema.HotInDays
+       }
+
+       questionList, total, err := qs.questionRepo.GetQuestionLink(ctx, 
req.Page, req.PageSize, req.QuestionID, req.OrderCond, req.InDays)
+       if err != nil {
+               return nil, 0, err
+       }
+
+       questions, err = qs.questioncommon.FormatQuestionsPage(ctx, 
questionList, req.LoginUserID, req.OrderCond)
+       if err != nil {
+               return nil, 0, err
+       }
+       return questions, total, nil
+}
diff --git a/internal/service/question_common/question.go 
b/internal/service/question_common/question.go
index 5dab1214..f242b3f8 100644
--- a/internal/service/question_common/question.go
+++ b/internal/service/question_common/question.go
@@ -75,6 +75,11 @@ type QuestionRepo interface {
        SitemapQuestions(ctx context.Context, page, pageSize int) 
(questionIDList []*schema.SiteMapQuestionInfo, err error)
        RemoveAllUserQuestion(ctx context.Context, userID string) (err error)
        UpdateSearch(ctx context.Context, questionID string) (err error)
+       LinkQuestion(ctx context.Context, link ...*entity.QuestionLink) (err 
error)
+       RemoveQuestionLink(ctx context.Context, link ...*entity.QuestionLink) 
(err error)
+       RecoverQuestionLink(ctx context.Context, link ...*entity.QuestionLink) 
(err error)
+       UpdateQuestionLinkStatus(ctx context.Context, status int, links 
...*entity.QuestionLink) (err error)
+       GetQuestionLink(ctx context.Context, page, pageSize int, questionID 
string, orderCond string, inDays int) (questions []*entity.Question, total 
int64, err error)
 }
 
 // QuestionCommon user service
diff --git a/pkg/checker/question_link.go b/pkg/checker/question_link.go
new file mode 100644
index 00000000..5907affa
--- /dev/null
+++ b/pkg/checker/question_link.go
@@ -0,0 +1,107 @@
+package checker
+
+import (
+       "github.com/apache/incubator-answer/internal/base/constant"
+       "github.com/apache/incubator-answer/pkg/obj"
+)
+
+const (
+       QuestionLinkTypeURL = 1
+       QuestionLinkTypeID  = 2
+)
+
+type QuestionLink struct {
+       LinkType   int
+       QuestionID string
+       AnswerID   string
+}
+
+func GetQuestionLink(content string) []QuestionLink {
+       uniqueIDs := make(map[string]struct{})
+       var questionLinks []QuestionLink
+
+       // use two pointer to find the link
+       left, right := 0, 0
+       for right < len(content) {
+               // find "/questions/" or "#"
+               if right+11 < len(content) && content[right:right+11] == 
"/questions/" {
+                       left = right
+                       right += 11
+                       processURL(content, &left, &right, uniqueIDs, 
&questionLinks)
+               } else if content[right] == '#' {
+                       left = right + 1
+                       right = left
+                       processID(content, &left, &right, uniqueIDs, 
&questionLinks)
+               } else {
+                       right++
+               }
+       }
+
+       return questionLinks
+}
+
+func processURL(content string, left, right *int, uniqueIDs 
map[string]struct{}, questionLinks *[]QuestionLink) {
+       for *right < len(content) && isDigit(content[*right]) {
+               *right++
+       }
+       questionID := content[*left+len("/questions/") : *right]
+
+       answerID := ""
+       if *right < len(content) && content[*right] == '/' {
+               *left = *right + 1
+               *right = *left
+               for *right < len(content) && isDigit(content[*right]) {
+                       *right++
+               }
+               answerID = content[*left:*right]
+       }
+
+       addUniqueID(questionID, answerID, QuestionLinkTypeURL, uniqueIDs, 
questionLinks)
+}
+
+func processID(content string, left, right *int, uniqueIDs 
map[string]struct{}, questionLinks *[]QuestionLink) {
+       for *right < len(content) && isDigit(content[*right]) {
+               *right++
+       }
+       id := content[*left:*right]
+       addUniqueID(id, "", QuestionLinkTypeID, uniqueIDs, questionLinks)
+}
+
+func isDigit(c byte) bool {
+       return c >= '0' && c <= '9'
+}
+
+func addUniqueID(questionID, answerID string, linkType int, uniqueIDs 
map[string]struct{}, questionLinks *[]QuestionLink) {
+       if questionID == "" && answerID == "" {
+               return
+       }
+
+       isAdd := false
+       if answerID != "" {
+               if objectType, err := obj.GetObjectTypeStrByObjectID(answerID); 
err == nil && objectType == constant.AnswerObjectType {
+                       if _, ok := uniqueIDs[answerID]; !ok {
+                               uniqueIDs[answerID] = struct{}{}
+                               isAdd = true
+                       }
+               }
+       }
+
+       if objectType, err := obj.GetObjectTypeStrByObjectID(questionID); err 
== nil {
+               if _, ok := uniqueIDs[questionID]; !ok {
+                       uniqueIDs[questionID] = struct{}{}
+                       isAdd = true
+                       if objectType == constant.AnswerObjectType {
+                               answerID = questionID
+                               questionID = ""
+                       }
+               }
+       }
+
+       if isAdd {
+               *questionLinks = append(*questionLinks, QuestionLink{
+                       LinkType:   linkType,
+                       QuestionID: questionID,
+                       AnswerID:   answerID,
+               })
+       }
+}
diff --git a/pkg/checker/question_link_test.go 
b/pkg/checker/question_link_test.go
new file mode 100644
index 00000000..9d734126
--- /dev/null
+++ b/pkg/checker/question_link_test.go
@@ -0,0 +1,141 @@
+package checker_test
+
+import (
+       "testing"
+
+       "github.com/apache/incubator-answer/pkg/checker"
+       "github.com/stretchr/testify/assert"
+)
+
+func TestGetQuestionLink(t *testing.T) {
+       // Step 1: Test empty content
+       t.Run("Empty content", func(t *testing.T) {
+               links := checker.GetQuestionLink("")
+               assert.Empty(t, links)
+       })
+
+       // Step 2: Test content without link or ID
+       t.Run("Content without link or ID", func(t *testing.T) {
+               links := checker.GetQuestionLink("This is a random text")
+               assert.Empty(t, links)
+       })
+
+       // Step 3: Test content with valid question link
+       t.Run("Valid question link", func(t *testing.T) {
+               links := checker.GetQuestionLink("Check this question: 
https://example.com/questions/10010000000000060";)
+               assert.Equal(t, []checker.QuestionLink{
+                       {
+                               LinkType:   checker.QuestionLinkTypeURL,
+                               QuestionID: "10010000000000060",
+                               AnswerID:   "",
+                       },
+               }, links)
+       })
+
+       // Step 4: Test content with valid question and answer link
+       t.Run("Valid question and answer link", func(t *testing.T) {
+               links := checker.GetQuestionLink("Check this answer: 
https://example.com/questions/10010000000000060/10020000000000060?from=copy";)
+               assert.Equal(t, []checker.QuestionLink{
+                       {
+                               LinkType:   checker.QuestionLinkTypeURL,
+                               QuestionID: "10010000000000060",
+                               AnswerID:   "10020000000000060",
+                       },
+               }, links)
+       })
+
+       // Step 5: Test content with #questionID
+       t.Run("Content with #questionID", func(t *testing.T) {
+               links := checker.GetQuestionLink("This is question 
#10010000000000060")
+               assert.Equal(t, []checker.QuestionLink{
+                       {
+                               LinkType:   checker.QuestionLinkTypeID,
+                               QuestionID: "10010000000000060",
+                               AnswerID:   "",
+                       },
+               }, links)
+       })
+
+       // Step 6: Test content with #answerID
+       t.Run("Content with #answerID", func(t *testing.T) {
+               links := checker.GetQuestionLink("This is answer 
#10020000000000060")
+               assert.Equal(t, []checker.QuestionLink{
+                       {
+                               LinkType:   checker.QuestionLinkTypeID,
+                               QuestionID: "",
+                               AnswerID:   "10020000000000060",
+                       },
+               }, links)
+       })
+
+       // Step 7: Test invalid question ID
+       t.Run("Invalid question ID", func(t *testing.T) {
+               links := 
checker.GetQuestionLink("https://example.com/questions/invalid";)
+               assert.Empty(t, links)
+       })
+
+       // Step 8: Test invalid answer ID
+       t.Run("Invalid answer ID", func(t *testing.T) {
+               links := 
checker.GetQuestionLink("https://example.com/questions/10010000000000060/invalid";)
+               assert.Equal(t, []checker.QuestionLink{
+                       {
+                               LinkType:   checker.QuestionLinkTypeURL,
+                               QuestionID: "10010000000000060",
+                               AnswerID:   "",
+                       },
+               }, links)
+       })
+
+       // Step 9: Test content with multiple links and IDs
+       t.Run("Multiple links and IDs", func(t *testing.T) {
+               content := "Question #10010000000000060 and 
https://example.com/questions/10010000000000060/10020000000000061 and 
https://example.com/questions/10010000000000065/10020000000000066 and another 
#10020000000000066"
+               links := checker.GetQuestionLink(content)
+               assert.Equal(t, []checker.QuestionLink{
+                       {
+                               LinkType:   checker.QuestionLinkTypeID,
+                               QuestionID: "10010000000000060",
+                               AnswerID:   "",
+                       },
+                       {
+                               LinkType:   checker.QuestionLinkTypeURL,
+                               QuestionID: "10010000000000060",
+                               AnswerID:   "10020000000000061",
+                       },
+                       {
+                               LinkType:   checker.QuestionLinkTypeURL,
+                               QuestionID: "10010000000000065",
+                               AnswerID:   "10020000000000066",
+                       },
+               }, links)
+       })
+
+       // Step 11: Test URL with www prefix
+       t.Run("URL with www prefix", func(t *testing.T) {
+               links := checker.GetQuestionLink("Check this question: 
https://www.example.com/questions/10010000000000060";)
+               assert.Equal(t, []checker.QuestionLink{
+                       {
+                               LinkType:   checker.QuestionLinkTypeURL,
+                               QuestionID: "10010000000000060",
+                               AnswerID:   "",
+                       },
+               }, links)
+       })
+
+       // Step 12: Test URL without protocol
+       t.Run("URL without protocol", func(t *testing.T) {
+               links := checker.GetQuestionLink("Check this question: 
example.com/questions/10010000000000060")
+               assert.Equal(t, []checker.QuestionLink{
+                       {
+                               LinkType:   checker.QuestionLinkTypeURL,
+                               QuestionID: "10010000000000060",
+                               AnswerID:   "",
+                       },
+               }, links)
+       })
+
+       // Step 14: Test error id
+       t.Run("Error id", func(t *testing.T) {
+               links := 
checker.GetQuestionLink("https://example.com/questions/10110000000000060";)
+               assert.Empty(t, links)
+       })
+}
diff --git a/ui/src/components/QuestionList/index.tsx 
b/ui/src/components/QuestionList/index.tsx
index c41f4b9d..066711bb 100644
--- a/ui/src/components/QuestionList/index.tsx
+++ b/ui/src/components/QuestionList/index.tsx
@@ -46,7 +46,7 @@ export const QUESTION_ORDER_KEYS: Type.QuestionOrderBy[] = [
   'recommend',
 ];
 interface Props {
-  source: 'questions' | 'tag';
+  source: 'questions' | 'tag' | 'linked';
   order?: Type.QuestionOrderBy;
   data;
   orderList?: Type.QuestionOrderBy[];
diff --git a/ui/src/pages/Questions/Detail/components/LinkedQuestions/index.tsx 
b/ui/src/pages/Questions/Detail/components/LinkedQuestions/index.tsx
new file mode 100644
index 00000000..a3a7a67a
--- /dev/null
+++ b/ui/src/pages/Questions/Detail/components/LinkedQuestions/index.tsx
@@ -0,0 +1,126 @@
+/*
+ * 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.
+ */
+
+import { memo, FC } from 'react';
+import { Card, ListGroup } from 'react-bootstrap';
+import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+import { Icon } from '@/components';
+import { useQuestionLink } from '@/services';
+import { pathFactory } from '@/router/pathFactory';
+
+interface Props {
+  id: string;
+}
+const Index: FC<Props> = ({ id }) => {
+  const { t } = useTranslation('translation', {
+    keyPrefix: 'linked_question',
+  });
+  const { t: t2 } = useTranslation('translation', {
+    keyPrefix: 'related_question',
+  });
+
+  const { data, isLoading } = useQuestionLink({
+    question_id: id,
+    page: 1,
+    page_size: 5,
+  });
+
+  if (isLoading) {
+    return null;
+  }
+
+  if (!data || !data.list) {
+    return (
+      <Card className="mb-4">
+        <Card.Header className="text-nowrap d-flex justify-content-between 
text-capitalize">
+          {t('title')}
+          <Link to={`/questions/linked/${id}`} className="btn btn-link p-0">
+            {t('more', { keyPrefix: 'btns' })}
+          </Link>
+        </Card.Header>
+        <ListGroup variant="flush">
+          <div
+            className="text-muted"
+            style={{
+              padding: 'var(--bs-card-spacer-y) var(--bs-card-spacer-x)',
+            }}>
+            {t('no_linked_question')}
+          </div>
+        </ListGroup>
+      </Card>
+    );
+  }
+
+  return (
+    <Card className="mb-4">
+      <Card.Header className="text-nowrap d-flex justify-content-between 
text-capitalize">
+        {t('title')}
+        <Link to={`/questions/linked/${id}`} className="btn btn-link p-0">
+          {t('more', { keyPrefix: 'btns' })}
+        </Link>
+      </Card.Header>
+      <ListGroup variant="flush">
+        {data.list.map((item) => {
+          return (
+            <ListGroup.Item
+              action
+              key={item.id}
+              as={Link}
+              to={pathFactory.questionLanding(item.id, item.url_title)}>
+              <div className="link-dark">{item.title}</div>
+              {item.answer_count > 0 && (
+                <div
+                  className={`mt-1 small me-2 ${
+                    item.accepted_answer_id > 0
+                      ? 'link-success'
+                      : 'link-secondary'
+                  }`}>
+                  <Icon
+                    name={
+                      item.accepted_answer_id > 0
+                        ? 'check-circle-fill'
+                        : 'chat-square-text-fill'
+                    }
+                    className="me-1"
+                  />
+                  <span>
+                    {item.answer_count} {t2('answers')}
+                  </span>
+                </div>
+              )}
+            </ListGroup.Item>
+          );
+        })}
+        {data.list.length === 0 ? (
+          <div
+            className="text-muted"
+            style={{
+              padding: 'var(--bs-card-spacer-y) var(--bs-card-spacer-x)',
+            }}>
+            {t('no_linked_question')}
+          </div>
+        ) : null}
+      </ListGroup>
+    </Card>
+  );
+};
+
+export default memo(Index);
diff --git a/ui/src/pages/Questions/Detail/components/index.tsx 
b/ui/src/pages/Questions/Detail/components/index.tsx
index 1cfe75ee..ee665790 100644
--- a/ui/src/pages/Questions/Detail/components/index.tsx
+++ b/ui/src/pages/Questions/Detail/components/index.tsx
@@ -25,6 +25,7 @@ import WriteAnswer from './WriteAnswer';
 import Alert from './Alert';
 import ContentLoader from './ContentLoader';
 import InviteToAnswer from './InviteToAnswer';
+import LinkedQuestions from './LinkedQuestions';
 
 export {
   Question,
@@ -35,4 +36,5 @@ export {
   Alert,
   ContentLoader,
   InviteToAnswer,
+  LinkedQuestions,
 };
diff --git a/ui/src/pages/Questions/Detail/index.tsx 
b/ui/src/pages/Questions/Detail/index.tsx
index 0ab02514..8c8921b8 100644
--- a/ui/src/pages/Questions/Detail/index.tsx
+++ b/ui/src/pages/Questions/Detail/index.tsx
@@ -47,6 +47,7 @@ import {
   Alert,
   ContentLoader,
   InviteToAnswer,
+  LinkedQuestions,
 } from './components';
 
 import './index.scss';
@@ -230,6 +231,7 @@ const Index = () => {
   });
 
   const showInviteToAnswer = question?.id;
+  const showLinkedQuestions = question?.id && question.id !== '';
   let canInvitePeople = false;
   if (showInviteToAnswer && Array.isArray(question.extends_actions)) {
     const inviteAct = question.extends_actions.find((op) => {
@@ -305,6 +307,7 @@ const Index = () => {
             readOnly={!canInvitePeople}
           />
         ) : null}
+        {showLinkedQuestions ? <LinkedQuestions id={question.id} /> : null}
         <RelatedQuestions id={question?.id || ''} />
       </Col>
     </Row>
diff --git a/ui/src/pages/Questions/Linked/index.tsx 
b/ui/src/pages/Questions/Linked/index.tsx
new file mode 100644
index 00000000..14224d77
--- /dev/null
+++ b/ui/src/pages/Questions/Linked/index.tsx
@@ -0,0 +1,95 @@
+import { FC } from 'react';
+import { Row, Col } from 'react-bootstrap';
+import { useParams, useSearchParams, Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+import { usePageTags } from '@/hooks';
+import { useQuestionLink } from '@/services';
+import * as Type from '@/common/interface';
+import {
+  QuestionList,
+  CustomSidebar,
+  HotQuestions,
+  FollowingTags,
+} from '@/components';
+import { userCenter, floppyNavigation } from '@/utils';
+import { QUESTION_ORDER_KEYS } from '@/components/QuestionList';
+import {
+  loggedUserInfoStore,
+  siteInfoStore,
+  loginSettingStore,
+} from '@/stores';
+
+const LinkedQuestions: FC = () => {
+  const { qid } = useParams<{ qid: string }>();
+  const { t } = useTranslation('translation', { keyPrefix: 'linked_question' 
});
+  const { t: t2 } = useTranslation('translation');
+  const { user: loggedUser } = loggedUserInfoStore((_) => _);
+  const [urlSearchParams] = useSearchParams();
+  const curPage = Number(urlSearchParams.get('page')) || 1;
+  const curOrder = (urlSearchParams.get('order') ||
+    QUESTION_ORDER_KEYS[0]) as Type.QuestionOrderBy;
+  const pageSize = 10;
+  const { siteInfo } = siteInfoStore();
+  const { data: listData, isLoading: listLoading } = useQuestionLink({
+    question_id: qid || '',
+    page: curPage,
+    page_size: pageSize,
+  });
+  const { login: loginSetting } = loginSettingStore();
+
+  usePageTags({
+    title: t('title'),
+  });
+
+  return (
+    <Row className="pt-4 mb-5">
+      <Col className="page-main flex-auto">
+        <QuestionList
+          source="linked"
+          data={listData}
+          order={curOrder}
+          orderList={
+            loggedUser.username
+              ? QUESTION_ORDER_KEYS
+              : QUESTION_ORDER_KEYS.filter((key) => key !== 'recommend')
+          }
+          isLoading={listLoading}
+        />
+      </Col>
+      <Col className="page-right-side mt-4 mt-xl-0">
+        <CustomSidebar />
+        {!loggedUser.username && (
+          <div className="card mb-4">
+            <div className="card-body">
+              <h5 className="card-title">
+                {t2('website_welcome', {
+                  site_name: siteInfo.name,
+                })}
+              </h5>
+              <p className="card-text">{siteInfo.description}</p>
+              <Link
+                to={userCenter.getLoginUrl()}
+                className="btn btn-primary"
+                onClick={floppyNavigation.handleRouteLinkClick}>
+                {t('login', { keyPrefix: 'btns' })}
+              </Link>
+              {loginSetting.allow_new_registrations ? (
+                <Link
+                  to={userCenter.getSignUpUrl()}
+                  className="btn btn-link ms-2"
+                  onClick={floppyNavigation.handleRouteLinkClick}>
+                  {t('signup', { keyPrefix: 'btns' })}
+                </Link>
+              ) : null}
+            </div>
+          </div>
+        )}
+        {loggedUser.access_token && <FollowingTags />}
+        <HotQuestions />
+      </Col>
+    </Row>
+  );
+};
+
+export default LinkedQuestions;
diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts
index aa31266e..b89f312c 100644
--- a/ui/src/router/routes.ts
+++ b/ui/src/router/routes.ts
@@ -107,6 +107,10 @@ const routes: RouteNode[] = [
             path: 'questions/:qid/:slugPermalink/:aid',
             page: 'pages/Questions/Detail',
           },
+          {
+            path: 'questions/linked/:qid',
+            page: 'pages/Questions/Linked',
+          },
           {
             path: '/search',
             page: 'pages/Search',
diff --git a/ui/src/services/client/question.ts 
b/ui/src/services/client/question.ts
index b62fbf1a..34d1e75c 100644
--- a/ui/src/services/client/question.ts
+++ b/ui/src/services/client/question.ts
@@ -119,3 +119,20 @@ export const unDeleteQuestion = (qid) => {
     question_id: qid,
   });
 };
+
+export const useQuestionLink = (params: {
+  question_id: string;
+  page: number;
+  page_size: number;
+}) => {
+  const apiUrl = `/answer/api/v1/question/link?${qs.stringify(params)}`;
+  const { data, error } = useSWR<Type.ListResult, Error>(
+    [apiUrl, params],
+    request.instance.get,
+  );
+  return {
+    data,
+    isLoading: !data && !error,
+    error,
+  };
+};

Reply via email to