You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@answer.apache.org by li...@apache.org on 2026/10/12 06:58:37 UTC

(answer) branch fix/2.0.4/user created (now b45786f0)

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

LinkinStars pushed a change to branch fix/2.0.4/user
in repository https://gitbox.apache.org/repos/asf/answer.git


      at b45786f0 fix(security): enforce profile content visibility

This branch includes the following new commits:

     new bca2a697 fix(security): enforce collection visibility controls
     new b45786f0 fix(security): enforce profile content visibility

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(answer) 01/02: fix(security): enforce collection visibility controls

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

LinkinStars pushed a commit to branch fix/2.0.4/user
in repository https://gitbox.apache.org/repos/asf/answer.git

commit bca2a69792a081574987c4798c316e37a3eb9010
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Fri Oct 9 16:34:41 2026 +0800

    fix(security): enforce collection visibility controls
    
    Apply consistent authorization checks to collection operations and listings.
---
 cmd/wire_gen.go                                    |  2 +-
 internal/controller/collection_controller.go       |  1 +
 internal/controller/question_controller.go         |  1 +
 internal/entity/collection_entity.go               |  5 +-
 internal/repo/collection/collection_repo.go        |  9 ++-
 .../collection/collection_repo_visibility_test.go  | 76 ++++++++++++++++++++++
 internal/schema/collection_group_schema.go         |  9 +--
 internal/schema/question_schema.go                 |  7 +-
 internal/service/collection/collection_service.go  | 20 ++++++
 .../collection_service_visibility_test.go          | 74 +++++++++++++++++++++
 internal/service/content/question_service.go       | 12 +++-
 .../content/question_service_visibility_test.go    | 28 ++++++++
 12 files changed, 231 insertions(+), 13 deletions(-)

diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 66e16278..8c8a689f 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -228,7 +228,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database,
 	followService := follow.NewFollowService(followFollowRepo, followRepo, tagCommonRepo)
 	followController := controller.NewFollowController(followService)
 	collectionGroupRepo := collection.NewCollectionGroupRepo(dataData)
-	collectionService := collection2.NewCollectionService(collectionRepo, collectionGroupRepo, questionCommon)
+	collectionService := collection2.NewCollectionService(collectionRepo, collectionGroupRepo, questionCommon, objService)
 	collectionController := controller.NewCollectionController(collectionService)
 	questionController := controller.NewQuestionController(questionService, answerService, rankService, siteInfoCommonService, captchaService, rateLimitMiddleware)
 	answerController := controller.NewAnswerController(answerService, rankService, captchaService, siteInfoCommonService, rateLimitMiddleware)
diff --git a/internal/controller/collection_controller.go b/internal/controller/collection_controller.go
index 78560be0..0c70d454 100644
--- a/internal/controller/collection_controller.go
+++ b/internal/controller/collection_controller.go
@@ -56,6 +56,7 @@ func (cc *CollectionController) CollectionSwitch(ctx *gin.Context) {
 
 	req.ObjectID = uid.DeShortID(req.ObjectID)
 	req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
 
 	resp, err := cc.collectionService.CollectionSwitch(ctx, req)
 	handler.HandleResponse(ctx, err, resp)
diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go
index 8294cdcb..389750e1 100644
--- a/internal/controller/question_controller.go
+++ b/internal/controller/question_controller.go
@@ -933,6 +933,7 @@ func (qc *QuestionController) PersonalCollectionPage(ctx *gin.Context) {
 	}
 
 	req.UserID = middleware.GetLoginUserIDFromContext(ctx)
+	req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
 
 	resp, err := qc.questionService.PersonalCollectionPage(ctx, req)
 	handler.HandleResponse(ctx, err, resp)
diff --git a/internal/entity/collection_entity.go b/internal/entity/collection_entity.go
index 24ae232f..f4cb00ba 100644
--- a/internal/entity/collection_entity.go
+++ b/internal/entity/collection_entity.go
@@ -33,8 +33,9 @@ type Collection struct {
 
 type CollectionSearch struct {
 	Collection
-	Page     int `json:"page" form:"page"`           // Query number of pages
-	PageSize int `json:"page_size" form:"page_size"` // Search page size
+	Page             int  `json:"page" form:"page"`           // Query number of pages
+	PageSize         int  `json:"page_size" form:"page_size"` // Search page size
+	IsAdminModerator bool `json:"-"`
 }
 
 // TableName collection table name
diff --git a/internal/repo/collection/collection_repo.go b/internal/repo/collection/collection_repo.go
index 482cb075..ffd89ce7 100644
--- a/internal/repo/collection/collection_repo.go
+++ b/internal/repo/collection/collection_repo.go
@@ -203,12 +203,17 @@ func (cr *collectionRepo) SearchList(ctx context.Context, search *entity.Collect
 	offset := search.Page * search.PageSize
 	session := cr.data.DB.Context(ctx).Where("")
 	if len(search.UserID) > 0 {
-		session = session.And("user_id = ?", search.UserID)
+		session = session.Join("INNER", "question", "collection.object_id = question.id").
+			And("collection.user_id = ?", search.UserID)
 	} else {
 		return rows, count, nil
 	}
+	if !search.IsAdminModerator {
+		session = session.And("(question.status IN (?, ?) AND question.show = ?) OR question.user_id = ?",
+			entity.QuestionStatusAvailable, entity.QuestionStatusClosed, entity.QuestionShow, search.UserID)
+	}
 	session = session.Limit(search.PageSize, offset)
-	count, err = session.OrderBy("updated_at desc").FindAndCount(&rows)
+	count, err = session.OrderBy("collection.updated_at desc").FindAndCount(&rows)
 	if err != nil {
 		return rows, count, err
 	}
diff --git a/internal/repo/collection/collection_repo_visibility_test.go b/internal/repo/collection/collection_repo_visibility_test.go
new file mode 100644
index 00000000..e03337a9
--- /dev/null
+++ b/internal/repo/collection/collection_repo_visibility_test.go
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package collection
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apache/answer/internal/base/data"
+	"github.com/apache/answer/internal/entity"
+)
+
+func TestSearchListExcludesRestrictedQuestionsForOtherUsers(t *testing.T) {
+	ctx := context.Background()
+	db, err := data.NewDB(false, &data.Database{Driver: "sqlite", Connection: ":memory:"})
+	if err != nil {
+		t.Fatalf("create SQLite database: %v", err)
+	}
+	t.Cleanup(func() { _ = db.Close() })
+	if err := db.Sync2(new(entity.Question), new(entity.Collection)); err != nil {
+		t.Fatalf("create tables: %v", err)
+	}
+
+	questions := []*entity.Question{
+		{ID: "1001", UserID: "author", Status: entity.QuestionStatusAvailable, Show: entity.QuestionShow},
+		{ID: "1002", UserID: "author", Status: entity.QuestionStatusAvailable, Show: entity.QuestionHide},
+		{ID: "1003", UserID: "author", Status: entity.QuestionStatusPending, Show: entity.QuestionShow},
+		{ID: "1004", UserID: "viewer", Status: entity.QuestionStatusAvailable, Show: entity.QuestionHide},
+		{ID: "1005", UserID: "author", Status: entity.QuestionStatusClosed, Show: entity.QuestionShow},
+	}
+	for _, question := range questions {
+		if _, err := db.Context(ctx).Insert(question); err != nil {
+			t.Fatalf("insert question %s: %v", question.ID, err)
+		}
+		if _, err := db.Context(ctx).Insert(&entity.Collection{ID: "200" + question.ID[3:], UserID: "viewer", ObjectID: question.ID}); err != nil {
+			t.Fatalf("insert collection for %s: %v", question.ID, err)
+		}
+	}
+
+	repo := &collectionRepo{data: &data.Data{DB: db}}
+	collections, total, err := repo.SearchList(ctx, &entity.CollectionSearch{
+		Collection: entity.Collection{UserID: "viewer"},
+		Page:       1,
+		PageSize:   10,
+	})
+	if err != nil {
+		t.Fatalf("search collections: %v", err)
+	}
+	if total != 3 || len(collections) != 3 {
+		t.Fatalf("visible collections = %d (total %d), want 3", len(collections), total)
+	}
+	visible := map[string]bool{}
+	for _, collection := range collections {
+		visible[collection.ObjectID] = true
+	}
+	if !visible["1001"] || !visible["1004"] || !visible["1005"] || visible["1002"] || visible["1003"] {
+		t.Fatalf("visible collections = %v, want 1001, 1004, and 1005", visible)
+	}
+}
diff --git a/internal/schema/collection_group_schema.go b/internal/schema/collection_group_schema.go
index 3177f3f1..3a4ac2cc 100644
--- a/internal/schema/collection_group_schema.go
+++ b/internal/schema/collection_group_schema.go
@@ -28,10 +28,11 @@ const (
 
 // CollectionSwitchReq switch collection request
 type CollectionSwitchReq struct {
-	ObjectID string `validate:"required" json:"object_id"`
-	GroupID  string `validate:"required" json:"group_id"`
-	Bookmark bool   `validate:"omitempty" json:"bookmark"`
-	UserID   string `json:"-"`
+	ObjectID         string `validate:"required" json:"object_id"`
+	GroupID          string `validate:"required" json:"group_id"`
+	Bookmark         bool   `validate:"omitempty" json:"bookmark"`
+	UserID           string `json:"-"`
+	IsAdminModerator bool   `json:"-"`
 }
 
 // CollectionSwitchResp switch collection response
diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go
index 4c6d2ead..6b24b550 100644
--- a/internal/schema/question_schema.go
+++ b/internal/schema/question_schema.go
@@ -508,9 +508,10 @@ type PersonalAnswerPageReq struct {
 }
 
 type PersonalCollectionPageReq struct {
-	Page     int    `validate:"omitempty,min=1" form:"page"`
-	PageSize int    `validate:"omitempty,min=1" form:"page_size"`
-	UserID   string `json:"-"`
+	Page             int    `validate:"omitempty,min=1" form:"page"`
+	PageSize         int    `validate:"omitempty,min=1" form:"page_size"`
+	UserID           string `json:"-"`
+	IsAdminModerator bool   `json:"-"`
 }
 
 type GetQuestionLinkReq struct {
diff --git a/internal/service/collection/collection_service.go b/internal/service/collection/collection_service.go
index 6f02ccf9..8402c324 100644
--- a/internal/service/collection/collection_service.go
+++ b/internal/service/collection/collection_service.go
@@ -22,33 +22,53 @@ package collection
 import (
 	"context"
 
+	"github.com/apache/answer/internal/base/reason"
 	"github.com/apache/answer/internal/entity"
 	"github.com/apache/answer/internal/schema"
 	collectioncommon "github.com/apache/answer/internal/service/collection_common"
 	questioncommon "github.com/apache/answer/internal/service/question_common"
+	"github.com/segmentfault/pacman/errors"
 )
 
+type ObjectInfoService interface {
+	GetInfo(ctx context.Context, objectID string) (*schema.SimpleObjectInfo, error)
+}
+
 // CollectionService user service
 type CollectionService struct {
 	collectionRepo      collectioncommon.CollectionRepo
 	collectionGroupRepo CollectionGroupRepo
 	questionCommon      *questioncommon.QuestionCommon
+	objectInfoService   ObjectInfoService
 }
 
 func NewCollectionService(
 	collectionRepo collectioncommon.CollectionRepo,
 	collectionGroupRepo CollectionGroupRepo,
 	questionCommon *questioncommon.QuestionCommon,
+	objectInfoService ObjectInfoService,
 ) *CollectionService {
 	return &CollectionService{
 		collectionRepo:      collectionRepo,
 		collectionGroupRepo: collectionGroupRepo,
 		questionCommon:      questionCommon,
+		objectInfoService:   objectInfoService,
 	}
 }
 
 func (cs *CollectionService) CollectionSwitch(ctx context.Context, req *schema.CollectionSwitchReq) (
 	resp *schema.CollectionSwitchResp, err error) {
+	objectInfo, err := cs.objectInfoService.GetInfo(ctx, req.ObjectID)
+	if err != nil {
+		if serviceErr, ok := err.(*errors.Error); ok && serviceErr.Reason == reason.ObjectNotFound {
+			return nil, errors.NotFound(reason.QuestionNotFound)
+		}
+		return nil, err
+	}
+	if err := objectInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
+		return nil, err
+	}
+
 	collectionGroup, err := cs.collectionGroupRepo.CreateDefaultGroupIfNotExist(ctx, req.UserID)
 	if err != nil {
 		return nil, err
diff --git a/internal/service/collection/collection_service_visibility_test.go b/internal/service/collection/collection_service_visibility_test.go
new file mode 100644
index 00000000..79f886dd
--- /dev/null
+++ b/internal/service/collection/collection_service_visibility_test.go
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package collection
+
+import (
+	"context"
+	"testing"
+
+	"github.com/apache/answer/internal/base/constant"
+	"github.com/apache/answer/internal/base/reason"
+	"github.com/apache/answer/internal/entity"
+	"github.com/apache/answer/internal/schema"
+	"github.com/segmentfault/pacman/errors"
+)
+
+type collectionTestObjectInfoService struct {
+	objectInfo *schema.SimpleObjectInfo
+	err        error
+}
+
+func (s collectionTestObjectInfoService) GetInfo(context.Context, string) (*schema.SimpleObjectInfo, error) {
+	return s.objectInfo, s.err
+}
+
+func TestCollectionSwitchRejectsHiddenQuestionForOtherUsers(t *testing.T) {
+	service := &CollectionService{objectInfoService: collectionTestObjectInfoService{
+		objectInfo: &schema.SimpleObjectInfo{
+			ObjectType:            constant.QuestionObjectType,
+			QuestionCreatorUserID: "question-owner",
+			QuestionStatus:        entity.QuestionStatusAvailable,
+			QuestionShow:          entity.QuestionHide,
+		},
+	}}
+
+	_, err := service.CollectionSwitch(context.Background(), &schema.CollectionSwitchReq{
+		ObjectID: "question-id",
+		UserID:   "other-user",
+		Bookmark: true,
+	})
+	if err == nil {
+		t.Fatal("CollectionSwitch() allowed another user to bookmark a hidden question")
+	}
+}
+
+func TestCollectionSwitchReturnsNotFoundForMissingQuestion(t *testing.T) {
+	service := &CollectionService{objectInfoService: collectionTestObjectInfoService{
+		err: errors.BadRequest(reason.ObjectNotFound),
+	}}
+	_, err := service.CollectionSwitch(context.Background(), &schema.CollectionSwitchReq{ObjectID: "question-id", UserID: "other-user"})
+	if err == nil {
+		t.Fatal("CollectionSwitch() allowed a missing question")
+	}
+	serviceErr, ok := err.(*errors.Error)
+	if !ok || serviceErr.Code != 404 || serviceErr.Reason != reason.QuestionNotFound {
+		t.Fatalf("CollectionSwitch() error = %#v, want QuestionNotFound", err)
+	}
+}
diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go
index ced0e010..ed33f9ea 100644
--- a/internal/service/content/question_service.go
+++ b/internal/service/content/question_service.go
@@ -1284,6 +1284,7 @@ func (qs *QuestionService) PersonalCollectionPage(ctx context.Context, req *sche
 	collectionSearch.UserID = req.UserID
 	collectionSearch.Page = req.Page
 	collectionSearch.PageSize = req.PageSize
+	collectionSearch.IsAdminModerator = req.IsAdminModerator
 	collectionList, total, err := qs.collectionCommon.SearchList(ctx, collectionSearch)
 	if err != nil {
 		return nil, err
@@ -1302,7 +1303,7 @@ func (qs *QuestionService) PersonalCollectionPage(ctx context.Context, req *sche
 			id = uid.EnShortID(id)
 		}
 		_, ok := questionMaps[id]
-		if ok {
+		if ok && canViewPersonalCollectionQuestion(questionMaps[id], req.UserID, req.IsAdminModerator) {
 			questionMaps[id].LastAnsweredUserInfo = nil
 			questionMaps[id].UpdateUserInfo = nil
 			questionMaps[id].Content = ""
@@ -1317,6 +1318,15 @@ func (qs *QuestionService) PersonalCollectionPage(ctx context.Context, req *sche
 	return pager.NewPageModel(total, list), nil
 }
 
+func canViewPersonalCollectionQuestion(question *schema.QuestionInfoResp, userID string, isAdminModerator bool) bool {
+	return (&schema.SimpleObjectInfo{
+		ObjectType:            constant.QuestionObjectType,
+		QuestionCreatorUserID: question.UserID,
+		QuestionStatus:        question.Status,
+		QuestionShow:          question.Show,
+	}).CheckVisibility(userID, isAdminModerator) == nil
+}
+
 func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName string, loginUserID string) ([]*schema.UserQuestionInfo, []*schema.UserAnswerInfo, error) {
 	answerlist := make([]*schema.AnswerInfo, 0)
 
diff --git a/internal/service/content/question_service_visibility_test.go b/internal/service/content/question_service_visibility_test.go
index 5cfeb5b7..fd0d98cf 100644
--- a/internal/service/content/question_service_visibility_test.go
+++ b/internal/service/content/question_service_visibility_test.go
@@ -138,3 +138,31 @@ func TestCheckQuestionVisibility(t *testing.T) {
 		})
 	}
 }
+
+func TestCanViewPersonalCollectionQuestion(t *testing.T) {
+	testCases := []struct {
+		name    string
+		status  int
+		show    int
+		author  string
+		viewer  string
+		isAdmin bool
+		allow   bool
+	}{
+		{"public question", entity.QuestionStatusAvailable, entity.QuestionShow, "author", "viewer", false, true},
+		{"closed question", entity.QuestionStatusClosed, entity.QuestionShow, "author", "viewer", false, true},
+		{"hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "viewer", false, false},
+		{"pending question", entity.QuestionStatusPending, entity.QuestionShow, "author", "viewer", false, false},
+		{"owner hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "author", false, true},
+		{"moderator hidden question", entity.QuestionStatusAvailable, entity.QuestionHide, "author", "viewer", true, true},
+	}
+
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			question := &schema.QuestionInfoResp{UserID: testCase.author, Status: testCase.status, Show: testCase.show}
+			if got := canViewPersonalCollectionQuestion(question, testCase.viewer, testCase.isAdmin); got != testCase.allow {
+				t.Fatalf("canViewPersonalCollectionQuestion() = %t, want %t", got, testCase.allow)
+			}
+		})
+	}
+}


(answer) 02/02: fix(security): enforce profile content visibility

Posted by li...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

LinkinStars pushed a commit to branch fix/2.0.4/user
in repository https://gitbox.apache.org/repos/asf/answer.git

commit b45786f0a2c28c99d5851f62ef76ce75b2733bca
Author: LinkinStars <li...@foxmail.com>
AuthorDate: Sat Oct 10 13:58:01 2026 +0800

    fix(security): enforce profile content visibility
    
    Apply consistent public visibility rules when building profile content.
---
 internal/entity/answer_entity.go                   | 11 ++--
 internal/repo/answer/answer_repo.go                | 21 +++++---
 .../repo_test/personal_answer_visibility_test.go   | 58 ++++++++++++++++++++++
 internal/service/content/question_service.go       | 10 +++-
 4 files changed, 86 insertions(+), 14 deletions(-)

diff --git a/internal/entity/answer_entity.go b/internal/entity/answer_entity.go
index 0e93aa97..2c825391 100644
--- a/internal/entity/answer_entity.go
+++ b/internal/entity/answer_entity.go
@@ -57,11 +57,12 @@ type Answer struct {
 
 type AnswerSearch struct {
 	Answer
-	IncludeDeleted bool   `json:"include_deleted"`
-	LoginUserID    string `json:"login_user_id"`
-	Order          string `json:"order_by"`                   // default or updated
-	Page           int    `json:"page" form:"page"`           // Query number of pages
-	PageSize       int    `json:"page_size" form:"page_size"` // Search page size
+	IncludeDeleted     bool   `json:"include_deleted"`
+	OnlyPublicQuestion bool   `json:"-"`
+	LoginUserID        string `json:"login_user_id"`
+	Order              string `json:"order_by"`                   // default or updated
+	Page               int    `json:"page" form:"page"`           // Query number of pages
+	PageSize           int    `json:"page_size" form:"page_size"` // Search page size
 }
 
 type PersonalAnswerPageQueryCond struct {
diff --git a/internal/repo/answer/answer_repo.go b/internal/repo/answer/answer_repo.go
index 615d8dfb..40f78b3a 100644
--- a/internal/repo/answer/answer_repo.go
+++ b/internal/repo/answer/answer_repo.go
@@ -348,28 +348,33 @@ func (ar *answerRepo) SearchList(ctx context.Context, search *entity.AnswerSearc
 	}
 	offset := search.Page * search.PageSize
 	session := ar.data.DB.Context(ctx)
+	if search.OnlyPublicQuestion {
+		session = session.Join("INNER", "question", "answer.question_id = question.id").
+			And("question.show = ? AND question.status IN (?, ?)",
+				entity.QuestionShow, entity.QuestionStatusAvailable, entity.QuestionStatusClosed)
+	}
 
 	if search.QuestionID != "" {
-		session = session.And("question_id = ?", search.QuestionID)
+		session = session.And("answer.question_id = ?", search.QuestionID)
 	}
 	if len(search.UserID) > 0 {
-		session = session.And("user_id = ?", search.UserID)
+		session = session.And("answer.user_id = ?", search.UserID)
 	}
 	switch search.Order {
 	case entity.AnswerSearchOrderByTime:
-		session = session.OrderBy("created_at desc")
+		session = session.OrderBy("answer.created_at desc")
 	case entity.AnswerSearchOrderByTimeAsc:
-		session = session.OrderBy("created_at asc")
+		session = session.OrderBy("answer.created_at asc")
 	case entity.AnswerSearchOrderByVote:
-		session = session.OrderBy("vote_count desc")
+		session = session.OrderBy("answer.vote_count desc")
 	default:
-		session = session.OrderBy("adopted desc,vote_count desc,created_at asc")
+		session = session.OrderBy("answer.adopted desc,answer.vote_count desc,answer.created_at asc")
 	}
 	if !search.IncludeDeleted {
 		if search.LoginUserID == "" {
-			session = session.And("status = ? ", entity.AnswerStatusAvailable)
+			session = session.And("answer.status = ? ", entity.AnswerStatusAvailable)
 		} else {
-			session = session.And("status = ? OR user_id = ?", entity.AnswerStatusAvailable, search.LoginUserID)
+			session = session.And("answer.status = ? OR answer.user_id = ?", entity.AnswerStatusAvailable, search.LoginUserID)
 		}
 	}
 
diff --git a/internal/repo/repo_test/personal_answer_visibility_test.go b/internal/repo/repo_test/personal_answer_visibility_test.go
index f50105aa..254ddfd2 100644
--- a/internal/repo/repo_test/personal_answer_visibility_test.go
+++ b/internal/repo/repo_test/personal_answer_visibility_test.go
@@ -87,3 +87,61 @@ func TestPersonalAnswerPageRespectsHiddenQuestionVisibility(t *testing.T) {
 	require.Equal(t, int64(2), ownerTotal)
 	require.Len(t, ownerAnswers, 2)
 }
+
+func TestAnswerSearchRespectsPublicParentQuestionVisibility(t *testing.T) {
+	ctx := context.Background()
+	questionRepo := question.NewQuestionRepo(testDataSource, unique.NewUniqueIDRepo(testDataSource))
+	answerRepo := answer.NewAnswerRepo(testDataSource, nil, nil, nil)
+
+	newQuestion := func(title string, status, show int) *entity.Question {
+		q := &entity.Question{
+			UserID:       "top-answer-question-owner",
+			Title:        title,
+			OriginalText: title,
+			ParsedText:   title,
+			Status:       status,
+			Show:         show,
+		}
+		require.NoError(t, questionRepo.AddQuestion(ctx, q))
+		return q
+	}
+	visibleQuestion := newQuestion("visible question", entity.QuestionStatusAvailable, entity.QuestionShow)
+	closedQuestion := newQuestion("closed question", entity.QuestionStatusClosed, entity.QuestionShow)
+	hiddenQuestion := newQuestion("hidden question", entity.QuestionStatusAvailable, entity.QuestionHide)
+	pendingQuestion := newQuestion("pending question", entity.QuestionStatusPending, entity.QuestionShow)
+	deletedQuestion := newQuestion("deleted question", entity.QuestionStatusDeleted, entity.QuestionShow)
+	questions := []*entity.Question{visibleQuestion, closedQuestion, hiddenQuestion, pendingQuestion, deletedQuestion}
+	answers := make([]*entity.Answer, 0, len(questions))
+	for _, q := range questions {
+		item := &entity.Answer{QuestionID: q.ID, UserID: "top-answer-author", OriginalText: "answer", ParsedText: "answer", Status: entity.AnswerStatusAvailable}
+		_, err := testDataSource.DB.Context(ctx).Insert(item)
+		require.NoError(t, err)
+		answers = append(answers, item)
+	}
+	t.Cleanup(func() {
+		for _, item := range answers {
+			_, _ = testDataSource.DB.Context(ctx).ID(item.ID).Delete(&entity.Answer{})
+		}
+		for _, q := range questions {
+			_, _ = testDataSource.DB.Context(ctx).ID(q.ID).Delete(&entity.Question{})
+		}
+	})
+
+	publicAnswers, total, err := answerRepo.SearchList(ctx, &entity.AnswerSearch{
+		Answer:             entity.Answer{UserID: "top-answer-author"},
+		OnlyPublicQuestion: true,
+		PageSize:           10,
+	})
+	require.NoError(t, err)
+	require.Equal(t, int64(2), total)
+	require.Len(t, publicAnswers, 2)
+	questionIDs := map[string]bool{}
+	for _, item := range publicAnswers {
+		questionIDs[item.QuestionID] = true
+	}
+	require.True(t, questionIDs[visibleQuestion.ID])
+	require.True(t, questionIDs[closedQuestion.ID])
+	require.False(t, questionIDs[hiddenQuestion.ID])
+	require.False(t, questionIDs[pendingQuestion.ID])
+	require.False(t, questionIDs[deletedQuestion.ID])
+}
diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go
index ed33f9ea..3350ef9b 100644
--- a/internal/service/content/question_service.go
+++ b/internal/service/content/question_service.go
@@ -1319,6 +1319,10 @@ func (qs *QuestionService) PersonalCollectionPage(ctx context.Context, req *sche
 }
 
 func canViewPersonalCollectionQuestion(question *schema.QuestionInfoResp, userID string, isAdminModerator bool) bool {
+	return canViewQuestion(question, userID, isAdminModerator)
+}
+
+func canViewQuestion(question *schema.QuestionInfoResp, userID string, isAdminModerator bool) bool {
 	return (&schema.SimpleObjectInfo{
 		ObjectType:            constant.QuestionObjectType,
 		QuestionCreatorUserID: question.UserID,
@@ -1354,6 +1358,7 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin
 	answersearch.UserID = userinfo.ID
 	answersearch.PageSize = 5
 	answersearch.Order = entity.AnswerSearchOrderByVote
+	answersearch.OnlyPublicQuestion = true
 	questionIDs := make([]string, 0)
 	answerList, _, err := qs.questioncommon.AnswerCommon.Search(ctx, answersearch)
 	if err != nil {
@@ -1370,7 +1375,7 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin
 	}
 	for _, item := range answerlist {
 		_, ok := questionMaps[item.QuestionID]
-		if ok {
+		if ok && canViewQuestion(questionMaps[item.QuestionID], "", false) {
 			item.QuestionInfo = questionMaps[item.QuestionID]
 		}
 	}
@@ -1383,6 +1388,9 @@ func (qs *QuestionService) SearchUserTopList(ctx context.Context, userName strin
 	}
 
 	for _, item := range answerlist {
+		if item.QuestionInfo == nil {
+			continue
+		}
 		info := &schema.UserAnswerInfo{}
 		_ = copier.Copy(info, item)
 		info.AnswerID = item.ID