From c9f41162860c4397d07bfc7ce940cd60b6a143f3 Mon Sep 17 00:00:00 2001 From: wenliang zhu <73632785+juniaoshaonian@users.noreply.github.com> Date: Tue, 18 Feb 2025 16:53:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=B7=BB=E5=8A=A0=20(#31?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * case添加缓存 * 给question添加缓存 * 给面经添加缓存 * 为列表添加测试 * 修改 * make check * 修改 --------- Co-authored-by: zhuwenliang --- .../integration/admin_case_handler_test.go | 138 +++- .../internal/integration/handler_test.go | 358 ++++++++++- .../internal/integration/startup/wire.go | 3 + .../internal/integration/startup/wire_gen.go | 9 +- .../cases/internal/repository/cache/cases.go | 102 ++- internal/cases/internal/repository/cases.go | 132 +++- .../cases/internal/repository/dao/cases.go | 18 +- internal/cases/wire.go | 5 + internal/cases/wire_gen.go | 7 +- .../integration/admin_handler_test.go | 223 ++++++- .../internal/integration/base_handler_test.go | 11 + .../internal/integration/handler_test.go | 598 ++++++++++++++++-- .../internal/repository/cache/ecache.go | 88 ++- .../internal/repository/cache/types.go | 15 +- .../question/internal/repository/question.go | 155 ++++- internal/question/internal/web/handler.go | 1 - .../integration/admin_handler_test.go | 68 +- .../internal/integration/handler_test.go | 111 +++- .../internal/integration/startup/wire.go | 5 +- .../internal/integration/startup/wire_gen.go | 7 +- .../internal/repository/cache/review.go | 65 ++ .../review/internal/repository/dao/review.go | 17 +- internal/review/internal/repository/review.go | 35 +- internal/review/wire.go | 10 +- internal/review/wire_gen.go | 7 +- ioc/wire_gen.go | 4 +- 26 files changed, 2060 insertions(+), 132 deletions(-) create mode 100644 internal/review/internal/repository/cache/review.go diff --git a/internal/cases/internal/integration/admin_case_handler_test.go b/internal/cases/internal/integration/admin_case_handler_test.go index 8980fa6d..25747f9c 100644 --- a/internal/cases/internal/integration/admin_case_handler_test.go +++ b/internal/cases/internal/integration/admin_case_handler_test.go @@ -474,6 +474,37 @@ func (s *AdminCaseHandlerTestSuite) TestPublish() { publishCase, err := s.dao.GetPublishCase(ctx, 1) require.NoError(t, err) s.assertCase(t, wantCase, dao.Case(publishCase)) + s.cacheAssertCase(domain.Case{ + Id: 1, + Uid: uid, + Title: "案例1", + Content: "案例1内容", + Introduction: "案例1介绍", + Labels: []string{ + "MySQL", + }, + Status: domain.PublishedStatus, + GithubRepo: "www.github.com", + GiteeRepo: "www.gitee.com", + Keywords: "mysql_keywords", + Shorthand: "mysql_shorthand", + Highlight: "mysql_highlight", + Guidance: "mysql_guidance", + Biz: "case", + BizId: 11, + }) + s.cacheAssertCaseList("case", []domain.Case{ + { + Id: 1, + Title: "案例1", + Introduction: "案例1介绍", + Labels: []string{ + "MySQL", + }, + Status: domain.PublishedStatus, + }, + }) + }, req: web.SaveReq{ Case: web.Case{ @@ -524,12 +555,26 @@ func (s *AdminCaseHandlerTestSuite) TestPublish() { Shorthand: "old_mysql_shorthand", Highlight: "old_mysql_highlight", Guidance: "old_mysql_guidance", - Biz: "case", + Biz: "question", BizId: 11, Ctime: 123, Utime: 234, }).Error require.NoError(t, err) + cs := []domain.Case{ + { + Id: 2, + Title: "老的案例标题", + Content: "老的案例内容", + Introduction: "老的案例介绍", + Labels: []string{"old-MySQL"}, + Status: domain.PublishedStatus, + }, + } + csByte, err := json.Marshal(cs) + require.NoError(t, err) + err = s.rdb.Set(ctx, "cases:list:question", string(csByte), 24*time.Hour) + require.NoError(t, err) }, after: func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) @@ -560,6 +605,36 @@ func (s *AdminCaseHandlerTestSuite) TestPublish() { require.NoError(t, err) publishCase.Ctime = 123 s.assertCase(t, wantCase, dao.Case(publishCase)) + s.cacheAssertCase(domain.Case{ + Id: 2, + Uid: uid, + Title: "案例2", + Content: "案例2内容", + Introduction: "案例2介绍", + Labels: []string{ + "MySQL", + }, + Status: domain.PublishedStatus, + GithubRepo: "www.github.com", + GiteeRepo: "www.gitee.com", + Keywords: "mysql_keywords", + Shorthand: "mysql_shorthand", + Highlight: "mysql_highlight", + Guidance: "mysql_guidance", + Biz: "question", + BizId: 12, + }) + s.cacheAssertCaseList("question", []domain.Case{ + { + Id: 2, + Title: "案例2", + Introduction: "案例2介绍", + Labels: []string{ + "MySQL", + }, + Status: domain.PublishedStatus, + }, + }) }, req: web.SaveReq{ Case: web.Case{ @@ -621,6 +696,7 @@ func (s *AdminCaseHandlerTestSuite) TestPublish() { pubCase := dao.PublishCase(oldCase) err = s.db.WithContext(ctx).Create(pubCase).Error require.NoError(t, err) + }, after: func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) @@ -651,6 +727,23 @@ func (s *AdminCaseHandlerTestSuite) TestPublish() { require.NoError(t, err) publishCase.Ctime = 123 s.assertCase(t, wantCase, dao.Case(publishCase)) + s.cacheAssertCase(domain.Case{ + Id: 3, + Uid: uid, + Title: "案例2", + Content: "案例2内容", + Introduction: "案例2介绍", + Labels: []string{"MySQL"}, + Status: domain.PublishedStatus, + GithubRepo: "www.github.com", + GiteeRepo: "www.gitee.com", + Keywords: "mysql_keywords", + Shorthand: "mysql_shorthand", + Highlight: "mysql_highlight", + Guidance: "mysql_guidance", + Biz: "ai", + BizId: 13, + }) }, req: web.SaveReq{ Case: web.Case{ @@ -696,6 +789,24 @@ func (s *AdminCaseHandlerTestSuite) TestPublish() { } } +func (s *AdminCaseHandlerTestSuite) cacheAssertCase(ca domain.Case) { + t := s.T() + key := fmt.Sprintf("cases:publish:%d", ca.Id) + val := s.rdb.Get(context.Background(), key) + require.NoError(t, val.Err) + valStr, err := val.String() + require.NoError(t, err) + actualCa := domain.Case{} + json.Unmarshal([]byte(valStr), &actualCa) + require.True(t, actualCa.Ctime.Unix() > 0) + require.True(t, actualCa.Utime.Unix() > 0) + ca.Ctime = actualCa.Ctime + ca.Utime = actualCa.Utime + assert.Equal(t, ca, actualCa) + _, err = s.rdb.Delete(context.Background(), key) + require.NoError(t, err) +} + func (s *AdminCaseHandlerTestSuite) TestEvent() { t := s.T() var evt event.Case @@ -733,7 +844,7 @@ func (s *AdminCaseHandlerTestSuite) TestEvent() { Highlight: "mysql_highlight", Guidance: "mysql_guidance", Status: 2, - Biz: domain.DefaultBiz, + Biz: "bbb", Ctime: time.UnixMilli(123), Utime: time.UnixMilli(123), }, ca) @@ -751,6 +862,7 @@ func (s *AdminCaseHandlerTestSuite) TestEvent() { Shorthand: "mysql_shorthand", Highlight: "mysql_highlight", Guidance: "mysql_guidance", + Biz: "bbb", }, } req2, err := http.NewRequest(http.MethodPost, @@ -797,3 +909,25 @@ func (s *AdminCaseHandlerTestSuite) assertCase(t *testing.T, expect dao.Case, ca func TestAdminCaseHandler(t *testing.T) { suite.Run(t, new(AdminCaseHandlerTestSuite)) } + +func (s *AdminCaseHandlerTestSuite) cacheAssertCaseList(biz string, cases []domain.Case) { + key := fmt.Sprintf("cases:list:%s", biz) + val := s.rdb.Get(context.Background(), key) + require.NoError(s.T(), val.Err) + + var cs []domain.Case + err := json.Unmarshal([]byte(val.Val.(string)), &cs) + require.NoError(s.T(), err) + require.Equal(s.T(), len(cases), len(cs)) + for idx, q := range cs { + require.True(s.T(), q.Utime.UnixMilli() > 0) + require.True(s.T(), q.Id > 0) + cs[idx].Id = cases[idx].Id + cs[idx].Utime = cases[idx].Utime + cs[idx].Ctime = cases[idx].Ctime + + } + assert.Equal(s.T(), cases, cs) + _, err = s.rdb.Delete(context.Background(), key) + require.NoError(s.T(), err) +} diff --git a/internal/cases/internal/integration/handler_test.go b/internal/cases/internal/integration/handler_test.go index 7e164355..52343cfe 100644 --- a/internal/cases/internal/integration/handler_test.go +++ b/internal/cases/internal/integration/handler_test.go @@ -18,6 +18,7 @@ package integration import ( "context" + "encoding/json" "fmt" "net/http" "strconv" @@ -158,30 +159,48 @@ func (s *HandlerTestSuite) SetupSuite() { } func (s *HandlerTestSuite) TestPubList() { - data := make([]dao.PublishCase, 0, 100) - for idx := 0; idx < 100; idx++ { - data = append(data, dao.PublishCase{ - Id: int64(idx + 1), - Uid: uid, - Title: fmt.Sprintf("这是发布的案例标题 %d", idx), - Introduction: fmt.Sprintf("这是发布的案例介绍 %d", idx), - Utime: 123, - }) - } - err := s.db.Create(&data).Error - require.NoError(s.T(), err) testCases := []struct { name string req web.Page + before func(t *testing.T) + after func(t *testing.T) wantCode int wantResp test.Result[web.CasesList] }{ { - name: "获取成功", + name: "首次获取前50条,设置缓存", req: web.Page{ Limit: 2, Offset: 0, }, + before: func(t *testing.T) { + data := make([]dao.PublishCase, 0, 100) + for idx := 0; idx < 100; idx++ { + data = append(data, dao.PublishCase{ + Id: int64(idx + 1), + Uid: uid, + Title: fmt.Sprintf("这是发布的案例标题 %d", idx), + Introduction: fmt.Sprintf("这是发布的案例介绍 %d", idx), + Utime: 1739779178000, + }) + } + err := s.db.Create(&data).Error + require.NoError(s.T(), err) + }, + after: func(t *testing.T) { + // 校验缓存数据 + wantDomainCases := make([]domain.Case, 0, 50) + index := 99 + for idx := 0; idx < 50; idx++ { + wantDomainCases = append(wantDomainCases, domain.Case{ + Id: int64(index - idx + 1), + Title: fmt.Sprintf("这是发布的案例标题 %d", index-idx), + Introduction: fmt.Sprintf("这是发布的案例介绍 %d", index-idx), + Utime: time.UnixMilli(1739779178000), + }) + } + s.cacheAssertCaseList(domain.DefaultBiz, wantDomainCases) + }, wantCode: 200, wantResp: test.Result[web.CasesList]{ Data: web.CasesList{ @@ -190,8 +209,8 @@ func (s *HandlerTestSuite) TestPubList() { { Id: 100, Title: "这是发布的案例标题 99", - Introduction: fmt.Sprintf("这是发布的案例介绍 %d", 99), - Utime: 123, + Introduction: "这是发布的案例介绍 99", + Utime: 1739779178000, Interactive: web.Interactive{ Liked: false, Collected: true, @@ -203,8 +222,70 @@ func (s *HandlerTestSuite) TestPubList() { { Id: 99, Title: "这是发布的案例标题 98", - Introduction: fmt.Sprintf("这是发布的案例介绍 %d", 98), - Utime: 123, + Introduction: "这是发布的案例介绍 98", + Utime: 1739779178000, + Interactive: web.Interactive{ + Liked: true, + Collected: false, + ViewCnt: 100, + LikeCnt: 101, + CollectCnt: 102, + }, + }, + }, + }, + }, + }, + {name: "命中缓存直接返回", + req: web.Page{ + Limit: 2, + Offset: 0, + }, + before: func(t *testing.T) { + // 直接设置缓存 + wantDomainCases := make([]domain.Case, 0, 50) + index := 99 + for idx := 0; idx < 50; idx++ { + wantDomainCases = append(wantDomainCases, domain.Case{ + Id: int64(index - idx + 1), + Title: fmt.Sprintf("这是发布的案例标题 %d", index-idx), + Introduction: fmt.Sprintf("这是发布的案例介绍 %d", index-idx), + Utime: time.UnixMilli(1739779178000), + }) + } + caseByte, err := json.Marshal(wantDomainCases) + require.NoError(t, err) + err = s.rdb.Set(context.Background(), "cases:list:baguwen", string(caseByte), 24*time.Hour) + require.NoError(t, err) + err = s.rdb.Set(context.Background(), "cases:total:baguwen", 100, 24*time.Hour) + require.NoError(t, err) + + }, + after: func(t *testing.T) { + }, + wantCode: 200, + wantResp: test.Result[web.CasesList]{ + Data: web.CasesList{ + Total: 100, + Cases: []web.Case{ + { + Id: 100, + Title: "这是发布的案例标题 99", + Introduction: "这是发布的案例介绍 99", + Utime: 1739779178000, + Interactive: web.Interactive{ + Liked: false, + Collected: true, + ViewCnt: 101, + LikeCnt: 102, + CollectCnt: 103, + }, + }, + { + Id: 99, + Title: "这是发布的案例标题 98", + Introduction: "这是发布的案例介绍 98", + Utime: 1739779178000, Interactive: web.Interactive{ Liked: true, Collected: false, @@ -218,11 +299,26 @@ func (s *HandlerTestSuite) TestPubList() { }, }, { - name: "获取部分", + name: "超出缓存范围走数据库", req: web.Page{ Limit: 2, Offset: 99, }, + before: func(t *testing.T) { + data := make([]dao.PublishCase, 0, 100) + for idx := 0; idx < 100; idx++ { + data = append(data, dao.PublishCase{ + Id: int64(idx + 1), + Title: fmt.Sprintf("这是发布的案例标题 %d", idx), + Introduction: fmt.Sprintf("这是发布的案例介绍 %d", idx), + Utime: 1739779178000, + }) + } + err := s.db.Create(&data).Error + require.NoError(s.T(), err) + }, + after: func(t *testing.T) { + }, wantCode: 200, wantResp: test.Result[web.CasesList]{ Data: web.CasesList{ @@ -232,13 +328,13 @@ func (s *HandlerTestSuite) TestPubList() { Id: 1, Title: "这是发布的案例标题 0", Introduction: "这是发布的案例介绍 0", - Utime: 123, + Utime: 1739779178000, Interactive: web.Interactive{ - Liked: true, - Collected: false, ViewCnt: 2, LikeCnt: 3, CollectCnt: 4, + Liked: true, + Collected: false, }, }, }, @@ -250,6 +346,7 @@ func (s *HandlerTestSuite) TestPubList() { for _, tc := range testCases { tc := tc s.T().Run(tc.name, func(t *testing.T) { + tc.before(t) req, err := http.NewRequest(http.MethodPost, "/case/list", iox.NewJSONReader(tc.req)) req.Header.Set("content-type", "application/json") @@ -258,6 +355,11 @@ func (s *HandlerTestSuite) TestPubList() { s.server.ServeHTTP(recorder, req) require.Equal(t, tc.wantCode, recorder.Code) assert.Equal(t, tc.wantResp, recorder.MustScan()) + tc.after(t) + err = s.db.Exec("TRUNCATE TABLE `cases`").Error + require.NoError(s.T(), err) + err = s.db.Exec("TRUNCATE TABLE `publish_cases`").Error + require.NoError(s.T(), err) }) } } @@ -297,8 +399,10 @@ func (s *HandlerTestSuite) TestPubDetail() { require.NoError(s.T(), err) testCases := []struct { - name string - before func(req *http.Request) + name string + before func(req *http.Request) + // 校验数据 + after func() req web.CaseId wantCode int wantResp test.Result[web.Case] @@ -310,6 +414,9 @@ func (s *HandlerTestSuite) TestPubDetail() { }, req: web.CaseId{ Cid: 3, + }, + after: func() { + }, wantCode: 200, wantResp: test.Result[web.Case]{ @@ -347,6 +454,9 @@ func (s *HandlerTestSuite) TestPubDetail() { }, req: web.CaseId{ Cid: 3, + }, + after: func() { + }, wantCode: 200, wantResp: test.Result[web.Case]{ @@ -383,6 +493,9 @@ func (s *HandlerTestSuite) TestPubDetail() { }, req: web.CaseId{ Cid: 3, + }, + after: func() { + }, wantCode: 200, wantResp: test.Result[web.Case]{ @@ -421,6 +534,9 @@ func (s *HandlerTestSuite) TestPubDetail() { }, req: web.CaseId{ Cid: 3, + }, + after: func() { + }, wantCode: 200, wantResp: test.Result[web.Case]{ @@ -452,6 +568,163 @@ func (s *HandlerTestSuite) TestPubDetail() { }, }, }, + { + name: "未命中缓存", + before: func(req *http.Request) { + err = s.db.Create(&dao.PublishCase{ + Id: 4, + Uid: uid, + Introduction: "redis案例介绍", + Labels: sqlx.JsonColumn[[]string]{ + Valid: true, + Val: []string{"Redis"}, + }, + Status: domain.PublishedStatus.ToUint8(), + Title: "redis案例标题", + Content: `123321`, + GithubRepo: "redis github仓库", + GiteeRepo: "redis gitee仓库", + Keywords: "redis_keywords", + Shorthand: "redis_shorthand", + Highlight: "redis_highlight", + Guidance: "redis_guidance", + Biz: "ai", + BizId: 13, + Utime: 1739519892000, + Ctime: 1739519892000, + }).Error + require.NoError(s.T(), err) + }, + req: web.CaseId{ + Cid: 4, + }, + wantCode: 200, + after: func() { + s.cacheAssertCase(domain.Case{ + Id: 4, + Uid: uid, + Introduction: "redis案例介绍", + Labels: []string{"Redis"}, + Status: domain.PublishedStatus, + Title: "redis案例标题", + Content: `123321`, + GithubRepo: "redis github仓库", + GiteeRepo: "redis gitee仓库", + Keywords: "redis_keywords", + Shorthand: "redis_shorthand", + Highlight: "redis_highlight", + Guidance: "redis_guidance", + Biz: "ai", + BizId: 13, + }) + }, + wantResp: test.Result[web.Case]{ + Data: web.Case{ + Id: 4, + Introduction: "redis案例介绍", + Labels: []string{"Redis"}, + Status: domain.PublishedStatus.ToUint8(), + Title: "redis案例标题", + Content: `123321`, + GithubRepo: "redis github仓库", + GiteeRepo: "redis gitee仓库", + Keywords: "redis_keywords", + Shorthand: "redis_shorthand", + Highlight: "redis_highlight", + Guidance: "redis_guidance", + Biz: "ai", + BizId: 13, + Utime: 1739519892000, + Interactive: web.Interactive{ + Liked: false, + Collected: true, + ViewCnt: 5, + LikeCnt: 6, + CollectCnt: 7, + }, + ExamineResult: 0, + Permitted: true, + }, + }, + }, + { + name: "命中缓存", + before: func(req *http.Request) { + ca := domain.Case{ + Id: 5, + Uid: uid, + Introduction: "redis案例介绍", + Labels: []string{"Redis"}, + Status: domain.PublishedStatus, + Title: "redis案例标题", + Content: `123321`, + GithubRepo: "redis github仓库", + GiteeRepo: "redis gitee仓库", + Keywords: "redis_keywords", + Shorthand: "redis_shorthand", + Highlight: "redis_highlight", + Guidance: "redis_guidance", + Biz: "ai", + BizId: 13, + Utime: time.UnixMilli(1739519892000), + Ctime: time.UnixMilli(1739519892000), + } + caByte, _ := json.Marshal(ca) + err = s.rdb.Set(context.Background(), "cases:publish:5", string(caByte), 24*time.Hour) + require.NoError(s.T(), err) + }, + req: web.CaseId{ + Cid: 5, + }, + wantCode: 200, + after: func() { + s.cacheAssertCase(domain.Case{ + Id: 5, + Uid: uid, + Introduction: "redis案例介绍", + Labels: []string{"Redis"}, + Status: domain.PublishedStatus, + Title: "redis案例标题", + Content: `123321`, + GithubRepo: "redis github仓库", + GiteeRepo: "redis gitee仓库", + Keywords: "redis_keywords", + Shorthand: "redis_shorthand", + Highlight: "redis_highlight", + Guidance: "redis_guidance", + Biz: "ai", + BizId: 13, + }) + }, + wantResp: test.Result[web.Case]{ + Data: web.Case{ + Id: 5, + Introduction: "redis案例介绍", + Labels: []string{"Redis"}, + Status: domain.PublishedStatus.ToUint8(), + Title: "redis案例标题", + Content: `123321`, + GithubRepo: "redis github仓库", + GiteeRepo: "redis gitee仓库", + Keywords: "redis_keywords", + Shorthand: "redis_shorthand", + Highlight: "redis_highlight", + Guidance: "redis_guidance", + Biz: "ai", + BizId: 13, + Utime: 1739519892000, + Interactive: web.Interactive{ + Liked: true, + Collected: false, + ViewCnt: 6, + LikeCnt: 7, + CollectCnt: 8, + }, + ExamineResult: 0, + Permitted: true, + }, + }, + }, } for _, tc := range testCases { tc := tc @@ -465,10 +738,27 @@ func (s *HandlerTestSuite) TestPubDetail() { s.server.ServeHTTP(recorder, req) require.Equal(t, tc.wantCode, recorder.Code) assert.Equal(t, tc.wantResp, recorder.MustScan()) + tc.after() }) } } +func (s *HandlerTestSuite) cacheAssertCase(ca domain.Case) { + t := s.T() + key := fmt.Sprintf("cases:publish:%d", ca.Id) + val := s.rdb.Get(context.Background(), key) + require.NoError(t, val.Err) + valStr, err := val.String() + require.NoError(t, err) + actualCa := domain.Case{} + json.Unmarshal([]byte(valStr), &actualCa) + require.True(t, actualCa.Ctime.Unix() > 0) + require.True(t, actualCa.Utime.Unix() > 0) + ca.Ctime = actualCa.Ctime + ca.Utime = actualCa.Utime + assert.Equal(t, ca, actualCa) +} + func (s *HandlerTestSuite) mockInteractive(biz string, id int64) interactive.Interactive { liked := id%2 == 1 collected := id%2 == 0 @@ -486,3 +776,25 @@ func (s *HandlerTestSuite) mockInteractive(biz string, id int64) interactive.Int func TestHandler(t *testing.T) { suite.Run(t, new(HandlerTestSuite)) } + +func (s *HandlerTestSuite) cacheAssertCaseList(biz string, cases []domain.Case) { + key := fmt.Sprintf("cases:list:%s", biz) + val := s.rdb.Get(context.Background(), key) + require.NoError(s.T(), val.Err) + + var cs []domain.Case + err := json.Unmarshal([]byte(val.Val.(string)), &cs) + require.NoError(s.T(), err) + require.Equal(s.T(), len(cases), len(cs)) + for idx, q := range cs { + require.True(s.T(), q.Utime.UnixMilli() > 0) + require.True(s.T(), q.Id > 0) + cs[idx].Id = cases[idx].Id + cs[idx].Utime = cases[idx].Utime + cs[idx].Ctime = cases[idx].Ctime + + } + assert.Equal(s.T(), cases, cs) + _, err = s.rdb.Delete(context.Background(), key) + require.NoError(s.T(), err) +} diff --git a/internal/cases/internal/integration/startup/wire.go b/internal/cases/internal/integration/startup/wire.go index c5887647..95c5c855 100644 --- a/internal/cases/internal/integration/startup/wire.go +++ b/internal/cases/internal/integration/startup/wire.go @@ -22,6 +22,7 @@ import ( "github.com/ecodeclub/webook/internal/cases" "github.com/ecodeclub/webook/internal/cases/internal/event" "github.com/ecodeclub/webook/internal/cases/internal/repository" + "github.com/ecodeclub/webook/internal/cases/internal/repository/cache" "github.com/ecodeclub/webook/internal/cases/internal/repository/dao" "github.com/ecodeclub/webook/internal/cases/internal/service" "github.com/ecodeclub/webook/internal/cases/internal/web" @@ -42,6 +43,7 @@ func InitModule( testioc.BaseSet, dao.NewCaseSetDAO, dao.NewGORMExamineDAO, + cache.NewCaseCache, repository.NewCaseRepo, repository.NewCaseSetRepo, repository.NewCachedExamineRepository, @@ -74,6 +76,7 @@ func InitExamModule( cases.InitCaseDAO, dao.NewCaseSetDAO, dao.NewGORMExamineDAO, + cache.NewCaseCache, repository.NewCaseRepo, repository.NewCaseSetRepo, repository.NewCachedExamineRepository, diff --git a/internal/cases/internal/integration/startup/wire_gen.go b/internal/cases/internal/integration/startup/wire_gen.go index 43acde9a..7376d290 100644 --- a/internal/cases/internal/integration/startup/wire_gen.go +++ b/internal/cases/internal/integration/startup/wire_gen.go @@ -12,6 +12,7 @@ import ( "github.com/ecodeclub/webook/internal/cases" "github.com/ecodeclub/webook/internal/cases/internal/event" "github.com/ecodeclub/webook/internal/cases/internal/repository" + "github.com/ecodeclub/webook/internal/cases/internal/repository/cache" "github.com/ecodeclub/webook/internal/cases/internal/repository/dao" "github.com/ecodeclub/webook/internal/cases/internal/service" "github.com/ecodeclub/webook/internal/cases/internal/web" @@ -25,7 +26,9 @@ import ( func InitModule(syncProducer event.SyncEventProducer, knowledgeBaseProducer event.KnowledgeBaseEventProducer, aiModule *ai.Module, memberModule *member.Module, sp session.Provider, intrModule *interactive.Module) (*cases.Module, error) { db := testioc.InitDB() caseDAO := cases.InitCaseDAO(db) - caseRepo := repository.NewCaseRepo(caseDAO) + ecacheCache := testioc.InitCache() + caseCache := cache.NewCaseCache(ecacheCache) + caseRepo := repository.NewCaseRepo(caseDAO, caseCache) mq := testioc.InitMQ() interactiveEventProducer, err := event.NewInteractiveEventProducer(mq) if err != nil { @@ -61,7 +64,9 @@ func InitModule(syncProducer event.SyncEventProducer, knowledgeBaseProducer even func InitExamModule(syncProducer event.SyncEventProducer, knowledgeBaseProducer event.KnowledgeBaseEventProducer, intrModule *interactive.Module, memberModule *member.Module, sp session.Provider, aiModule *ai.Module) (*cases.Module, error) { db := testioc.InitDB() caseDAO := cases.InitCaseDAO(db) - caseRepo := repository.NewCaseRepo(caseDAO) + ecacheCache := testioc.InitCache() + caseCache := cache.NewCaseCache(ecacheCache) + caseRepo := repository.NewCaseRepo(caseDAO, caseCache) mq := testioc.InitMQ() interactiveEventProducer, err := event.NewInteractiveEventProducer(mq) if err != nil { diff --git a/internal/cases/internal/repository/cache/cases.go b/internal/cases/internal/repository/cache/cases.go index 506e473e..4c96678d 100644 --- a/internal/cases/internal/repository/cache/cases.go +++ b/internal/cases/internal/repository/cache/cases.go @@ -1,21 +1,121 @@ package cache import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + "github.com/ecodeclub/ecache" + "github.com/ecodeclub/webook/internal/cases/internal/domain" + "github.com/pkg/errors" ) type CaseCache interface { + SetCase(ctx context.Context, ca domain.Case) error + GetCase(ctx context.Context, id int64) (domain.Case, error) + SetCases(ctx context.Context, biz string, cas []domain.Case) error + GetCases(ctx context.Context, biz string) ([]domain.Case, error) + GetTotal(ctx context.Context, biz string) (int64, error) + SetTotal(ctx context.Context, biz string, total int64) error } +const ( + expiration = 24 * time.Hour +) + +var ( + ErrCaseNotFound = errors.New("案例没找到") +) + type caseCache struct { ec ecache.Cache } +func (c *caseCache) SetCases(ctx context.Context, biz string, cas []domain.Case) error { + bytes, err := json.Marshal(cas) + if err != nil { + return errors.Wrap(err, "序列化案例列表失败") + } + return c.ec.Set(ctx, c.casesKey(biz), string(bytes), expiration) +} + +func (c *caseCache) GetCases(ctx context.Context, biz string) ([]domain.Case, error) { + val := c.ec.Get(ctx, c.casesKey(biz)) + if val.KeyNotFound() { + return nil, ErrCaseNotFound + } + if val.Err != nil { + return nil, val.Err + } + + var res []domain.Case + err := json.Unmarshal([]byte(val.Val.(string)), &res) + return res, errors.Wrap(err, "反序列化案例列表失败") +} + +func (c *caseCache) GetTotal(ctx context.Context, biz string) (int64, error) { + val := c.ec.Get(ctx, c.totalKey(biz)) + if val.KeyNotFound() { + return 0, ErrCaseNotFound + } + if val.Err != nil { + return 0, val.Err + } + ans, err := val.String() + if err != nil { + return 0, err + } + return strconv.ParseInt(ans, 10, 64) +} + +func (c *caseCache) SetTotal(ctx context.Context, biz string, total int64) error { + return c.ec.Set(ctx, c.totalKey(biz), total, expiration) +} + func NewCaseCache(ec ecache.Cache) CaseCache { return &caseCache{ ec: &ecache.NamespaceCache{ C: ec, - Namespace: "cases", + Namespace: "cases:", }, } } +func (c *caseCache) SetCase(ctx context.Context, ca domain.Case) error { + cabyte, err := json.Marshal(ca) + if err != nil { + return err + } + return c.ec.Set(ctx, c.caseKey(ca.Id), string(cabyte), expiration) +} + +func (c *caseCache) GetCase(ctx context.Context, id int64) (domain.Case, error) { + caVal := c.ec.Get(ctx, c.caseKey(id)) + if caVal.KeyNotFound() { + return domain.Case{}, ErrCaseNotFound + } + if caVal.Err != nil { + return domain.Case{}, caVal.Err + } + + var ca domain.Case + err := json.Unmarshal([]byte(caVal.Val.(string)), &ca) + if err != nil { + return domain.Case{}, err + } + return ca, nil +} + +func (c *caseCache) caseKey(id int64) string { + return fmt.Sprintf("publish:%d", id) +} + +// 新增以下 key 生成方法(放在 caseKey 方法附近) +func (c *caseCache) casesKey(biz string) string { + return fmt.Sprintf("list:%s", biz) +} + +func (c *caseCache) totalKey(biz string) string { + return fmt.Sprintf("total:%s", biz) +} diff --git a/internal/cases/internal/repository/cases.go b/internal/cases/internal/repository/cases.go index dcf2697b..5aa1f349 100644 --- a/internal/cases/internal/repository/cases.go +++ b/internal/cases/internal/repository/cases.go @@ -4,6 +4,10 @@ import ( "context" "time" + "github.com/ecodeclub/webook/internal/cases/internal/repository/cache" + "github.com/gotomicro/ego/core/elog" + "github.com/pkg/errors" + "golang.org/x/sync/errgroup" "github.com/ecodeclub/ekit/slice" @@ -33,11 +37,26 @@ type CaseRepo interface { } type caseRepo struct { - caseDao dao.CaseDAO + caseDao dao.CaseDAO + caseCache cache.CaseCache + logger *elog.Component } func (c *caseRepo) PubCount(ctx context.Context) (int64, error) { - return c.caseDao.PublishCaseCount(ctx) + total, cacheErr := c.caseCache.GetTotal(ctx, domain.DefaultBiz) + if cacheErr == nil { + return total, nil + } + total, err := c.caseDao.PublishCaseCount(ctx, domain.DefaultBiz) + if err != nil { + return 0, err + } + cacheErr = c.caseCache.SetTotal(ctx, domain.DefaultBiz, total) + if cacheErr != nil { + // 记录一下日志 + c.logger.Error("记录缓存失败", elog.FieldErr(cacheErr)) + } + return total, nil } func (c *caseRepo) Ids(ctx context.Context) ([]int64, error) { @@ -68,23 +87,52 @@ func (c *caseRepo) Exclude(ctx context.Context, ids []int64, offset int, limit i } func (c *caseRepo) PubList(ctx context.Context, offset int, limit int) ([]domain.Case, error) { + // 检查是否在缓存范围内 + if c.checkTop50(offset, limit) { + // 尝试从缓存获取 + cases, err := c.caseCache.GetCases(ctx, domain.DefaultBiz) + if err == nil { + return c.getCasesFromCache(cases, offset, limit), nil + } + domainCases, err := c.cacheList(ctx, domain.DefaultBiz) + if err != nil { + return domainCases, err + } + return c.getCasesFromCache(domainCases, offset, limit), nil + } + + // 超出缓存范围,直接查询数据库 caseList, err := c.caseDao.PublishCaseList(ctx, offset, limit) if err != nil { return nil, err } - domainCases := make([]domain.Case, 0, len(caseList)) - for _, ca := range caseList { - domainCases = append(domainCases, c.toDomain(dao.Case(ca))) - } - return domainCases, nil + return slice.Map(caseList, func(idx int, src dao.PublishCase) domain.Case { + return c.toDomain(dao.Case(src)) + }), nil } func (c *caseRepo) GetPubByID(ctx context.Context, caseId int64) (domain.Case, error) { - caseInfo, err := c.caseDao.GetPublishCase(ctx, caseId) + ca, eerr := c.caseCache.GetCase(ctx, caseId) + if eerr == nil { + // 命中缓存 + return ca, nil + } + if !errors.Is(eerr, cache.ErrCaseNotFound) { + // 记录一下日志 + c.logger.Error("案例获取缓存失败", elog.FieldErr(eerr), elog.Int64("cid", caseId)) + } + + daoCa, err := c.caseDao.GetPublishCase(ctx, caseId) if err != nil { return domain.Case{}, err } - return c.toDomain(dao.Case(caseInfo)), nil + ca = c.toDomain(dao.Case(daoCa)) + eerr = c.caseCache.SetCase(ctx, ca) + if eerr != nil { + // 记录一下日志 + c.logger.Error("案例设置缓存失败", elog.FieldErr(eerr), elog.Int64("cid", caseId)) + } + return ca, nil } func (c *caseRepo) GetPubByIDs(ctx context.Context, ids []int64) ([]domain.Case, error) { @@ -96,7 +144,25 @@ func (c *caseRepo) GetPubByIDs(ctx context.Context, ids []int64) ([]domain.Case, func (c *caseRepo) Sync(ctx context.Context, ca domain.Case) (int64, error) { caseModel := c.toEntity(ca) - return c.caseDao.Sync(ctx, caseModel) + daoCa, err := c.caseDao.Sync(ctx, caseModel) + if err != nil { + return 0, err + } + + // 获取最新数据并更新缓存 + domainCase := c.toDomain(daoCa) + eerr := c.caseCache.SetCase(ctx, domainCase) + if eerr != nil { + c.logger.Error("案例设置缓存失败", elog.FieldErr(eerr), elog.Int64("cid", daoCa.Id)) + } + + // 更新前50条列表缓存 + _, cacheErr := c.cacheList(ctx, domainCase.Biz) + if cacheErr != nil { + c.logger.Error("更新案例列表缓存失败", elog.FieldErr(cacheErr), elog.String("biz", domainCase.Biz)) + } + + return daoCa.Id, nil } func (c *caseRepo) List(ctx context.Context, offset int, limit int) ([]domain.Case, error) { @@ -173,9 +239,53 @@ func (c *caseRepo) toDomain(caseDao dao.Case) domain.Case { } } -func NewCaseRepo(caseDao dao.CaseDAO) CaseRepo { +func NewCaseRepo(caseDao dao.CaseDAO, caseCache cache.CaseCache) CaseRepo { return &caseRepo{ caseDao: caseDao, // 后续接入缓存 + caseCache: caseCache, + logger: elog.DefaultLogger, + } +} + +// 新增缓存范围检查方法 +const ( + cacheMax = 50 + cacheMin = 0 +) + +func (c *caseRepo) checkTop50(offset, limit int) bool { + last := offset + limit + return last <= cacheMax +} + +// 新增从缓存数据分页方法 +func (c *caseRepo) getCasesFromCache(cases []domain.Case, offset, limit int) []domain.Case { + if offset >= len(cases) { + return []domain.Case{} + } + remain := len(cases) - offset + if remain > limit { + remain = limit } + res := make([]domain.Case, 0, remain) + for i := offset; i < offset+remain; i++ { + res = append(res, cases[i]) + } + return res +} + +func (c *caseRepo) cacheList(ctx context.Context, biz string) ([]domain.Case, error) { + caseList, err := c.caseDao.PublishCaseList(ctx, cacheMin, cacheMax) + if err != nil { + return nil, err + } + domainCases := slice.Map(caseList, func(idx int, src dao.PublishCase) domain.Case { + return c.toDomain(dao.Case(src)) + }) + cacheErr := c.caseCache.SetCases(ctx, biz, domainCases) + if cacheErr != nil { + c.logger.Error("案例列表设置缓存失败", elog.FieldErr(cacheErr), elog.String("biz", biz)) + } + return domainCases, nil } diff --git a/internal/cases/internal/repository/dao/cases.go b/internal/cases/internal/repository/dao/cases.go index a05a19d3..6b762184 100644 --- a/internal/cases/internal/repository/dao/cases.go +++ b/internal/cases/internal/repository/dao/cases.go @@ -19,12 +19,12 @@ type CaseDAO interface { List(ctx context.Context, offset, limit int) ([]Case, error) Count(ctx context.Context) (int64, error) - Sync(ctx context.Context, c Case) (int64, error) + Sync(ctx context.Context, c Case) (Case, error) // 提供给同步到知识库用 Ids(ctx context.Context) ([]int64, error) // 线上库 PublishCaseList(ctx context.Context, offset, limit int) ([]PublishCase, error) - PublishCaseCount(ctx context.Context) (int64, error) + PublishCaseCount(ctx context.Context, biz string) (int64, error) GetPublishCase(ctx context.Context, caseId int64) (PublishCase, error) GetPubByIDs(ctx context.Context, ids []int64) ([]PublishCase, error) @@ -102,20 +102,20 @@ func (ca *caseDAO) List(ctx context.Context, offset, limit int) ([]Case, error) return caseList, err } -func (ca *caseDAO) Sync(ctx context.Context, c Case) (int64, error) { - var id = c.Id +func (ca *caseDAO) Sync(ctx context.Context, c Case) (Case, error) { err := ca.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var err error - id, err = ca.save(tx, &c) + id, err := ca.save(tx, &c) if err != nil { return err } + c.Id = id pubC := PublishCase(c) return tx.Clauses(clause.OnConflict{ DoUpdates: clause.AssignmentColumns(ca.updateColumns), }).Create(&pubC).Error }) - return id, err + return c, err } func (ca *caseDAO) PublishCaseList(ctx context.Context, offset, limit int) ([]PublishCase, error) { @@ -129,9 +129,11 @@ func (ca *caseDAO) PublishCaseList(ctx context.Context, offset, limit int) ([]Pu return publishCaseList, err } -func (ca *caseDAO) PublishCaseCount(ctx context.Context) (int64, error) { +func (ca *caseDAO) PublishCaseCount(ctx context.Context, biz string) (int64, error) { var res int64 - err := ca.db.WithContext(ctx).Model(&PublishCase{}).Select("COUNT(id)").Count(&res).Error + err := ca.db.WithContext(ctx).Model(&PublishCase{}).Select("COUNT(id)"). + Where("biz = ?", biz). + Count(&res).Error return res, err } diff --git a/internal/cases/wire.go b/internal/cases/wire.go index 75bf66e8..c824823f 100644 --- a/internal/cases/wire.go +++ b/internal/cases/wire.go @@ -5,6 +5,9 @@ package cases import ( "sync" + "github.com/ecodeclub/ecache" + "github.com/ecodeclub/webook/internal/cases/internal/repository/cache" + "github.com/ecodeclub/ginx/session" "github.com/ecodeclub/webook/internal/member" @@ -30,10 +33,12 @@ func InitModule(db *egorm.Component, aiModule *ai.Module, memberModule *member.Module, sp session.Provider, + redisCache ecache.Cache, q mq.MQ) (*Module, error) { wire.Build(InitCaseDAO, dao.NewCaseSetDAO, dao.NewGORMExamineDAO, + cache.NewCaseCache, repository.NewCaseRepo, repository.NewCaseSetRepo, repository.NewCachedExamineRepository, diff --git a/internal/cases/wire_gen.go b/internal/cases/wire_gen.go index 8c423a5b..9f830492 100644 --- a/internal/cases/wire_gen.go +++ b/internal/cases/wire_gen.go @@ -9,11 +9,13 @@ package cases import ( "sync" + "github.com/ecodeclub/ecache" "github.com/ecodeclub/ginx/session" "github.com/ecodeclub/mq-api" "github.com/ecodeclub/webook/internal/ai" "github.com/ecodeclub/webook/internal/cases/internal/event" "github.com/ecodeclub/webook/internal/cases/internal/repository" + "github.com/ecodeclub/webook/internal/cases/internal/repository/cache" "github.com/ecodeclub/webook/internal/cases/internal/repository/dao" "github.com/ecodeclub/webook/internal/cases/internal/service" "github.com/ecodeclub/webook/internal/cases/internal/web" @@ -25,9 +27,10 @@ import ( // Injectors from wire.go: -func InitModule(db *gorm.DB, intrModule *interactive.Module, aiModule *ai.Module, memberModule *member.Module, sp session.Provider, q mq.MQ) (*Module, error) { +func InitModule(db *gorm.DB, intrModule *interactive.Module, aiModule *ai.Module, memberModule *member.Module, sp session.Provider, redisCache ecache.Cache, q mq.MQ) (*Module, error) { caseDAO := InitCaseDAO(db) - caseRepo := repository.NewCaseRepo(caseDAO) + caseCache := cache.NewCaseCache(redisCache) + caseRepo := repository.NewCaseRepo(caseDAO, caseCache) interactiveEventProducer, err := event.NewInteractiveEventProducer(q) if err != nil { return nil, err diff --git a/internal/question/internal/integration/admin_handler_test.go b/internal/question/internal/integration/admin_handler_test.go index 08434b1c..cb74d475 100644 --- a/internal/question/internal/integration/admin_handler_test.go +++ b/internal/question/internal/integration/admin_handler_test.go @@ -353,6 +353,35 @@ func (s *AdminHandlerTestSuite) TestSync() { Content: "面试题内容", }, dao.Question(q)) assert.Equal(t, 4, len(eles)) + s.cacheAssertQuestion(domain.Question{ + Id: 1, + Uid: uid, + Title: "面试题1", + Biz: "project", + BizId: 1, + Status: domain.PublishedStatus, + Labels: []string{"MySQL"}, + Content: "面试题内容", + Answer: domain.Answer{ + Analysis: s.buildDomainAnswerEle(0, 1), + Basic: s.buildDomainAnswerEle(1, 2), + Intermediate: s.buildDomainAnswerEle(2, 3), + Advanced: s.buildDomainAnswerEle(3, 4), + }, + }) + s.cacheAssertQuestionList("project", []domain.Question{ + { + Id: 1, + Uid: uid, + Title: "面试题1", + Biz: "project", + BizId: 1, + Status: domain.PublishedStatus, + Labels: []string{"MySQL"}, + Content: "面试题内容", + }, + }) + }, req: web.SaveReq{ Question: web.Question{ @@ -463,6 +492,30 @@ func (s *AdminHandlerTestSuite) TestSync() { Highlight: "新的亮点", Guidance: "新的引导点", }, dao.AnswerElement(pAnalysis)) + + s.cacheAssertQuestion(domain.Question{ + Id: 2, + Uid: uid, + Status: domain.PublishedStatus, + Biz: "roadmap", + BizId: 2, + Labels: []string{"sqlite"}, + Title: "面试题1", + Content: "新的内容", + Answer: domain.Answer{ + Analysis: domain.AnswerElement{ + Id: 1, + Content: "新的分析", + Keywords: "新的 keyword", + Shorthand: "新的速记", + Highlight: "新的亮点", + Guidance: "新的引导点", + }, + Basic: s.buildDomainAnswerEle(1, 2), + Intermediate: s.buildDomainAnswerEle(2, 3), + Advanced: s.buildDomainAnswerEle(3, 4), + }, + }) }, req: func() web.SaveReq { analysis := web.AnswerElement{ @@ -493,6 +546,69 @@ func (s *AdminHandlerTestSuite) TestSync() { Data: 2, }, }, + { + name: "更新缓存", + before: func(t *testing.T) { + s.producer.EXPECT().Produce(gomock.Any(), gomock.Any()).Return(nil) + s.knowledgeBaseProducer.EXPECT().Produce(gomock.Any(), gomock.Any()).Return(nil) + ques := []domain.Question{ + { + Id: 8, + Uid: uid, + Title: "面试题1", + Biz: "case", + BizId: 1, + Status: domain.PublishedStatus, + Labels: []string{"MySQL"}, + Content: "面试题内容", + }, + { + Id: 9, + Uid: uid, + Title: "面试题1", + Biz: "case", + BizId: 1, + Status: domain.PublishedStatus, + Labels: []string{"MySQL"}, + Content: "面试题内容", + }, + } + quesByte, _ := json.Marshal(ques) + err := s.rdb.Set(context.Background(), "question:list:case", string(quesByte), 24*time.Hour) + require.NoError(t, err) + }, + after: func(t *testing.T) { + s.cacheAssertQuestionList("case", []domain.Question{ + { + Id: 1, + Uid: uid, + Title: "面试题1", + Biz: "case", + BizId: 1, + Status: domain.PublishedStatus, + Labels: []string{"MySQL"}, + Content: "面试题内容", + }, + }) + }, + req: web.SaveReq{ + Question: web.Question{ + Title: "面试题1", + Content: "面试题内容", + Biz: "case", + BizId: 1, + Labels: []string{"MySQL"}, + Analysis: s.buildAnswerEle(0), + Basic: s.buildAnswerEle(1), + Intermediate: s.buildAnswerEle(2), + Advanced: s.buildAnswerEle(3), + }, + }, + wantCode: 200, + wantResp: test.Result[int64]{ + Data: 1, + }, + }, } for _, tc := range testCases { @@ -532,18 +648,53 @@ func (s *AdminHandlerTestSuite) TestDelete() { name: "删除成功", qid: 123, before: func(t *testing.T) { + originQs := []domain.Question{ + { + Id: 234, + Biz: "xx", + }, + { + Id: 123, + Biz: "xx", + }, + } + qsByte, err := json.Marshal(originQs) + require.NoError(t, err) + s.rdb.Set(context.Background(), "question:list:xx", string(qsByte), 24*time.Hour) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) defer cancel() var qid int64 = 123 // prepare data - _, err := s.dao.Sync(ctx, dao.Question{ - Id: qid, + s.db.Model(&dao.Question{}).Create(&dao.Question{ + Id: qid, + Biz: "xx", + }) + _, err = s.dao.Sync(ctx, dao.Question{ + Id: qid, + Biz: "xx", }, []dao.AnswerElement{ { Qid: qid, }, }) require.NoError(t, err) + + s.db.Model(&dao.Question{}).Create(&dao.Question{ + Id: 234, + Biz: "xx", + Utime: 1739779178000, + }) + _, err = s.dao.Sync(ctx, dao.Question{ + Id: 234, + Biz: "xx", + Utime: 1739779178000, + }, []dao.AnswerElement{ + { + Qid: 234, + }, + }) + require.NoError(t, err) err = s.db.Create(&dao.QuestionSetQuestion{ QID: qid, }).Error @@ -558,9 +709,15 @@ func (s *AdminHandlerTestSuite) TestDelete() { _, _, err = s.dao.GetByID(ctx, qid) assert.Equal(t, err, gorm.ErrRecordNotFound) var res []dao.QuestionSetQuestion - err = s.db.Where("qid = ?").Find(&res).Error + err = s.db.Model(&dao.QuestionSetQuestion{}).Where("qid = ?", qid).Find(&res).Error assert.NoError(t, err) assert.Equal(t, 0, len(res)) + s.cacheAssertQuestionList("xx", []domain.Question{ + { + Id: 234, + Biz: "xx", + }, + }) }, wantCode: 200, wantResp: test.Result[any]{}, @@ -580,6 +737,7 @@ func (s *AdminHandlerTestSuite) TestDelete() { for _, tc := range testCases { tc := tc s.T().Run(tc.name, func(t *testing.T) { + tc.before(t) req, err := http.NewRequest(http.MethodPost, "/question/delete", iox.NewJSONReader(web.Qid{Qid: tc.qid})) req.Header.Set("content-type", "application/json") @@ -588,6 +746,7 @@ func (s *AdminHandlerTestSuite) TestDelete() { s.server.ServeHTTP(recorder, req) require.Equal(t, tc.wantCode, recorder.Code) assert.Equal(t, tc.wantResp, recorder.MustScan()) + tc.after(t) }) } @@ -772,3 +931,61 @@ func (s *AdminHandlerTestSuite) getAnswerElement(idx int64) domain.AnswerElement Guidance: fmt.Sprintf("引导点 %d", idx), } } + +// 校验缓存中的数据 +func (s *AdminHandlerTestSuite) cacheAssertQuestion(q domain.Question) { + t := s.T() + key := fmt.Sprintf("question:publish:%d", q.Id) + val := s.rdb.Get(context.Background(), key) + require.NoError(t, val.Err) + + var actual domain.Question + err := json.Unmarshal([]byte(val.Val.(string)), &actual) + require.NoError(t, err) + + // 处理时间字段 + require.True(t, actual.Utime.Unix() > 0) + q.Utime = actual.Utime + + // 清理缓存 + require.True(t, actual.Answer.Basic.Id > 0) + require.True(t, actual.Answer.Advanced.Id > 0) + require.True(t, actual.Answer.Intermediate.Id > 0) + require.True(t, actual.Answer.Analysis.Id > 0) + actual.Answer.Basic.Id = 0 + actual.Answer.Advanced.Id = 0 + actual.Answer.Intermediate.Id = 0 + actual.Answer.Analysis.Id = 0 + q.Answer.Basic.Id = 0 + q.Answer.Advanced.Id = 0 + q.Answer.Intermediate.Id = 0 + q.Answer.Analysis.Id = 0 + _, err = s.rdb.Delete(context.Background(), key) + require.NoError(t, err) + assert.Equal(t, q, actual) +} + +func (s *AdminHandlerTestSuite) cacheAssertQuestionList(biz string, questions []domain.Question) { + key := fmt.Sprintf("question:list:%s", biz) + val := s.rdb.Get(context.Background(), key) + require.NoError(s.T(), val.Err) + + var qs []domain.Question + err := json.Unmarshal([]byte(val.Val.(string)), &qs) + require.NoError(s.T(), err) + require.Equal(s.T(), len(questions), len(qs)) + for idx, q := range qs { + require.True(s.T(), q.Utime.UnixMilli() > 0) + require.True(s.T(), q.Id > 0) + //require.True(s.T(), q.Answer.Analysis.Id > 0) + //require.True(s.T(), q.Answer.Basic.Id > 0) + //require.True(s.T(), q.Answer.Advanced.Id > 0) + //require.True(s.T(), q.Answer.Intermediate.Id > 0) + qs[idx].Id = questions[idx].Id + qs[idx].Utime = questions[idx].Utime + + } + assert.Equal(s.T(), questions, qs) + _, err = s.rdb.Delete(context.Background(), key) + require.NoError(s.T(), err) +} diff --git a/internal/question/internal/integration/base_handler_test.go b/internal/question/internal/integration/base_handler_test.go index ac127620..18f42f20 100644 --- a/internal/question/internal/integration/base_handler_test.go +++ b/internal/question/internal/integration/base_handler_test.go @@ -133,6 +133,17 @@ func (s *BaseTestSuite) buildDAOAnswerEle( } } +func (s *BaseTestSuite) buildDomainAnswerEle(idx int, id int64) domain.AnswerElement { + return domain.AnswerElement{ + Id: id, + Content: fmt.Sprintf("这是解析 %d", idx), + Keywords: fmt.Sprintf("关键字 %d", idx), + Shorthand: fmt.Sprintf("快速记忆法 %d", idx), + Highlight: fmt.Sprintf("亮点 %d", idx), + Guidance: fmt.Sprintf("引导点 %d", idx), + } +} + func (s *BaseTestSuite) buildAnswerEle(idx int64) web.AnswerElement { return web.AnswerElement{ Content: fmt.Sprintf("这是解析 %d", idx), diff --git a/internal/question/internal/integration/handler_test.go b/internal/question/internal/integration/handler_test.go index 4bea8b84..4db1c9ac 100644 --- a/internal/question/internal/integration/handler_test.go +++ b/internal/question/internal/integration/handler_test.go @@ -18,12 +18,15 @@ package integration import ( "context" + "encoding/json" "fmt" "net/http" "strconv" "testing" "time" + "github.com/ecodeclub/ekit/sqlx" + "github.com/ecodeclub/webook/internal/member" membermocks "github.com/ecodeclub/webook/internal/member/mocks" @@ -161,49 +164,51 @@ func (s *HandlerTestSuite) SetupSuite() { } func (s *HandlerTestSuite) TestPubList() { - // 插入一百条 - data := make([]dao.PublishQuestion, 0, 100) - for idx := 0; idx < 100; idx++ { - id := int64(idx + 1) - data = append(data, dao.PublishQuestion{ - Id: id, - Uid: uid, - Biz: domain.DefaultBiz, - BizId: id, - Status: domain.UnPublishedStatus.ToUint8(), - Title: fmt.Sprintf("这是标题 %d", idx), - Content: fmt.Sprintf("这是解析 %d", idx), - Utime: 123, - }) - } - - // project 的不会被搜索到 - data = append(data, dao.PublishQuestion{ - Id: 101, - Uid: uid, - Biz: "project", - BizId: 101, - Status: domain.UnPublishedStatus.ToUint8(), - Title: fmt.Sprintf("这是标题 %d", 101), - Content: fmt.Sprintf("这是解析 %d", 101), - Utime: 123, - }) - err := s.db.Create(&data).Error - require.NoError(s.T(), err) testCases := []struct { - name string - req web.Page - + name string + req web.Page + before func(t *testing.T) + after func(t *testing.T) wantCode int wantResp test.Result[web.QuestionList] }{ { - name: "获取成功", + name: "获取的数据位于前50条,未命中缓存,会写入缓存", req: web.Page{ Limit: 2, Offset: 0, }, + before: func(t *testing.T) { + // 插入一百条 + data := make([]dao.PublishQuestion, 0, 100) + for idx := 0; idx < 100; idx++ { + id := int64(idx + 1) + data = append(data, dao.PublishQuestion{ + Id: id, + Uid: uid, + Biz: domain.DefaultBiz, + BizId: id, + Status: domain.PublishedStatus.ToUint8(), + Title: fmt.Sprintf("这是标题 %d", idx), + Content: fmt.Sprintf("这是解析 %d", idx), + Utime: 123, + }) + } + // project 的不会被搜索到 + data = append(data, dao.PublishQuestion{ + Id: 101, + Uid: uid, + Biz: "project", + BizId: 101, + Status: domain.PublishedStatus.ToUint8(), + Title: fmt.Sprintf("这是标题 %d", 101), + Content: fmt.Sprintf("这是解析 %d", 101), + Utime: 123, + }) + err := s.db.Create(&data).Error + require.NoError(s.T(), err) + }, wantCode: 200, wantResp: test.Result[web.QuestionList]{ Data: web.QuestionList{ @@ -213,7 +218,7 @@ func (s *HandlerTestSuite) TestPubList() { Id: 100, Title: "这是标题 99", Content: "这是解析 99", - Status: domain.UnPublishedStatus.ToUint8(), + Status: domain.PublishedStatus.ToUint8(), Utime: 123, Biz: domain.DefaultBiz, BizId: 100, @@ -229,7 +234,7 @@ func (s *HandlerTestSuite) TestPubList() { Id: 99, Title: "这是标题 98", Content: "这是解析 98", - Status: domain.UnPublishedStatus.ToUint8(), + Status: domain.PublishedStatus.ToUint8(), Utime: 123, Biz: domain.DefaultBiz, BizId: 99, @@ -244,13 +249,139 @@ func (s *HandlerTestSuite) TestPubList() { }, }, }, + after: func(t *testing.T) { + // 校验缓存中的数据 + wantDomainQuestions := make([]domain.Question, 0, 50) + index := 99 + for idx := 0; idx < 50; idx++ { + id := int64(index - idx + 1) + wantDomainQuestions = append(wantDomainQuestions, domain.Question{ + Id: id, + Uid: uid, + Biz: domain.DefaultBiz, + BizId: id, + Status: domain.PublishedStatus, + Title: fmt.Sprintf("这是标题 %d", index-idx), + Content: fmt.Sprintf("这是解析 %d", index-idx), + }) + } + s.cacheAssertQuestionList(domain.DefaultBiz, wantDomainQuestions) + _, err := s.rdb.Delete(context.Background(), "question:total") + require.NoError(s.T(), err) + }, + }, + { + name: "获取的数据位于前50条,命中缓存,直接返回", + req: web.Page{ + Limit: 2, + Offset: 0, + }, + before: func(t *testing.T) { + // 只写缓存 + wantDomainQuestions := make([]domain.Question, 0, 50) + index := 99 + for idx := 0; idx < 50; idx++ { + id := int64(index - idx + 1) + wantDomainQuestions = append(wantDomainQuestions, domain.Question{ + Id: id, + Uid: uid, + Biz: domain.DefaultBiz, + BizId: id, + Utime: time.UnixMilli(1739779178000), + Status: domain.PublishedStatus, + Title: fmt.Sprintf("这是标题 %d", index-idx), + Content: fmt.Sprintf("这是解析 %d", index-idx), + }) + } + queByte, err := json.Marshal(wantDomainQuestions) + require.NoError(t, err) + err = s.rdb.Set(context.Background(), "question:list:baguwen", string(queByte), 24*time.Hour) + require.NoError(t, err) + }, + wantCode: 200, + wantResp: test.Result[web.QuestionList]{ + Data: web.QuestionList{ + Total: 100, + Questions: []web.Question{ + { + Id: 100, + Title: "这是标题 99", + Content: "这是解析 99", + Status: domain.PublishedStatus.ToUint8(), + Utime: 1739779178000, + Biz: domain.DefaultBiz, + BizId: 100, + Interactive: web.Interactive{ + ViewCnt: 101, + LikeCnt: 102, + CollectCnt: 103, + Liked: false, + Collected: true, + }, + }, + { + Id: 99, + Title: "这是标题 98", + Content: "这是解析 98", + Status: domain.PublishedStatus.ToUint8(), + Utime: 1739779178000, + Biz: domain.DefaultBiz, + BizId: 99, + Interactive: web.Interactive{ + ViewCnt: 100, + LikeCnt: 101, + CollectCnt: 102, + Liked: true, + Collected: false, + }, + }, + }, + }, + }, + after: func(t *testing.T) { + }, }, { - name: "获取部分", + name: "获取部分,不在前50从数据库中返回", req: web.Page{ Limit: 2, Offset: 99, }, + before: func(t *testing.T) { + // 插入一百条 + data := make([]dao.PublishQuestion, 0, 100) + for idx := 0; idx < 100; idx++ { + id := int64(idx + 1) + data = append(data, dao.PublishQuestion{ + Id: id, + Uid: uid, + Biz: domain.DefaultBiz, + BizId: id, + Status: domain.PublishedStatus.ToUint8(), + Title: fmt.Sprintf("这是标题 %d", idx), + Content: fmt.Sprintf("这是解析 %d", idx), + Utime: 123, + }) + } + // project 的不会被搜索到 + data = append(data, dao.PublishQuestion{ + Id: 101, + Uid: uid, + Biz: "project", + BizId: 101, + Status: domain.PublishedStatus.ToUint8(), + Title: fmt.Sprintf("这是标题 %d", 101), + Content: fmt.Sprintf("这是解析 %d", 101), + Utime: 123, + }) + err := s.db.Create(&data).Error + require.NoError(s.T(), err) + }, + after: func(t *testing.T) { + key := fmt.Sprintf("question:list:%s", domain.DefaultBiz) + val := s.rdb.Get(context.Background(), key) + require.True(t, val.KeyNotFound()) + }, wantCode: 200, wantResp: test.Result[web.QuestionList]{ Data: web.QuestionList{ @@ -262,7 +393,7 @@ func (s *HandlerTestSuite) TestPubList() { Content: "这是解析 0", Biz: domain.DefaultBiz, BizId: 1, - Status: domain.UnPublishedStatus.ToUint8(), + Status: domain.PublishedStatus.ToUint8(), Utime: 123, Interactive: web.Interactive{ ViewCnt: 2, @@ -276,11 +407,107 @@ func (s *HandlerTestSuite) TestPubList() { }, }, }, + { + name: "有部分在前五十,有部分不在。命中数据库直接返回", + req: web.Page{ + Limit: 3, + Offset: 48, + }, + before: func(t *testing.T) { + // 插入一百条 + data := make([]dao.PublishQuestion, 0, 100) + for idx := 0; idx < 100; idx++ { + id := int64(idx + 1) + data = append(data, dao.PublishQuestion{ + Id: id, + Uid: uid, + Biz: domain.DefaultBiz, + BizId: id, + Status: domain.PublishedStatus.ToUint8(), + Title: fmt.Sprintf("这是标题 %d", idx), + Content: fmt.Sprintf("这是解析 %d", idx), + Utime: 123, + }) + } + // project 的不会被搜索到 + data = append(data, dao.PublishQuestion{ + Id: 101, + Uid: uid, + Biz: "project", + BizId: 101, + Status: domain.PublishedStatus.ToUint8(), + Title: fmt.Sprintf("这是标题 %d", 101), + Content: fmt.Sprintf("这是解析 %d", 101), + Utime: 123, + }) + err := s.db.Create(&data).Error + require.NoError(s.T(), err) + }, + after: func(t *testing.T) { + key := fmt.Sprintf("question:list:%s", domain.DefaultBiz) + val := s.rdb.Get(context.Background(), key) + require.True(t, val.KeyNotFound()) + }, + wantCode: 200, + wantResp: test.Result[web.QuestionList]{ + Data: web.QuestionList{ + Total: 100, + Questions: []web.Question{ + { + Id: 52, // 缓存最后第二条(100-48=52) + Title: "这是标题 51", + Content: "这是解析 51", + Utime: 123, + Status: domain.PublishedStatus.ToUint8(), + Biz: domain.DefaultBiz, + BizId: 52, + Interactive: web.Interactive{ + ViewCnt: 53, + LikeCnt: 54, + CollectCnt: 55, + Collected: true, + }, + }, + { + Id: 51, // 缓存最后一条 + Title: "这是标题 50", + Content: "这是解析 50", + Utime: 123, + Status: domain.PublishedStatus.ToUint8(), + Biz: domain.DefaultBiz, + BizId: 51, + Interactive: web.Interactive{ + ViewCnt: 52, + LikeCnt: 53, + CollectCnt: 54, + Liked: true, + }, + }, + { + Id: 50, // 数据库第一条(按倒序查询) + Title: "这是标题 49", + Content: "这是解析 49", + Utime: 123, + Status: domain.PublishedStatus.ToUint8(), + Biz: domain.DefaultBiz, + BizId: 50, + Interactive: web.Interactive{ + ViewCnt: 51, + LikeCnt: 52, + CollectCnt: 53, + Collected: true, + }, + }, + }, + }, + }, + }, } for _, tc := range testCases { tc := tc s.T().Run(tc.name, func(t *testing.T) { + tc.before(t) req, err := http.NewRequest(http.MethodPost, "/question/list", iox.NewJSONReader(tc.req)) req.Header.Set("content-type", "application/json") @@ -288,13 +515,18 @@ func (s *HandlerTestSuite) TestPubList() { recorder := test.NewJSONResponseRecorder[web.QuestionList]() s.server.ServeHTTP(recorder, req) require.Equal(t, tc.wantCode, recorder.Code) - assert.Equal(t, tc.wantResp, recorder.MustScan()) + data := recorder.MustScan() + assert.Equal(t, tc.wantResp, data) + tc.after(t) + err = s.db.Exec("TRUNCATE TABLE `questions`").Error + require.NoError(s.T(), err) + err = s.db.Exec("TRUNCATE TABLE `publish_questions`").Error + require.NoError(s.T(), err) + _, err = s.rdb.Delete(context.Background(), "question:list:baguwen") + require.NoError(s.T(), err) }) } - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - _, err = s.rdb.Delete(ctx, "question:total") - require.NoError(s.T(), err) + } func (s *HandlerTestSuite) TestPubDetail() { @@ -303,6 +535,7 @@ func (s *HandlerTestSuite) TestPubDetail() { name string req web.Qid before func(req *http.Request) + after func() wantData web.Question }{ { @@ -312,6 +545,9 @@ func (s *HandlerTestSuite) TestPubDetail() { }, before: func(req *http.Request) { req.Header.Set("not_member", "1") + }, + after: func() { + }, wantData: web.Question{ Id: 1041, @@ -357,6 +593,9 @@ func (s *HandlerTestSuite) TestPubDetail() { name: "会员返回全部数据", req: web.Qid{ Qid: 1041, + }, + after: func() { + }, before: func(req *http.Request) {}, wantData: web.Question{ @@ -404,6 +643,9 @@ func (s *HandlerTestSuite) TestPubDetail() { name: "token中会员过期,但是是会员,返回全部数据", req: web.Qid{ Qid: 1041, + }, + after: func() { + }, before: func(req *http.Request) { req.Header.Set("uid", "4") @@ -453,6 +695,9 @@ func (s *HandlerTestSuite) TestPubDetail() { name: "没有登录返回部分数据", req: web.Qid{ Qid: 1041, + }, + after: func() { + }, before: func(req *http.Request) { req.Header.Set("not_login", "1") @@ -504,6 +749,9 @@ func (s *HandlerTestSuite) TestPubDetail() { }, before: func(req *http.Request) { req.Header.Set("uid", "4") + }, + after: func() { + }, wantData: web.Question{ Id: 1042, @@ -549,6 +797,9 @@ func (s *HandlerTestSuite) TestPubDetail() { name: "有权限返回全部数据", req: web.Qid{ Qid: 1042, + }, + after: func() { + }, before: func(req *http.Request) { }, @@ -593,6 +844,229 @@ func (s *HandlerTestSuite) TestPubDetail() { Permitted: true, }, }, + { + name: "未命中缓存,刷新缓存", + req: web.Qid{ + Qid: 22, + }, + before: func(req *http.Request) { + err := s.db.Create(&dao.PublishQuestion{ + Id: 22, + Uid: uid, + Labels: sqlx.JsonColumn[[]string]{ + Valid: true, + Val: []string{"MySQL"}, + }, + BizId: 32, + Biz: "baguwen", + Status: domain.PublishedStatus.ToUint8(), + Title: "缓存测试问题标题", + Content: "缓存测试问题内容", + Utime: 1739678267424, + Ctime: 1739678267424, + }).Error + require.NoError(s.T(), err) + analysis := s.buildDAOAnswerEle(22, 1, dao.AnswerElementTypeAnalysis) + analysis.Id = 101 + basic := s.buildDAOAnswerEle(22, 2, dao.AnswerElementTypeBasic) + basic.Id = 102 + intermedia := s.buildDAOAnswerEle(22, 3, dao.AnswerElementTypeIntermedia) + intermedia.Id = 103 + advanced := s.buildDAOAnswerEle(22, 4, dao.AnswerElementTypeAdvanced) + advanced.Id = 104 + + eles := []dao.PublishAnswerElement{ + dao.PublishAnswerElement(analysis), + dao.PublishAnswerElement(basic), + dao.PublishAnswerElement(advanced), + dao.PublishAnswerElement(intermedia), + } + err = s.db.WithContext(context.Background()).Create(&eles).Error + require.NoError(s.T(), err) + + }, + after: func() { + analysis := s.buildDomainAnswerEle(1, 101) + basic := s.buildDomainAnswerEle(2, 102) + intermedia := s.buildDomainAnswerEle(3, 103) + advanced := s.buildDomainAnswerEle(4, 104) + + // 校验缓存中有没有写入数据 + s.cacheAssertQuestion(domain.Question{ + Id: 22, + Uid: uid, + Labels: []string{"MySQL"}, + BizId: 32, + Biz: "baguwen", + Status: domain.PublishedStatus, + Title: "缓存测试问题标题", + Content: "缓存测试问题内容", + Answer: domain.Answer{ + Analysis: analysis, + Basic: basic, + Intermediate: intermedia, + Advanced: advanced, + }, + }) + }, + wantData: web.Question{ + Id: 22, + Labels: []string{"MySQL"}, + BizId: 32, + Biz: "baguwen", + Status: domain.PublishedStatus.ToUint8(), + Title: "缓存测试问题标题", + Content: "缓存测试问题内容", + Utime: 1739678267424, + Analysis: web.AnswerElement{ + Id: 101, + Content: fmt.Sprintf("这是解析 %d", 1), + Keywords: fmt.Sprintf("关键字 %d", 1), + Shorthand: fmt.Sprintf("快速记忆法 %d", 1), + Highlight: fmt.Sprintf("亮点 %d", 1), + Guidance: fmt.Sprintf("引导点 %d", 1), + }, + Basic: web.AnswerElement{ + Id: 102, + Content: fmt.Sprintf("这是解析 %d", 2), + Keywords: fmt.Sprintf("关键字 %d", 2), + Shorthand: fmt.Sprintf("快速记忆法 %d", 2), + Highlight: fmt.Sprintf("亮点 %d", 2), + Guidance: fmt.Sprintf("引导点 %d", 2), + }, + Intermediate: web.AnswerElement{ + Id: 103, + Content: fmt.Sprintf("这是解析 %d", 3), + Keywords: fmt.Sprintf("关键字 %d", 3), + Shorthand: fmt.Sprintf("快速记忆法 %d", 3), + Highlight: fmt.Sprintf("亮点 %d", 3), + Guidance: fmt.Sprintf("引导点 %d", 3), + }, + Advanced: web.AnswerElement{ + Id: 104, + Content: fmt.Sprintf("这是解析 %d", 4), + Keywords: fmt.Sprintf("关键字 %d", 4), + Shorthand: fmt.Sprintf("快速记忆法 %d", 4), + Highlight: fmt.Sprintf("亮点 %d", 4), + Guidance: fmt.Sprintf("引导点 %d", 4), + }, + Interactive: web.Interactive{ + CollectCnt: 25, + LikeCnt: 24, + ViewCnt: 23, + Collected: true, + }, + ExamineResult: 0, + Permitted: true, + }, + }, + { + name: "命中缓存,直接返回", + req: web.Qid{ + Qid: 23, + }, + before: func(req *http.Request) { + analysis := s.buildDomainAnswerEle(1, 105) + basic := s.buildDomainAnswerEle(2, 106) + intermedia := s.buildDomainAnswerEle(3, 107) + advanced := s.buildDomainAnswerEle(4, 108) + que := domain.Question{ + Id: 23, + Uid: uid, + Labels: []string{"MySQL"}, + BizId: 32, + Biz: "baguwen", + Status: domain.PublishedStatus, + Title: "缓存测试问题标题", + Content: "缓存测试问题内容", + Utime: time.UnixMilli(1739678267424), + Answer: domain.Answer{ + Analysis: analysis, + Basic: basic, + Intermediate: intermedia, + Advanced: advanced, + }, + } + queByte, err := json.Marshal(que) + require.NoError(s.T(), err) + err = s.rdb.Set(context.Background(), "question:publish:23", string(queByte), 24*time.Hour) + require.NoError(s.T(), err) + + }, + after: func() { + analysis := s.buildDomainAnswerEle(1, 105) + basic := s.buildDomainAnswerEle(2, 106) + intermedia := s.buildDomainAnswerEle(3, 107) + advanced := s.buildDomainAnswerEle(4, 108) + + // 校验缓存中有没有写入数据 + s.cacheAssertQuestion(domain.Question{ + Id: 23, + Uid: uid, + Labels: []string{"MySQL"}, + BizId: 32, + Biz: "baguwen", + Status: domain.PublishedStatus, + Title: "缓存测试问题标题", + Content: "缓存测试问题内容", + Answer: domain.Answer{ + Analysis: analysis, + Basic: basic, + Intermediate: intermedia, + Advanced: advanced, + }, + }) + }, + wantData: web.Question{ + Id: 23, + Labels: []string{"MySQL"}, + BizId: 32, + Biz: "baguwen", + Status: domain.PublishedStatus.ToUint8(), + Title: "缓存测试问题标题", + Content: "缓存测试问题内容", + Utime: 1739678267424, + Analysis: web.AnswerElement{ + Id: 105, + Content: fmt.Sprintf("这是解析 %d", 1), + Keywords: fmt.Sprintf("关键字 %d", 1), + Shorthand: fmt.Sprintf("快速记忆法 %d", 1), + Highlight: fmt.Sprintf("亮点 %d", 1), + Guidance: fmt.Sprintf("引导点 %d", 1), + }, + Basic: web.AnswerElement{ + Id: 106, + Content: fmt.Sprintf("这是解析 %d", 2), + Keywords: fmt.Sprintf("关键字 %d", 2), + Shorthand: fmt.Sprintf("快速记忆法 %d", 2), + Highlight: fmt.Sprintf("亮点 %d", 2), + Guidance: fmt.Sprintf("引导点 %d", 2), + }, + Intermediate: web.AnswerElement{ + Id: 107, + Content: fmt.Sprintf("这是解析 %d", 3), + Keywords: fmt.Sprintf("关键字 %d", 3), + Shorthand: fmt.Sprintf("快速记忆法 %d", 3), + Highlight: fmt.Sprintf("亮点 %d", 3), + Guidance: fmt.Sprintf("引导点 %d", 3), + }, + Advanced: web.AnswerElement{ + Id: 108, + Content: fmt.Sprintf("这是解析 %d", 4), + Keywords: fmt.Sprintf("关键字 %d", 4), + Shorthand: fmt.Sprintf("快速记忆法 %d", 4), + Highlight: fmt.Sprintf("亮点 %d", 4), + Guidance: fmt.Sprintf("引导点 %d", 4), + }, + Interactive: web.Interactive{ + CollectCnt: 26, + LikeCnt: 25, + ViewCnt: 24, + Liked: true, + }, + Permitted: true, + }, + }, } for _, tc := range testcases { s.T().Run(tc.name, func(t *testing.T) { @@ -606,6 +1080,7 @@ func (s *HandlerTestSuite) TestPubDetail() { require.Equal(t, 200, recorder.Code) data := recorder.MustScan().Data assert.Equal(t, tc.wantData, data) + tc.after() }) } } @@ -733,6 +1208,45 @@ func (s *HandlerTestSuite) initData() { require.NoError(t, err) } +// 校验缓存中的数据 +func (s *HandlerTestSuite) cacheAssertQuestion(q domain.Question) { + t := s.T() + key := fmt.Sprintf("question:publish:%d", q.Id) + val := s.rdb.Get(context.Background(), key) + require.NoError(t, val.Err) + + var actual domain.Question + err := json.Unmarshal([]byte(val.Val.(string)), &actual) + require.NoError(t, err) + + // 处理时间字段 + require.True(t, actual.Utime.Unix() > 0) + q.Utime = actual.Utime + assert.Equal(t, q, actual) + // 清理缓存 + _, err = s.rdb.Delete(context.Background(), key) + require.NoError(t, err) +} + +func (s *HandlerTestSuite) cacheAssertQuestionList(biz string, questions []domain.Question) { + key := fmt.Sprintf("question:list:%s", biz) + val := s.rdb.Get(context.Background(), key) + require.NoError(s.T(), val.Err) + + qs := []domain.Question{} + err := json.Unmarshal([]byte(val.Val.(string)), &qs) + require.NoError(s.T(), err) + require.Equal(s.T(), len(questions), len(qs)) + for idx, q := range qs { + require.True(s.T(), q.Utime.UnixMilli() > 0) + qs[idx].Utime = questions[idx].Utime + qs[idx].Answer.Utime = questions[idx].Answer.Utime + } + assert.Equal(s.T(), questions, qs) + _, err = s.rdb.Delete(context.Background(), key) + require.NoError(s.T(), err) +} + func TestHandler(t *testing.T) { suite.Run(t, new(HandlerTestSuite)) } diff --git a/internal/question/internal/repository/cache/ecache.go b/internal/question/internal/repository/cache/ecache.go index d6f108ee..56c1b373 100644 --- a/internal/question/internal/repository/cache/ecache.go +++ b/internal/question/internal/repository/cache/ecache.go @@ -16,8 +16,13 @@ package cache import ( "context" + "encoding/json" + "fmt" "time" + "github.com/ecodeclub/webook/internal/question/internal/domain" + "github.com/pkg/errors" + "github.com/ecodeclub/ecache" ) @@ -25,6 +30,14 @@ type QuestionECache struct { ec ecache.Cache } +var ( + ErrQuestionNotFound = errors.New("问题没找到") +) + +const ( + expiration = 24 * time.Hour +) + func NewQuestionECache(ec ecache.Cache) QuestionCache { return &QuestionECache{ ec: &ecache.NamespaceCache{ @@ -33,17 +46,80 @@ func NewQuestionECache(ec ecache.Cache) QuestionCache { }, } } +func (q *QuestionECache) SetQuestion(ctx context.Context, question domain.Question) error { + questionByte, err := json.Marshal(question) + if err != nil { + return errors.Wrap(err, "序列化问题失败") + } + return q.ec.Set(ctx, q.questionKey(question.Id), string(questionByte), expiration) +} + +func (q *QuestionECache) GetQuestion(ctx context.Context, id int64) (domain.Question, error) { + qVal := q.ec.Get(ctx, q.questionKey(id)) + if qVal.KeyNotFound() { + return domain.Question{}, ErrQuestionNotFound + } + if qVal.Err != nil { + return domain.Question{}, errors.Wrap(qVal.Err, "查询缓存出错") + } + + var question domain.Question + err := json.Unmarshal([]byte(qVal.Val.(string)), &question) + if err != nil { + return domain.Question{}, errors.Wrap(err, "反序列化问题失败") + } + return question, nil +} + +func (q *QuestionECache) SetQuestions(ctx context.Context, biz string, questions []domain.Question) error { + questionsByte, err := json.Marshal(questions) + if err != nil { + return errors.Wrap(err, "序列化问题失败") + } + return q.ec.Set(ctx, q.questionListKey(biz), string(questionsByte), expiration) +} + +func (q *QuestionECache) GetQuestions(ctx context.Context, biz string) ([]domain.Question, error) { + key := q.questionListKey(biz) + qVal := q.ec.Get(ctx, key) + if qVal.KeyNotFound() { + return nil, ErrQuestionNotFound + } + if qVal.Err != nil { + return nil, errors.Wrap(qVal.Err, "查询缓存出错") + } + + var questions []domain.Question + err := json.Unmarshal([]byte(qVal.Val.(string)), &questions) + if err != nil { + return nil, errors.Wrap(err, "反序列化问题失败") + } + return questions, nil +} -func (q *QuestionECache) GetTotal(ctx context.Context) (int64, error) { - return q.ec.Get(ctx, q.totalKey()).AsInt64() +func (q *QuestionECache) GetTotal(ctx context.Context, biz string) (int64, error) { + return q.ec.Get(ctx, q.totalKey(biz)).AsInt64() } -func (q *QuestionECache) SetTotal(ctx context.Context, total int64) error { +func (q *QuestionECache) SetTotal(ctx context.Context, biz string, total int64) error { // 设置更久的过期时间都可以,毕竟很少更新题库 - return q.ec.Set(ctx, q.totalKey(), total, time.Minute*30) + return q.ec.Set(ctx, q.totalKey(biz), total, time.Minute*30) +} + +func (q *QuestionECache) DelQuestion(ctx context.Context, id int64) error { + _, err := q.ec.Delete(ctx, q.questionKey(id)) + return err } // 注意 Namespace 设置 -func (q *QuestionECache) totalKey() string { - return "total" +func (q *QuestionECache) totalKey(biz string) string { + return fmt.Sprintf("total:%s", biz) +} + +func (q *QuestionECache) questionKey(id int64) string { + return fmt.Sprintf("publish:%d", id) +} + +func (q *QuestionECache) questionListKey(biz string) string { + return fmt.Sprintf("list:%s", biz) } diff --git a/internal/question/internal/repository/cache/types.go b/internal/question/internal/repository/cache/types.go index 7c7d03ca..16608d1d 100644 --- a/internal/question/internal/repository/cache/types.go +++ b/internal/question/internal/repository/cache/types.go @@ -14,9 +14,18 @@ package cache -import "context" +import ( + "context" + + "github.com/ecodeclub/webook/internal/question/internal/domain" +) type QuestionCache interface { - GetTotal(ctx context.Context) (int64, error) - SetTotal(ctx context.Context, total int64) error + GetTotal(ctx context.Context, biz string) (int64, error) + SetTotal(ctx context.Context, biz string, total int64) error + SetQuestion(ctx context.Context, question domain.Question) error + GetQuestion(ctx context.Context, id int64) (domain.Question, error) + SetQuestions(ctx context.Context, biz string, questions []domain.Question) error + GetQuestions(ctx context.Context, biz string) ([]domain.Question, error) + DelQuestion(ctx context.Context, id int64) error } diff --git a/internal/question/internal/repository/question.go b/internal/question/internal/repository/question.go index b1448e04..8914a7e6 100644 --- a/internal/question/internal/repository/question.go +++ b/internal/question/internal/repository/question.go @@ -30,6 +30,11 @@ import ( "github.com/ecodeclub/webook/internal/question/internal/repository/dao" ) +const ( + cacheMax = 50 + cacheMin = 0 +) + type Repository interface { PubList(ctx context.Context, offset int, limit int, biz string) ([]domain.Question, error) // Sync 保存到制作库,而后同步到线上库 @@ -61,7 +66,20 @@ type CachedRepository struct { } func (c *CachedRepository) PubCount(ctx context.Context, biz string) (int64, error) { - return c.dao.PubCount(ctx, biz) + total, cacheErr := c.cache.GetTotal(ctx, biz) + if cacheErr == nil { + return total, nil + } + total, err := c.dao.PubCount(ctx, biz) + if err != nil { + return 0, err + } + cacheErr = c.cache.SetTotal(ctx, biz, total) + if cacheErr != nil { + // 记录一下日志 + c.logger.Error("记录缓存失败", elog.FieldErr(cacheErr)) + } + return total, nil } func (c *CachedRepository) QuestionIds(ctx context.Context) ([]int64, error) { @@ -77,6 +95,25 @@ func (c *CachedRepository) GetPubByIDs(ctx context.Context, qids []int64) ([]dom func (c *CachedRepository) GetPubByID(ctx context.Context, qid int64) (domain.Question, error) { // 可以缓存 + question, cacheErr := c.cache.GetQuestion(ctx, qid) + // 找到直接返回 + if cacheErr == nil { + return question, nil + } + + entityQuestion, err := c.getPubByIDFromDb(ctx, qid) + if err != nil { + return domain.Question{}, err + } + cacheErr = c.cache.SetQuestion(ctx, entityQuestion) + if cacheErr != nil { + // 记录一下日志 + c.logger.Error("记录缓存失败", elog.FieldErr(cacheErr)) + } + return entityQuestion, nil +} + +func (c *CachedRepository) getPubByIDFromDb(ctx context.Context, qid int64) (domain.Question, error) { data, pubEles, err := c.dao.GetPubByID(ctx, qid) if err != nil { return domain.Question{}, err @@ -84,7 +121,8 @@ func (c *CachedRepository) GetPubByID(ctx context.Context, qid int64) (domain.Qu eles := slice.Map(pubEles, func(idx int, src dao.PublishAnswerElement) dao.AnswerElement { return dao.AnswerElement(src) }) - return c.toDomainWithAnswer(dao.Question(data), eles), nil + entityQuestion := c.toDomainWithAnswer(dao.Question(data), eles) + return entityQuestion, nil } func (c *CachedRepository) ExcludeQuestions(ctx context.Context, ids []int64, offset int, limit int) ([]domain.Question, int64, error) { @@ -119,7 +157,26 @@ func (c *CachedRepository) GetById(ctx context.Context, qid int64) (domain.Quest } func (c *CachedRepository) Delete(ctx context.Context, qid int64) error { - return c.dao.Delete(ctx, qid) + que, _, err := c.dao.GetByID(ctx, qid) + if err != nil { + return nil + } + err = c.dao.Delete(ctx, qid) + if err != nil { + return err + } + // + cacheErr := c.cache.DelQuestion(ctx, qid) + if cacheErr != nil { + // 记录一下日志 + c.logger.Error("删除题目缓存失败", elog.FieldErr(cacheErr), elog.Int64("qid", qid)) + } + cacheErr = c.cacheList(ctx, que.Biz) + if cacheErr != nil { + // 记录一下日志 + c.logger.Error("设置题目列表缓存失败", elog.FieldErr(cacheErr), elog.String("biz", que.Biz)) + } + return nil } func (c *CachedRepository) Update(ctx context.Context, question *domain.Question) error { @@ -135,7 +192,46 @@ func (c *CachedRepository) Create(ctx context.Context, question *domain.Question func (c *CachedRepository) Sync(ctx context.Context, que *domain.Question) (int64, error) { // 理论上来说要更新缓存,但是我懒得写了 q, eles := c.toEntity(que) - return c.dao.Sync(ctx, q, eles) + id, err := c.dao.Sync(ctx, q, eles) + if err != nil { + return id, err + } + // todo 以后重构,现直接从数据库中获取,写入缓存 + questionEntity, cacheErr := c.getPubByIDFromDb(ctx, id) + if cacheErr != nil { + // 记录一下日志 + c.logger.Error("设置题目缓存失败", elog.FieldErr(cacheErr), elog.Int64("qid", id)) + } + cacheErr = c.cache.SetQuestion(ctx, questionEntity) + if cacheErr != nil { + // 记录一下日志 + c.logger.Error("设置题目缓存失败", elog.FieldErr(cacheErr), elog.Int64("qid", id)) + } + + // 更新前50条的缓存 + cacheErr = c.cacheList(ctx, que.Biz) + if cacheErr != nil { + // 记录一下日志 + c.logger.Error("设置题目列表缓存失败", elog.FieldErr(cacheErr), elog.String("biz", que.Biz)) + } + // 更新总数 + cacheErr = c.cacheTotal(ctx, que.Biz) + if cacheErr != nil { + // 记录一下日志 + c.logger.Error("设置题目总数缓存失败", elog.FieldErr(cacheErr), elog.String("biz", que.Biz)) + } + return id, nil +} + +func (c *CachedRepository) cacheList(ctx context.Context, biz string) error { + list, err := c.dao.PubList(ctx, cacheMin, cacheMax, biz) + if err != nil { + return err + } + qs := slice.Map(list, func(idx int, src dao.PublishQuestion) domain.Question { + return c.toDomain(dao.Question(src)) + }) + return c.cache.SetQuestions(ctx, biz, qs) } func (c *CachedRepository) List(ctx context.Context, offset int, limit int) ([]domain.Question, error) { @@ -151,12 +247,63 @@ func (c *CachedRepository) Total(ctx context.Context) (int64, error) { func (c *CachedRepository) PubList(ctx context.Context, offset int, limit int, biz string) ([]domain.Question, error) { // TODO 缓存第一页 + if c.checkTop50(offset, limit) { + // 可以从缓存获取 + qs, err := c.cache.GetQuestions(ctx, biz) + if err == nil { + return c.getQuestionsFromCache(qs, offset, limit), nil + } + // 未命中缓存 + daoqs, err := c.dao.PubList(ctx, cacheMin, cacheMax, biz) + if err != nil { + return nil, err + } + qs = slice.Map(daoqs, func(idx int, src dao.PublishQuestion) domain.Question { + return c.toDomain(dao.Question(src)) + }) + + cacheErr := c.cache.SetQuestions(ctx, biz, qs) + if cacheErr != nil { + c.logger.Error("设置题目列表缓存失败", elog.FieldErr(cacheErr), elog.String("biz", biz)) + } + return c.getQuestionsFromCache(qs, offset, limit), nil + } + qs, err := c.dao.PubList(ctx, offset, limit, biz) return slice.Map(qs, func(idx int, src dao.PublishQuestion) domain.Question { return c.toDomain(dao.Question(src)) }), err } +// 校验数据是否都存在于缓存中 +func (c *CachedRepository) checkTop50(offset, limit int) bool { + last := offset + limit + return last <= cacheMax +} + +func (c *CachedRepository) getQuestionsFromCache(questions []domain.Question, offset, limit int) []domain.Question { + if offset >= len(questions) { + return []domain.Question{} + } + remain := len(questions) - offset + if remain > limit { + remain = limit + } + res := make([]domain.Question, 0, remain) + for i := offset; i < offset+remain; i++ { + res = append(res, questions[i]) + } + return res +} + +func (c *CachedRepository) cacheTotal(ctx context.Context, biz string) error { + count, err := c.dao.PubCount(ctx, biz) + if err != nil { + return err + } + return c.cache.SetTotal(ctx, biz, count) +} + func (c *CachedRepository) toDomainWithAnswer(que dao.Question, eles []dao.AnswerElement) domain.Question { res := c.toDomain(que) for _, ele := range eles { diff --git a/internal/question/internal/web/handler.go b/internal/question/internal/web/handler.go index 03d4ae72..e2f2fe7c 100644 --- a/internal/question/internal/web/handler.go +++ b/internal/question/internal/web/handler.go @@ -218,5 +218,4 @@ func (h *Handler) checkPermission(gctx *ginx.Context, que domain.Question) (bool } return true, uid } - } diff --git a/internal/review/internal/integration/admin_handler_test.go b/internal/review/internal/integration/admin_handler_test.go index 27c7edd5..4640422c 100644 --- a/internal/review/internal/integration/admin_handler_test.go +++ b/internal/review/internal/integration/admin_handler_test.go @@ -16,12 +16,15 @@ package integration import ( "context" + "encoding/json" "fmt" "net/http" "strconv" "testing" "time" + "github.com/ecodeclub/ecache" + "github.com/ecodeclub/ekit/iox" "github.com/ecodeclub/ekit/sqlx" "github.com/ecodeclub/ginx/session" @@ -48,6 +51,7 @@ type AdminHandlerTestSuite struct { db *egorm.Component server *egin.Component reviewDao dao.ReviewDAO + rdb ecache.Cache } func (s *AdminHandlerTestSuite) TearDownTest() { @@ -60,6 +64,7 @@ func (s *AdminHandlerTestSuite) TearDownTest() { func (s *AdminHandlerTestSuite) SetupSuite() { db := testioc.InitDB() testmq := testioc.InitMQ() + rdb := testioc.InitCache() ctrl := gomock.NewController(s.T()) svc := intrmocks.NewMockService(ctrl) svc.EXPECT().GetByIds(gomock.Any(), "review", gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, biz string, uid int64, ids []int64) (map[int64]interactive.Interactive, error) { @@ -72,7 +77,7 @@ func (s *AdminHandlerTestSuite) SetupSuite() { }).AnyTimes() mou := startup.InitModule(db, &interactive.Module{ Svc: svc, - }, testmq, session.DefaultProvider()) + }, testmq, rdb, session.DefaultProvider()) econf.Set("server", map[string]any{"contextTimeout": "1s"}) server := egin.Load("server").Build() server.Use(func(ctx *gin.Context) { @@ -89,6 +94,7 @@ func (s *AdminHandlerTestSuite) SetupSuite() { s.db = db s.server = server s.reviewDao = reviewDao + s.rdb = rdb } func (s *AdminHandlerTestSuite) TestSave() { @@ -264,6 +270,19 @@ func (s *AdminHandlerTestSuite) TestPublish() { pubReview, err := s.reviewDao.GetPublishReview(ctx, 1) require.NoError(t, err) assertReview(t, wantReview, dao.Review(pubReview)) + s.assertCachedReview(t, domain.Review{ + ID: 1, + Title: "标题", + Desc: "简介", + Labels: []string{"MySQL"}, + Uid: uid, + JD: "测试JD", + JDAnalysis: "JD分析", + Questions: "面试问题", + QuestionAnalysis: "问题分析", + Resume: "简历内容", + Status: domain.PublishedStatus, + }) }, req: web.ReviewSaveReq{ Review: web.Review{ @@ -338,6 +357,19 @@ func (s *AdminHandlerTestSuite) TestPublish() { pubReview, err := s.reviewDao.GetPublishReview(ctx, 2) require.NoError(t, err) assertReview(t, dao.Review(wantReview), dao.Review(pubReview)) + s.assertCachedReview(t, domain.Review{ + ID: 2, + Uid: uid, + Title: "新的标题", + Desc: "新的简介", + Labels: []string{"新MySQL"}, + JD: "新的JD", + JDAnalysis: "新的分析", + Questions: "新的问题", + QuestionAnalysis: "新的分析", + Resume: "新的简历", + Status: 2, // 已发布状态 + }) }, req: web.ReviewSaveReq{ Review: web.Review{ @@ -416,6 +448,20 @@ func (s *AdminHandlerTestSuite) TestPublish() { pubReview, err := s.reviewDao.GetPublishReview(ctx, 3) require.NoError(t, err) assertReview(t, dao.Review(wantReview), dao.Review(pubReview)) + + s.assertCachedReview(t, domain.Review{ + ID: 3, + Uid: uid, + Title: "最新标题", + Desc: "最新简介", + Labels: []string{"最新MySQL"}, + JD: "最新JD", + JDAnalysis: "最新分析", + Questions: "最新问题", + QuestionAnalysis: "最新分析", + Resume: "最新简历", + Status: domain.PublishedStatus, + }) }, req: web.ReviewSaveReq{ Review: web.Review{ @@ -688,6 +734,26 @@ func (s *AdminHandlerTestSuite) TestList() { } } +func (s *AdminHandlerTestSuite) assertCachedReview(t *testing.T, want domain.Review) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + key := fmt.Sprintf("review:publish:%d", want.ID) + // 获取缓存值 + cachedVal := s.rdb.Get(ctx, key) + require.NoError(t, cachedVal.Err) + + // 反序列化 + var cachedReview domain.Review + err := json.Unmarshal([]byte(cachedVal.Val.(string)), &cachedReview) + require.NoError(t, err) + require.True(t, cachedReview.Utime > 0) + cachedReview.Utime = 0 + // 断言内容 + assert.Equal(t, want, cachedReview) + _, err = s.rdb.Delete(context.Background(), key) + require.NoError(t, err) +} + func TestReviewAdminHandler(t *testing.T) { suite.Run(t, new(AdminHandlerTestSuite)) } diff --git a/internal/review/internal/integration/handler_test.go b/internal/review/internal/integration/handler_test.go index 8a3d18c9..8637d194 100644 --- a/internal/review/internal/integration/handler_test.go +++ b/internal/review/internal/integration/handler_test.go @@ -2,12 +2,15 @@ package integration import ( "context" + "encoding/json" "fmt" "net/http" "strconv" "testing" "time" + "github.com/ecodeclub/ecache" + "github.com/ecodeclub/ekit/sqlx" "github.com/ecodeclub/webook/internal/interactive" intrmocks "github.com/ecodeclub/webook/internal/interactive/mocks" @@ -37,6 +40,7 @@ type TestSuite struct { db *egorm.Component server *egin.Component reviewDao dao.ReviewDAO + rdb ecache.Cache } func mockInteractive(biz string, id int64) interactive.Interactive { @@ -56,6 +60,7 @@ func mockInteractive(biz string, id int64) interactive.Interactive { func (s *TestSuite) SetupSuite() { db := testioc.InitDB() testmq := testioc.InitMQ() + rdb := testioc.InitCache() ctrl := gomock.NewController(s.T()) svc := intrmocks.NewMockService(ctrl) svc.EXPECT().GetByIds(gomock.Any(), "review", gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, biz string, uid int64, ids []int64) (map[int64]interactive.Interactive, error) { @@ -72,7 +77,7 @@ func (s *TestSuite) SetupSuite() { }).AnyTimes() mou := startup.InitModule(db, &interactive.Module{ Svc: svc, - }, testmq, session.DefaultProvider()) + }, testmq, rdb, session.DefaultProvider()) econf.Set("server", map[string]any{"contextTimeout": "1s"}) server := egin.Load("server").Build() server.Use(func(ctx *gin.Context) { @@ -89,6 +94,7 @@ func (s *TestSuite) SetupSuite() { s.db = db s.server = server s.reviewDao = reviewDao + s.rdb = rdb } func (s *TestSuite) TearDownTest() { @@ -271,6 +277,7 @@ func (s *TestSuite) TestPubDetail() { testCases := []struct { name string before func(t *testing.T) + after func(t *testing.T) req web.DetailReq wantCode int wantResp test.Result[web.Review] @@ -305,6 +312,86 @@ func (s *TestSuite) TestPubDetail() { _, err = s.reviewDao.Sync(ctx, review) require.NoError(t, err) }, + after: func(t *testing.T) { + s.assertCachedReview(t, domain.Review{ + ID: 1, + Uid: uid, + Title: "已发布的标题", + Desc: "已发布的描述", + Labels: []string{"已发布的标签"}, + JD: "已发布的JD", + JDAnalysis: "已发布的JD分析", + Questions: "已发布的面试问题", + QuestionAnalysis: "已发布的问题分析", + Resume: "已发布的简历", + Status: domain.PublishedStatus, + }) + }, + req: web.DetailReq{ + ID: 1, + }, + wantCode: 200, + wantResp: test.Result[web.Review]{ + Data: web.Review{ + ID: 1, + Title: "已发布的标题", + Desc: "已发布的描述", + Labels: []string{"已发布的标签"}, + JD: "已发布的JD", + JDAnalysis: "已发布的JD分析", + Questions: "已发布的面试问题", + QuestionAnalysis: "已发布的问题分析", + Resume: "已发布的简历", + Status: domain.PublishedStatus.ToUint8(), + Interactive: web.Interactive{ + CollectCnt: 4, + LikeCnt: 3, + ViewCnt: 2, + Liked: true, + Collected: false, + }, + }, + }, + }, + { + name: "直接命中缓存", + before: func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + re := domain.Review{ + ID: 1, + Uid: uid, + Title: "已发布的标题", + Desc: "已发布的描述", + Labels: []string{"已发布的标签"}, + JD: "已发布的JD", + JDAnalysis: "已发布的JD分析", + Questions: "已发布的面试问题", + QuestionAnalysis: "已发布的问题分析", + Resume: "已发布的简历", + Status: domain.PublishedStatus, + Utime: 1111111, + } + reByte, err := json.Marshal(re) + require.NoError(t, err) + err = s.rdb.Set(ctx, "review:publish:1", string(reByte), 24*time.Hour) + require.NoError(t, err) + }, + after: func(t *testing.T) { + s.assertCachedReview(t, domain.Review{ + ID: 1, + Uid: uid, + Title: "已发布的标题", + Desc: "已发布的描述", + Labels: []string{"已发布的标签"}, + JD: "已发布的JD", + JDAnalysis: "已发布的JD分析", + Questions: "已发布的面试问题", + QuestionAnalysis: "已发布的问题分析", + Resume: "已发布的简历", + Status: domain.PublishedStatus, + }) + }, req: web.DetailReq{ ID: 1, }, @@ -354,7 +441,7 @@ func (s *TestSuite) TestPubDetail() { assert.True(t, resp.Data.Utime != 0) resp.Data.Utime = 0 assert.Equal(t, tc.wantResp, resp) - + tc.after(t) // 清理数据 err = s.db.Exec("TRUNCATE table `reviews`").Error require.NoError(t, err) @@ -377,3 +464,23 @@ func assertReview(t *testing.T, expect dao.Review, actual dao.Review) { func TestReviewHandler(t *testing.T) { suite.Run(t, new(TestSuite)) } + +func (s *TestSuite) assertCachedReview(t *testing.T, want domain.Review) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + key := fmt.Sprintf("review:publish:%d", want.ID) + // 获取缓存值 + cachedVal := s.rdb.Get(ctx, key) + require.NoError(t, cachedVal.Err) + + // 反序列化 + var cachedReview domain.Review + err := json.Unmarshal([]byte(cachedVal.Val.(string)), &cachedReview) + require.NoError(t, err) + require.True(t, cachedReview.Utime > 0) + cachedReview.Utime = 0 + // 断言内容 + assert.Equal(t, want, cachedReview) + _, err = s.rdb.Delete(context.Background(), key) + require.NoError(t, err) +} diff --git a/internal/review/internal/integration/startup/wire.go b/internal/review/internal/integration/startup/wire.go index 7b205bc9..988cf6c3 100644 --- a/internal/review/internal/integration/startup/wire.go +++ b/internal/review/internal/integration/startup/wire.go @@ -3,12 +3,14 @@ package startup import ( + "github.com/ecodeclub/ecache" "github.com/ecodeclub/ginx/session" "github.com/ecodeclub/mq-api" "github.com/ecodeclub/webook/internal/interactive" "github.com/ecodeclub/webook/internal/review" "github.com/ecodeclub/webook/internal/review/internal/event" "github.com/ecodeclub/webook/internal/review/internal/repository" + "github.com/ecodeclub/webook/internal/review/internal/repository/cache" "github.com/ecodeclub/webook/internal/review/internal/repository/dao" "github.com/ecodeclub/webook/internal/review/internal/service" "github.com/ecodeclub/webook/internal/review/internal/web" @@ -16,11 +18,12 @@ import ( "github.com/google/wire" ) -func InitModule(db *egorm.Component, interSvc *interactive.Module, q mq.MQ, sp session.Provider) *review.Module { +func InitModule(db *egorm.Component, interSvc *interactive.Module, q mq.MQ, ec ecache.Cache, sp session.Provider) *review.Module { wire.Build( initReviewDao, initIntrProducer, repository.NewReviewRepo, + cache.NewReviewCache, service.NewReviewSvc, web.NewHandler, web.NewAdminHandler, diff --git a/internal/review/internal/integration/startup/wire_gen.go b/internal/review/internal/integration/startup/wire_gen.go index 1c8a051d..28224802 100644 --- a/internal/review/internal/integration/startup/wire_gen.go +++ b/internal/review/internal/integration/startup/wire_gen.go @@ -7,12 +7,14 @@ package startup import ( + "github.com/ecodeclub/ecache" "github.com/ecodeclub/ginx/session" "github.com/ecodeclub/mq-api" "github.com/ecodeclub/webook/internal/interactive" "github.com/ecodeclub/webook/internal/review" "github.com/ecodeclub/webook/internal/review/internal/event" "github.com/ecodeclub/webook/internal/review/internal/repository" + "github.com/ecodeclub/webook/internal/review/internal/repository/cache" "github.com/ecodeclub/webook/internal/review/internal/repository/dao" "github.com/ecodeclub/webook/internal/review/internal/service" "github.com/ecodeclub/webook/internal/review/internal/web" @@ -22,9 +24,10 @@ import ( // Injectors from wire.go: -func InitModule(db *gorm.DB, interSvc *interactive.Module, q mq.MQ, sp session.Provider) *review.Module { +func InitModule(db *gorm.DB, interSvc *interactive.Module, q mq.MQ, ec ecache.Cache, sp session.Provider) *review.Module { reviewDAO := initReviewDao(db) - reviewRepo := repository.NewReviewRepo(reviewDAO) + reviewCache := cache.NewReviewCache(ec) + reviewRepo := repository.NewReviewRepo(reviewDAO, reviewCache) interactiveEventProducer := initIntrProducer(q) reviewSvc := service.NewReviewSvc(reviewRepo, interactiveEventProducer) serviceService := interSvc.Svc diff --git a/internal/review/internal/repository/cache/review.go b/internal/review/internal/repository/cache/review.go new file mode 100644 index 00000000..acc58e7a --- /dev/null +++ b/internal/review/internal/repository/cache/review.go @@ -0,0 +1,65 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/ecodeclub/ecache" + "github.com/ecodeclub/webook/internal/review/internal/domain" + "github.com/pkg/errors" +) + +const ( + reviewExpiration = 24 * time.Hour +) + +var ( + ErrReviewNotFound = errors.New("面经没找到") +) + +type ReviewCache interface { + SetReview(ctx context.Context, re domain.Review) error + GetReview(ctx context.Context, id int64) (domain.Review, error) +} +type reviewCache struct { + ec ecache.Cache +} + +func NewReviewCache(ec ecache.Cache) ReviewCache { + return &reviewCache{ + ec: &ecache.NamespaceCache{ + C: ec, + Namespace: "review:", + }, + } +} + +func (r *reviewCache) SetReview(ctx context.Context, re domain.Review) error { + reviewByte, err := json.Marshal(re) + if err != nil { + return errors.Wrap(err, "序列化面经失败") + } + return r.ec.Set(ctx, r.reviewKey(re.ID), string(reviewByte), reviewExpiration) +} + +func (r *reviewCache) GetReview(ctx context.Context, id int64) (domain.Review, error) { + val := r.ec.Get(ctx, r.reviewKey(id)) + if val.KeyNotFound() { + return domain.Review{}, ErrReviewNotFound + } + if val.Err != nil { + return domain.Review{}, errors.Wrap(val.Err, "查询缓存出错") + } + + var re domain.Review + err := json.Unmarshal([]byte(val.Val.(string)), &re) + if err != nil { + return domain.Review{}, errors.Wrap(err, "反序列化评价失败") + } + return re, nil +} +func (r *reviewCache) reviewKey(id int64) string { + return fmt.Sprintf("publish:%d", id) +} diff --git a/internal/review/internal/repository/dao/review.go b/internal/review/internal/repository/dao/review.go index b21918d2..5a0ce07c 100644 --- a/internal/review/internal/repository/dao/review.go +++ b/internal/review/internal/repository/dao/review.go @@ -25,7 +25,7 @@ type ReviewDAO interface { Count(ctx context.Context) (int64, error) // Sync 同步到线上库 - Sync(ctx context.Context, c Review) (int64, error) + Sync(ctx context.Context, c Review) (Review, error) PublishReviewList(ctx context.Context, offset, limit int) ([]PublishReview, error) GetPublishReview(ctx context.Context, reviewId int64) (PublishReview, error) } @@ -83,23 +83,24 @@ func (r *reviewDao) save(db *gorm.DB, review Review) (int64, error) { return review.ID, err } -func (r *reviewDao) Sync(ctx context.Context, c Review) (int64, error) { - var id = c.ID +func (r *reviewDao) Sync(ctx context.Context, re Review) (Review, error) { + var id = re.ID now := time.Now().UnixMilli() - c.Ctime = now - c.Utime = now + re.Ctime = now + re.Utime = now err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var err error - id, err = r.save(tx, c) + id, err = r.save(tx, re) if err != nil { return err } - pubReview := PublishReview(c) + re.ID = id + pubReview := PublishReview(re) return tx.Clauses(clause.OnConflict{ DoUpdates: clause.AssignmentColumns(r.getUpdateCols()), }).Create(&pubReview).Error }) - return id, err + return re, err } func (r *reviewDao) PublishReviewList(ctx context.Context, offset, limit int) ([]PublishReview, error) { diff --git a/internal/review/internal/repository/review.go b/internal/review/internal/repository/review.go index 1ae98f22..7030c5c2 100644 --- a/internal/review/internal/repository/review.go +++ b/internal/review/internal/repository/review.go @@ -4,6 +4,8 @@ import ( "context" "github.com/ecodeclub/ekit/sqlx" + "github.com/ecodeclub/webook/internal/review/internal/repository/cache" + "github.com/gotomicro/ego/core/elog" "github.com/ecodeclub/ekit/slice" "github.com/ecodeclub/webook/internal/review/internal/domain" @@ -21,12 +23,16 @@ type ReviewRepo interface { PubInfo(ctx context.Context, id int64) (domain.Review, error) } type reviewRepo struct { - reviewDao dao.ReviewDAO + reviewDao dao.ReviewDAO + reviewCache cache.ReviewCache + logger *elog.Component } -func NewReviewRepo(reviewDao dao.ReviewDAO) ReviewRepo { +func NewReviewRepo(reviewDao dao.ReviewDAO, reviewCache cache.ReviewCache) ReviewRepo { return &reviewRepo{ - reviewDao: reviewDao, + reviewDao: reviewDao, + reviewCache: reviewCache, + logger: elog.DefaultLogger, } } @@ -59,7 +65,15 @@ func (r *reviewRepo) Info(ctx context.Context, id int64) (domain.Review, error) } func (r *reviewRepo) Publish(ctx context.Context, re domain.Review) (int64, error) { - return r.reviewDao.Sync(ctx, toDaoReview(re)) + reDao, err := r.reviewDao.Sync(ctx, toDaoReview(re)) + if err != nil { + return 0, err + } + cacheErr := r.reviewCache.SetReview(ctx, toDomainReview(reDao)) + if cacheErr != nil { + r.logger.Error("设置面经缓存失败", elog.FieldErr(cacheErr), elog.Int64("review_id", reDao.ID)) + } + return reDao.ID, nil } func (r *reviewRepo) PubList(ctx context.Context, offset, limit int) ([]domain.Review, error) { @@ -74,11 +88,21 @@ func (r *reviewRepo) PubList(ctx context.Context, offset, limit int) ([]domain.R } func (r *reviewRepo) PubInfo(ctx context.Context, id int64) (domain.Review, error) { + // 先尝试从缓存获取 + re, err := r.reviewCache.GetReview(ctx, id) + if err == nil { + return re, nil + } + // 缓存未命中时回源查询 pubReview, err := r.reviewDao.GetPublishReview(ctx, id) if err != nil { return domain.Review{}, err } - return toDomainReview(dao.Review(pubReview)), nil + domainRe := toDomainReview(dao.Review(pubReview)) + if cacheErr := r.reviewCache.SetReview(ctx, domainRe); cacheErr != nil { + r.logger.Error("设置发布面经缓存失败", elog.FieldErr(cacheErr), elog.Int64("review_id", id)) + } + return domainRe, nil } // 将 domain.Review 转换为 dao.Review @@ -102,6 +126,7 @@ func toDaoReview(review domain.Review) dao.Review { func toDomainReview(review dao.Review) domain.Review { return domain.Review{ ID: review.ID, + Uid: review.Uid, JD: review.JD, Title: review.Title, Desc: review.Desc, diff --git a/internal/review/wire.go b/internal/review/wire.go index ad746acf..ba702eb0 100644 --- a/internal/review/wire.go +++ b/internal/review/wire.go @@ -3,11 +3,13 @@ package review import ( + "github.com/ecodeclub/ecache" "github.com/ecodeclub/ginx/session" "github.com/ecodeclub/mq-api" "github.com/ecodeclub/webook/internal/interactive" "github.com/ecodeclub/webook/internal/review/internal/event" "github.com/ecodeclub/webook/internal/review/internal/repository" + "github.com/ecodeclub/webook/internal/review/internal/repository/cache" "github.com/ecodeclub/webook/internal/review/internal/repository/dao" "github.com/ecodeclub/webook/internal/review/internal/service" "github.com/ecodeclub/webook/internal/review/internal/web" @@ -15,12 +17,18 @@ import ( "github.com/google/wire" ) -func InitModule(db *egorm.Component, interSvc *interactive.Module, q mq.MQ, sp session.Provider) *Module { +func InitModule(db *egorm.Component, + interSvc *interactive.Module, + q mq.MQ, + sp session.Provider, + ec ecache.Cache, +) *Module { wire.Build( initReviewDao, initIntrProducer, repository.NewReviewRepo, service.NewReviewSvc, + cache.NewReviewCache, web.NewHandler, web.NewAdminHandler, wire.FieldsOf(new(*interactive.Module), "Svc"), diff --git a/internal/review/wire_gen.go b/internal/review/wire_gen.go index 5970d02d..63da875f 100644 --- a/internal/review/wire_gen.go +++ b/internal/review/wire_gen.go @@ -7,11 +7,13 @@ package review import ( + "github.com/ecodeclub/ecache" "github.com/ecodeclub/ginx/session" "github.com/ecodeclub/mq-api" "github.com/ecodeclub/webook/internal/interactive" "github.com/ecodeclub/webook/internal/review/internal/event" "github.com/ecodeclub/webook/internal/review/internal/repository" + "github.com/ecodeclub/webook/internal/review/internal/repository/cache" "github.com/ecodeclub/webook/internal/review/internal/repository/dao" "github.com/ecodeclub/webook/internal/review/internal/service" "github.com/ecodeclub/webook/internal/review/internal/web" @@ -21,9 +23,10 @@ import ( // Injectors from wire.go: -func InitModule(db *gorm.DB, interSvc *interactive.Module, q mq.MQ, sp session.Provider) *Module { +func InitModule(db *gorm.DB, interSvc *interactive.Module, q mq.MQ, sp session.Provider, ec ecache.Cache) *Module { reviewDAO := initReviewDao(db) - reviewRepo := repository.NewReviewRepo(reviewDAO) + reviewCache := cache.NewReviewCache(ec) + reviewRepo := repository.NewReviewRepo(reviewDAO, reviewCache) interactiveEventProducer := initIntrProducer(q) reviewSvc := service.NewReviewSvc(reviewRepo, interactiveEventProducer) serviceService := interSvc.Svc diff --git a/ioc/wire_gen.go b/ioc/wire_gen.go index 83bd48e2..2fd5bf75 100644 --- a/ioc/wire_gen.go +++ b/ioc/wire_gen.go @@ -78,7 +78,7 @@ func InitApp() (*App, error) { handler2 := userModule.Hdl config := InitCosConfig() handler3 := cos.InitHandler(config) - casesModule, err := cases.InitModule(db, interactiveModule, aiModule, module, provider, mq) + casesModule, err := cases.InitModule(db, interactiveModule, aiModule, module, provider, cache, mq) if err != nil { return nil, err } @@ -137,7 +137,7 @@ func InitApp() (*App, error) { projectHandler := resumeModule.PrjHdl analysisHandler := resumeModule.AnalysisHandler handler17 := aiModule.Hdl - reviewModule := review.InitModule(db, interactiveModule, mq, provider) + reviewModule := review.InitModule(db, interactiveModule, mq, provider, cache) handler18 := reviewModule.Hdl component := initGinxServer(provider, checkMembershipMiddlewareBuilder, localActiveLimit, checkPermissionMiddlewareBuilder, handler, examineHandler, questionSetHandler, webHandler, handler2, handler3, handler4, handler5, handler6, handler7, handler8, handler9, handler10, handler11, handler12, handler13, handler14, handler15, handler16, caseSetHandler, webExamineHandler, projectHandler, analysisHandler, handler17, handler18) adminHandler := projectModule.AdminHdl From 8478949946a01b57ec6e799752c1f184105c3c60 Mon Sep 17 00:00:00 2001 From: Deng Ming Date: Wed, 19 Feb 2025 10:19:53 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=8D=87=E7=BA=A7=20ginx=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8ff0041c..589e4164 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/bwmarrin/snowflake v0.3.0 github.com/ecodeclub/ecache v0.0.0-20240111145855-75679834beca github.com/ecodeclub/ekit v0.0.9-0.20240331142359-871f65136a9b - github.com/ecodeclub/ginx v0.0.0-20250123094857-7acefcc057ee + github.com/ecodeclub/ginx v0.0.1 github.com/ecodeclub/mq-api v0.0.0-20240508035004-fd7de3346cfe github.com/ego-component/egorm v1.1.1 github.com/gin-contrib/cors v1.5.0 diff --git a/go.sum b/go.sum index ca180a3d..0bc31bab 100644 --- a/go.sum +++ b/go.sum @@ -148,8 +148,8 @@ github.com/ecodeclub/ecache v0.0.0-20240111145855-75679834beca h1:qksXJxULYYX+3Z github.com/ecodeclub/ecache v0.0.0-20240111145855-75679834beca/go.mod h1:faDaVWB0J1EfgyY6e7Z40EWv65Asu4FrtlWVDAOBRiM= github.com/ecodeclub/ekit v0.0.9-0.20240331142359-871f65136a9b h1:E2+ixsdn65iQdIBWEC0afanMV0P9/vcaTUq9aLWOTzk= github.com/ecodeclub/ekit v0.0.9-0.20240331142359-871f65136a9b/go.mod h1:rEGubThvxoIQT/qnbVBkZgSvYwgKrY/dtwEWKRTmgeY= -github.com/ecodeclub/ginx v0.0.0-20250123094857-7acefcc057ee h1:lEwj76B8kDnAFKKfC2kIF+uaG2vu83s1hVxyuZbmytU= -github.com/ecodeclub/ginx v0.0.0-20250123094857-7acefcc057ee/go.mod h1:PCGcpNNuknwamOKIEkRwfwKngqg4syGydrONSIxb08w= +github.com/ecodeclub/ginx v0.0.1 h1:YLGf3gCibcAzuyfnR4nvimd59YVOwb2NyJoSbELa4/Q= +github.com/ecodeclub/ginx v0.0.1/go.mod h1:PCGcpNNuknwamOKIEkRwfwKngqg4syGydrONSIxb08w= github.com/ecodeclub/mq-api v0.0.0-20240508035004-fd7de3346cfe h1:PJ/YcqAQx/9XnRDoLGD9Or1gNgYjJK72+RhX7TbyMao= github.com/ecodeclub/mq-api v0.0.0-20240508035004-fd7de3346cfe/go.mod h1:M+2owQhSRoGyX15L0rUdoSvUDvTOuexquG/605wqYtI= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=