diff --git a/internal/question/internal/integration/admin_set_handler_test.go b/internal/question/internal/integration/admin_set_handler_test.go index 21d32fa8..3e21e714 100644 --- a/internal/question/internal/integration/admin_set_handler_test.go +++ b/internal/question/internal/integration/admin_set_handler_test.go @@ -197,6 +197,80 @@ func (s *AdminSetHandlerTestSuite) TestQuestionSet_Save() { } } +func (s *AdminSetHandlerTestSuite) TestQuestionSet_Candidates() { + testCases := []struct { + name string + + before func(t *testing.T) + req web.CandidateReq + + wantCode int + wantResp test.Result[web.QuestionList] + }{ + { + name: "获取成功", + before: func(t *testing.T) { + // 准备数据 + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + // 创建一个空题集 + id, err := s.questionSetDAO.Create(ctx, dao.QuestionSet{ + Id: 1, + Uid: uid, + Title: "Go", + Description: "Go题集", + Biz: "roadmap", + BizId: 2, + Utime: 123, + }) + require.NoError(t, err) + // 添加问题 + questions := []dao.Question{ + s.buildQuestion(1), + s.buildQuestion(2), + s.buildQuestion(3), + s.buildQuestion(4), + s.buildQuestion(5), + s.buildQuestion(6), + } + err = s.db.WithContext(ctx).Create(&questions).Error + require.NoError(t, err) + qids := []int64{1, 2, 3} + require.NoError(t, s.questionSetDAO.UpdateQuestionsByID(ctx, id, qids)) + }, + req: web.CandidateReq{ + QSID: 1, + Offset: 1, + Limit: 2, + }, + wantCode: 200, + wantResp: test.Result[web.QuestionList]{ + Data: web.QuestionList{ + Total: 3, + Questions: []web.Question{ + s.buildWebQuestion(5), + s.buildWebQuestion(4), + }, + }, + }, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + tc.before(t) + req, err := http.NewRequest(http.MethodPost, + "/question-sets/candidate", iox.NewJSONReader(tc.req)) + req.Header.Set("content-type", "application/json") + require.NoError(t, err) + recorder := test.NewJSONResponseRecorder[web.QuestionList]() + s.server.ServeHTTP(recorder, req) + require.Equal(t, tc.wantCode, recorder.Code) + assert.Equal(t, tc.wantResp, recorder.MustScan()) + }) + } +} + func (s *AdminSetHandlerTestSuite) TestQuestionSet_UpdateQuestions() { testCases := []struct { name string diff --git a/internal/question/internal/integration/base_handler_test.go b/internal/question/internal/integration/base_handler_test.go index 8eae88b9..ac127620 100644 --- a/internal/question/internal/integration/base_handler_test.go +++ b/internal/question/internal/integration/base_handler_test.go @@ -20,6 +20,8 @@ import ( "fmt" "testing" + "github.com/ecodeclub/webook/internal/question/internal/domain" + "github.com/ecodeclub/webook/internal/question/internal/web" "github.com/ecodeclub/webook/internal/interactive" @@ -92,6 +94,30 @@ func (s *BaseTestSuite) mockInteractive(biz string, id int64) interactive.Intera } } +func (s *BaseTestSuite) buildQuestion(id int64) dao.Question { + return dao.Question{ + Id: id, + Uid: uid, + Biz: domain.DefaultBiz, + BizId: id, + Title: fmt.Sprintf("标题%d", id), + Content: fmt.Sprintf("内容%d", id), + Ctime: 123 + id, + Utime: 123 + id, + } +} + +func (s *BaseTestSuite) buildWebQuestion(id int64) web.Question { + return web.Question{ + Id: id, + Biz: domain.DefaultBiz, + BizId: id, + Title: fmt.Sprintf("标题%d", id), + Content: fmt.Sprintf("内容%d", id), + Utime: 123 + id, + } +} + func (s *BaseTestSuite) buildDAOAnswerEle( qid int64, idx int, diff --git a/internal/question/internal/integration/startup/wire_gen.go b/internal/question/internal/integration/startup/wire_gen.go index 3dee45ea..5342b17a 100644 --- a/internal/question/internal/integration/startup/wire_gen.go +++ b/internal/question/internal/integration/startup/wire_gen.go @@ -40,14 +40,14 @@ func InitModule(p event.SyncDataToSearchEventProducer, intrModule *interactive.M serviceService := service.NewService(repositoryRepository, p, interactiveEventProducer) questionSetDAO := baguwen.InitQuestionSetDAO(db) questionSetRepository := repository.NewQuestionSetRepository(questionSetDAO) - questionSetService := service.NewQuestionSetService(questionSetRepository, interactiveEventProducer, p) + questionSetService := service.NewQuestionSetService(questionSetRepository, repositoryRepository, interactiveEventProducer, p) adminHandler := web.NewAdminHandler(serviceService) adminQuestionSetHandler := web.NewAdminQuestionSetHandler(questionSetService) service2 := intrModule.Svc examineDAO := dao.NewGORMExamineDAO(db) examineRepository := repository.NewCachedExamineRepository(examineDAO) - gptService := aiModule.Svc - examineService := service.NewLLMExamineService(repositoryRepository, examineRepository, gptService) + llmService := aiModule.Svc + examineService := service.NewLLMExamineService(repositoryRepository, examineRepository, llmService) service3 := permModule.Svc handler := web.NewHandler(service2, examineService, service3, serviceService) questionSetHandler := web.NewQuestionSetHandler(questionSetService, examineService, service2) diff --git a/internal/question/internal/repository/dao/question.go b/internal/question/internal/repository/dao/question.go index 119a3107..e871c757 100644 --- a/internal/question/internal/repository/dao/question.go +++ b/internal/question/internal/repository/dao/question.go @@ -40,12 +40,31 @@ type QuestionDAO interface { PubCount(ctx context.Context) (int64, error) GetPubByID(ctx context.Context, qid int64) (PublishQuestion, []PublishAnswerElement, error) GetPubByIDs(ctx context.Context, qids []int64) ([]PublishQuestion, error) + NotInTotal(ctx context.Context, ids []int64) (int64, error) + NotIn(ctx context.Context, ids []int64, offset int, limit int) ([]Question, error) } type GORMQuestionDAO struct { db *egorm.Component } +func (g *GORMQuestionDAO) NotInTotal(ctx context.Context, ids []int64) (int64, error) { + var res int64 + err := g.db.WithContext(ctx). + Model(&Question{}). + Where("id NOT IN ?", ids).Count(&res).Error + return res, err +} + +func (g *GORMQuestionDAO) NotIn(ctx context.Context, ids []int64, offset int, limit int) ([]Question, error) { + var res []Question + err := g.db.WithContext(ctx). + Model(&Question{}). + Where("id NOT IN ?", ids).Order("utime DESC"). + Offset(offset).Limit(limit).Find(&res).Error + return res, err +} + func (g *GORMQuestionDAO) GetPubByIDs(ctx context.Context, qids []int64) ([]PublishQuestion, error) { var qs []PublishQuestion db := g.db.WithContext(ctx) diff --git a/internal/question/internal/repository/question.go b/internal/question/internal/repository/question.go index a41973e5..bf7adb04 100644 --- a/internal/question/internal/repository/question.go +++ b/internal/question/internal/repository/question.go @@ -18,6 +18,8 @@ import ( "context" "time" + "golang.org/x/sync/errgroup" + "github.com/ecodeclub/ekit/sqlx" "github.com/ecodeclub/ekit/slice" @@ -42,6 +44,8 @@ type Repository interface { GetById(ctx context.Context, qid int64) (domain.Question, error) GetPubByID(ctx context.Context, qid int64) (domain.Question, error) GetPubByIDs(ctx context.Context, ids []int64) ([]domain.Question, error) + // ExcludeQuestions 分页接口,不含这些 id 的问题 + ExcludeQuestions(ctx context.Context, ids []int64, offset int, limit int) ([]domain.Question, int64, error) } // CachedRepository 支持缓存的 repository 实现 @@ -71,6 +75,29 @@ func (c *CachedRepository) GetPubByID(ctx context.Context, qid int64) (domain.Qu return c.toDomainWithAnswer(dao.Question(data), eles), nil } +func (c *CachedRepository) ExcludeQuestions(ctx context.Context, ids []int64, offset int, limit int) ([]domain.Question, int64, error) { + var ( + eg errgroup.Group + cnt int64 + data []dao.Question + ) + eg.Go(func() error { + var err error + cnt, err = c.dao.NotInTotal(ctx, ids) + return err + }) + + eg.Go(func() error { + var err error + data, err = c.dao.NotIn(ctx, ids, offset, limit) + return err + }) + err := eg.Wait() + return slice.Map(data, func(idx int, src dao.Question) domain.Question { + return c.toDomain(src) + }), cnt, err +} + func (c *CachedRepository) GetById(ctx context.Context, qid int64) (domain.Question, error) { data, eles, err := c.dao.GetByID(ctx, qid) if err != nil { diff --git a/internal/question/internal/service/question_set.go b/internal/question/internal/service/question_set.go index a09cf033..e2a5728f 100644 --- a/internal/question/internal/service/question_set.go +++ b/internal/question/internal/service/question_set.go @@ -18,6 +18,8 @@ import ( "context" "time" + "github.com/ecodeclub/ekit/slice" + "github.com/ecodeclub/webook/internal/question/internal/event" "github.com/gotomicro/ego/core/elog" @@ -37,16 +39,29 @@ type QuestionSetService interface { Detail(ctx context.Context, id int64) (domain.QuestionSet, error) GetByIds(ctx context.Context, ids []int64) ([]domain.QuestionSet, error) DetailByBiz(ctx context.Context, biz string, bizId int64) (domain.QuestionSet, error) + GetCandidates(ctx context.Context, id int64, offset int, limit int) ([]domain.Question, int64, error) } type questionSetService struct { repo repository.QuestionSetRepository + queRepo repository.Repository producer event.SyncDataToSearchEventProducer intrProducer event.InteractiveEventProducer logger *elog.Component syncTimeout time.Duration } +func (q *questionSetService) GetCandidates(ctx context.Context, id int64, offset int, limit int) ([]domain.Question, int64, error) { + qs, err := q.repo.GetByID(ctx, id) + if err != nil { + return nil, 0, err + } + qids := slice.Map(qs.Questions, func(idx int, src domain.Question) int64 { + return src.Id + }) + return q.queRepo.ExcludeQuestions(ctx, qids, offset, limit) +} + func (q *questionSetService) DetailByBiz(ctx context.Context, biz string, bizId int64) (domain.QuestionSet, error) { return q.repo.GetByBiz(ctx, biz, bizId) } @@ -140,10 +155,12 @@ func (q *questionSetService) syncQuestionSet(id int64) { } func NewQuestionSetService(repo repository.QuestionSetRepository, + queRepo repository.Repository, intrProducer event.InteractiveEventProducer, producer event.SyncDataToSearchEventProducer) QuestionSetService { return &questionSetService{ repo: repo, + queRepo: queRepo, producer: producer, intrProducer: intrProducer, logger: elog.DefaultLogger, diff --git a/internal/question/internal/web/admin_base.go b/internal/question/internal/web/admin_base.go new file mode 100644 index 00000000..5c41f5f4 --- /dev/null +++ b/internal/question/internal/web/admin_base.go @@ -0,0 +1,41 @@ +// Copyright 2023 ecodeclub +// +// Licensed 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 web + +import ( + "github.com/ecodeclub/ekit/slice" + "github.com/ecodeclub/webook/internal/question/internal/domain" +) + +type AdminBaseHandler struct { +} + +func (h AdminBaseHandler) toQuestionList(data []domain.Question, cnt int64) QuestionList { + return QuestionList{ + Total: cnt, + Questions: slice.Map(data, func(idx int, src domain.Question) Question { + return Question{ + Id: src.Id, + Title: src.Title, + Content: src.Content, + Labels: src.Labels, + Biz: src.Biz, + BizId: src.BizId, + Status: src.Status.ToUint8(), + Utime: src.Utime.UnixMilli(), + } + }), + } +} diff --git a/internal/question/internal/web/admin.go b/internal/question/internal/web/admin_question.go similarity index 83% rename from internal/question/internal/web/admin.go rename to internal/question/internal/web/admin_question.go index e963256a..d1a70fae 100644 --- a/internal/question/internal/web/admin.go +++ b/internal/question/internal/web/admin_question.go @@ -15,17 +15,16 @@ package web import ( - "github.com/ecodeclub/ekit/slice" "github.com/ecodeclub/ginx" "github.com/ecodeclub/ginx/session" "github.com/ecodeclub/webook/internal/interactive" - "github.com/ecodeclub/webook/internal/question/internal/domain" "github.com/ecodeclub/webook/internal/question/internal/service" "github.com/gin-gonic/gin" ) // AdminHandler 制作库 type AdminHandler struct { + AdminBaseHandler svc service.Service } @@ -78,7 +77,6 @@ func (h *AdminHandler) Publish(ctx *ginx.Context, req SaveReq, sess session.Sess } func (h *AdminHandler) List(ctx *ginx.Context, req Page) (ginx.Result, error) { - // 制作库不需要统计总数 data, cnt, err := h.svc.List(ctx, req.Offset, req.Limit) if err != nil { return systemErrorResult, err @@ -88,22 +86,6 @@ func (h *AdminHandler) List(ctx *ginx.Context, req Page) (ginx.Result, error) { }, nil } -func (h *AdminHandler) toQuestionList(data []domain.Question, cnt int64) QuestionList { - return QuestionList{ - Total: cnt, - Questions: slice.Map(data, func(idx int, src domain.Question) Question { - return Question{ - Id: src.Id, - Title: src.Title, - Content: src.Content, - Labels: src.Labels, - Status: src.Status.ToUint8(), - Utime: src.Utime.UnixMilli(), - } - }), - } -} - func (h *AdminHandler) Detail(ctx *ginx.Context, req Qid) (ginx.Result, error) { detail, err := h.svc.Detail(ctx, req.Qid) if err != nil { diff --git a/internal/question/internal/web/admin_question_set_handler.go b/internal/question/internal/web/admin_question_set_handler.go index 94850e76..93480201 100644 --- a/internal/question/internal/web/admin_question_set_handler.go +++ b/internal/question/internal/web/admin_question_set_handler.go @@ -27,6 +27,7 @@ import ( ) type AdminQuestionSetHandler struct { + AdminBaseHandler svc service.QuestionSetService } @@ -40,6 +41,17 @@ func (h *AdminQuestionSetHandler) PrivateRoutes(server *gin.Engine) { g.POST("/questions/save", ginx.BS[UpdateQuestions](h.UpdateQuestions)) g.POST("/list", ginx.B[Page](h.ListQuestionSets)) g.POST("/detail", ginx.B(h.RetrieveQuestionSetDetail)) + g.POST("/candidate", ginx.B[CandidateReq](h.Candidate)) +} + +func (h *AdminQuestionSetHandler) Candidate(ctx *ginx.Context, req CandidateReq) (ginx.Result, error) { + data, cnt, err := h.svc.GetCandidates(ctx, req.QSID, req.Offset, req.Limit) + if err != nil { + return systemErrorResult, err + } + return ginx.Result{ + Data: h.toQuestionList(data, cnt), + }, nil } // UpdateQuestions 整体更新题集中的所有问题 覆盖式的 前端传递过来的问题集合就是题集中最终的问题集合 diff --git a/internal/question/internal/web/vo.go b/internal/question/internal/web/vo.go index dc73cb07..e86bf255 100644 --- a/internal/question/internal/web/vo.go +++ b/internal/question/internal/web/vo.go @@ -126,6 +126,12 @@ type Page struct { Limit int `json:"limit,omitempty"` } +type CandidateReq struct { + QSID int64 `json:"qsid"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` +} + type Qid struct { Qid int64 `json:"qid"` } diff --git a/internal/question/mocks/quetion_set.mock.go b/internal/question/mocks/quetion_set.mock.go index 8329c82c..f8d2d2c5 100644 --- a/internal/question/mocks/quetion_set.mock.go +++ b/internal/question/mocks/quetion_set.mock.go @@ -156,6 +156,46 @@ func (c *QuestionSetServiceGetByIdsCall) DoAndReturn(f func(context.Context, []i return c } +// GetCandidates mocks base method. +func (m *MockQuestionSetService) GetCandidates(ctx context.Context, id int64, offset, limit int) ([]domain.Question, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCandidates", ctx, id, offset, limit) + ret0, _ := ret[0].([]domain.Question) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetCandidates indicates an expected call of GetCandidates. +func (mr *MockQuestionSetServiceMockRecorder) GetCandidates(ctx, id, offset, limit any) *QuestionSetServiceGetCandidatesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCandidates", reflect.TypeOf((*MockQuestionSetService)(nil).GetCandidates), ctx, id, offset, limit) + return &QuestionSetServiceGetCandidatesCall{Call: call} +} + +// QuestionSetServiceGetCandidatesCall wrap *gomock.Call +type QuestionSetServiceGetCandidatesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *QuestionSetServiceGetCandidatesCall) Return(arg0 []domain.Question, arg1 int64, arg2 error) *QuestionSetServiceGetCandidatesCall { + c.Call = c.Call.Return(arg0, arg1, arg2) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *QuestionSetServiceGetCandidatesCall) Do(f func(context.Context, int64, int, int) ([]domain.Question, int64, error)) *QuestionSetServiceGetCandidatesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *QuestionSetServiceGetCandidatesCall) DoAndReturn(f func(context.Context, int64, int, int) ([]domain.Question, int64, error)) *QuestionSetServiceGetCandidatesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // List mocks base method. func (m *MockQuestionSetService) List(ctx context.Context, offset, limit int) ([]domain.QuestionSet, int64, error) { m.ctrl.T.Helper() diff --git a/internal/question/wire_gen.go b/internal/question/wire_gen.go index 1e24d8ad..337f3a59 100644 --- a/internal/question/wire_gen.go +++ b/internal/question/wire_gen.go @@ -44,7 +44,7 @@ func InitModule(db *gorm.DB, intrModule *interactive.Module, ec ecache.Cache, pe serviceService := service.NewService(repositoryRepository, syncDataToSearchEventProducer, interactiveEventProducer) questionSetDAO := InitQuestionSetDAO(db) questionSetRepository := repository.NewQuestionSetRepository(questionSetDAO) - questionSetService := service.NewQuestionSetService(questionSetRepository, interactiveEventProducer, syncDataToSearchEventProducer) + questionSetService := service.NewQuestionSetService(questionSetRepository, repositoryRepository, interactiveEventProducer, syncDataToSearchEventProducer) adminHandler := web.NewAdminHandler(serviceService) adminQuestionSetHandler := web.NewAdminQuestionSetHandler(questionSetService) service2 := intrModule.Svc diff --git a/internal/test/ioc/db.go b/internal/test/ioc/db.go index fac0e8f0..f611a945 100644 --- a/internal/test/ioc/db.go +++ b/internal/test/ioc/db.go @@ -12,7 +12,10 @@ func InitDB() *egorm.Component { if db != nil { return db } - econf.Set("mysql", map[string]string{"dsn": "webook:webook@tcp(localhost:13316)/webook?charset=utf8mb4&collation=utf8mb4_general_ci&parseTime=True&loc=Local&timeout=1s&readTimeout=3s&writeTimeout=3s"}) + econf.Set("mysql", map[string]any{ + "dsn": "webook:webook@tcp(localhost:13316)/webook?charset=utf8mb4&collation=utf8mb4_general_ci&parseTime=True&loc=Local&timeout=1s&readTimeout=3s&writeTimeout=3s", + "debug": true, + }) ioc.WaitForDBSetup(econf.GetStringMapString("mysql")["dsn"]) db = egorm.Load("mysql").Build() return db