diff --git a/Taskfile.yaml b/Taskfile.yaml index c27514bbe..f3874badb 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -85,7 +85,7 @@ tasks: test-all: desc: Run all tests. cmds: - - go test -timeout 10s -tags test ./... + - go test -v -timeout 10s -tags test github.com/bangumi/server/internal/tag/... env: TEST_MYSQL: "1" TEST_REDIS: "1" diff --git a/canal/canal.go b/canal/canal.go index 50ac7f1b8..3b57ec5e6 100644 --- a/canal/canal.go +++ b/canal/canal.go @@ -59,7 +59,7 @@ func Main() error { // driver and connector fx.Provide( - driver.NewMysqlConnectionPool, + driver.NewMysqlSqlDB, driver.NewRedisClient, logger.Copy, cache.NewRedisCache, subject.NewMysqlRepo, search.New, session.NewMysqlRepo, session.New, driver.NewS3, diff --git a/cmd/archive/main.go b/cmd/archive/main.go index fd23fbc79..047b6a79d 100644 --- a/cmd/archive/main.go +++ b/cmd/archive/main.go @@ -80,7 +80,7 @@ func start(out string) { err := fx.New( fx.NopLogger, fx.Provide( - driver.NewMysqlConnectionPool, dal.NewDB, + driver.NewMysqlSqlDB, dal.NewGormDB, config.NewAppConfig, logger.Copy, @@ -210,7 +210,7 @@ type Subject struct { type Tag struct { Name string `json:"name"` - Count int `json:"count"` + Count uint `json:"count"` } func exportSubjects(q *query.Query, w io.Writer) { diff --git a/cmd/gen/gorm/main.go b/cmd/gen/gorm/main.go index 643aa85d4..baccbecba 100644 --- a/cmd/gen/gorm/main.go +++ b/cmd/gen/gorm/main.go @@ -92,12 +92,12 @@ func main() { panic("failed to read config: " + err.Error()) } - conn, err := driver.NewMysqlConnectionPool(c) + conn, err := driver.NewMysqlSqlDB(c) if err != nil { panic(err) } - db, err := dal.NewDB(conn, c) + db, err := dal.NewGormDB(conn, c) if err != nil { panic(err) } diff --git a/cmd/web/cmd.go b/cmd/web/cmd.go index 7127a0561..4e3ff73b2 100644 --- a/cmd/web/cmd.go +++ b/cmd/web/cmd.go @@ -41,6 +41,7 @@ import ( "github.com/bangumi/server/internal/revision" "github.com/bangumi/server/internal/search" "github.com/bangumi/server/internal/subject" + "github.com/bangumi/server/internal/tag" "github.com/bangumi/server/internal/timeline" "github.com/bangumi/server/internal/user" "github.com/bangumi/server/web" @@ -65,7 +66,8 @@ func start() error { fx.Provide( config.AppConfigReader(config.AppTypeHTTP), driver.NewRedisClientWithMetrics, // redis - driver.NewMysqlConnectionPool, // mysql + driver.NewMysqlSqlDB, // mysql + driver.NewRueidisClient, func() *resty.Client { httpClient := resty.New().SetJSONEscapeHTML(false) httpClient.JSONUnmarshal = json.Unmarshal @@ -74,6 +76,8 @@ func start() error { }, ), + fx.Invoke(dal.SetupMetrics), + dal.Module, fx.Provide( @@ -87,6 +91,8 @@ func start() error { dam.New, subject.NewMysqlRepo, subject.NewCachedRepo, person.NewMysqlRepo, + tag.NewCachedRepo, tag.NewMysqlRepo, + auth.NewService, person.NewService, search.New, ), diff --git a/dal/fx.go b/dal/fx.go index 465467229..1f06120bb 100644 --- a/dal/fx.go +++ b/dal/fx.go @@ -27,7 +27,7 @@ import ( var Module = fx.Module("dal", fx.Provide( - NewDB, + NewGormDB, query.Use, NewMysqlTransaction, func(db *sql.DB) *sqlx.DB { diff --git a/dal/metrics.go b/dal/metrics.go index 041d6120f..4702c7a0b 100644 --- a/dal/metrics.go +++ b/dal/metrics.go @@ -22,7 +22,7 @@ import ( "gorm.io/gorm" ) -func setupMetrics(db *gorm.DB, conn *sql.DB) error { +func SetupMetrics(db *gorm.DB, conn *sql.DB) error { var DatabaseQuery = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "chii_db_execute_total", diff --git a/dal/new.go b/dal/new.go index bef0f170b..6166a1447 100644 --- a/dal/new.go +++ b/dal/new.go @@ -27,7 +27,7 @@ import ( "github.com/bangumi/server/internal/pkg/logger" ) -func NewDB(conn *sql.DB, c config.AppConfig) (*gorm.DB, error) { +func NewGormDB(conn *sql.DB, c config.AppConfig) (*gorm.DB, error) { var gLog gormLogger.Interface if c.Debug.Gorm { logger.Info("enable gorm debug mode, will log all sql") @@ -51,9 +51,5 @@ func NewDB(conn *sql.DB, c config.AppConfig) (*gorm.DB, error) { return nil, errgo.Wrap(err, "create dal") } - if err = setupMetrics(db, conn); err != nil { - return nil, errgo.Wrap(err, "setup metrics") - } - return db, nil } diff --git a/dal/new_test.go b/dal/new_test.go index 54f0645cc..7245ea6bc 100644 --- a/dal/new_test.go +++ b/dal/new_test.go @@ -31,9 +31,9 @@ func TestNewDB(t *testing.T) { cfg, err := config.NewAppConfig() require.NoError(t, err) - conn, err := driver.NewMysqlConnectionPool(cfg) + conn, err := driver.NewMysqlSqlDB(cfg) require.NoError(t, err) - db, err := dal.NewDB(conn, cfg) + db, err := dal.NewGormDB(conn, cfg) require.NoError(t, err) err = db.Exec("select 0;").Error diff --git a/go.mod b/go.mod index 912ef457b..44dae6b6b 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/prometheus/client_golang v1.20.4 github.com/redis/go-redis/v9 v9.6.1 + github.com/redis/rueidis v1.0.47 github.com/samber/lo v1.47.0 github.com/segmentio/kafka-go v0.4.47 github.com/spf13/cobra v1.8.1 diff --git a/go.sum b/go.sum index 47325d807..63e43c185 100644 --- a/go.sum +++ b/go.sum @@ -338,8 +338,8 @@ github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y= -github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= @@ -400,6 +400,8 @@ github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqn github.com/redis/go-redis/v9 v9.0.2/go.mod h1:/xDTe9EF1LM61hek62Poq2nzQSGj0xSrEtEHbBQevps= github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/redis/rueidis v1.0.47 h1:41UdeXOo4eJuW+cfpUJuLtVGyO0QJY3A2rEYgJWlfHs= +github.com/redis/rueidis v1.0.47/go.mod h1:by+34b0cFXndxtYmPAHpoTHO5NkosDlBvhexoTURIxM= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -513,6 +515,8 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/internal/cachekey/cachekey.go b/internal/cachekey/cachekey.go index 319cc3b23..2f424a728 100644 --- a/internal/cachekey/cachekey.go +++ b/internal/cachekey/cachekey.go @@ -54,3 +54,7 @@ func Index(id model.IndexID) string { func User(id model.UserID) string { return resPrefix + "user:" + strconv.FormatUint(uint64(id), 10) } + +func SubjectMetaTag(id model.SubjectID) string { + return resPrefix + "subject:meta-tags:" + strconv.FormatUint(uint64(id), 10) +} diff --git a/internal/collections/infra/mysql_repo.go b/internal/collections/infra/mysql_repo.go index a408e44ce..e45df0bd4 100644 --- a/internal/collections/infra/mysql_repo.go +++ b/internal/collections/infra/mysql_repo.go @@ -333,8 +333,8 @@ func (r mysqlRepo) reCountSubjectTags(ctx context.Context, tx *query.Query, return errgo.Trace(err) } - var count = make(map[string]int) - var countMap = make(map[string]uint32) + var count = make(map[string]uint) + var countMap = make(map[string]uint) for _, tag := range tagList { if !dam.ValidateTag(tag.Tag.Name) { @@ -342,7 +342,7 @@ func (r mysqlRepo) reCountSubjectTags(ctx context.Context, tx *query.Query, } count[tag.Tag.Name]++ - countMap[tag.Tag.Name] = tag.Tag.Results + countMap[tag.Tag.Name] = uint(tag.Tag.Results) } var phpTags = make([]subject.Tag, 0, len(count)) @@ -351,7 +351,7 @@ func (r mysqlRepo) reCountSubjectTags(ctx context.Context, tx *query.Query, phpTags = append(phpTags, subject.Tag{ Name: lo.ToPtr(name), Count: c, - TotalCount: int(countMap[name]), + TotalCount: uint(countMap[name]), }) } diff --git a/internal/model/subject.go b/internal/model/subject.go index 577c9e55f..626de64ee 100644 --- a/internal/model/subject.go +++ b/internal/model/subject.go @@ -17,8 +17,9 @@ package model const subjectLocked = 2 type Tag struct { - Name string - Count int + Name string + Count uint + TotalCount uint } type Subject struct { diff --git a/internal/pkg/cache/noop.go b/internal/pkg/cache/noop.go index 491f46aab..8dc204b32 100644 --- a/internal/pkg/cache/noop.go +++ b/internal/pkg/cache/noop.go @@ -17,6 +17,8 @@ package cache import ( "context" "time" + + "github.com/redis/rueidis" ) func NewNoop() RedisCache { @@ -36,3 +38,7 @@ func (n noop) Set(context.Context, string, any, time.Duration) error { func (n noop) Del(context.Context, ...string) error { return nil } + +func (n noop) mget(ctx context.Context, key []string) rueidis.RedisResult { + return rueidis.RedisResult{} +} diff --git a/internal/pkg/cache/redis.go b/internal/pkg/cache/redis.go index 16746dd5f..9806f94d1 100644 --- a/internal/pkg/cache/redis.go +++ b/internal/pkg/cache/redis.go @@ -19,6 +19,7 @@ import ( "time" "github.com/redis/go-redis/v9" + "github.com/redis/rueidis" "github.com/trim21/errgo" "go.uber.org/zap" @@ -34,15 +35,18 @@ type RedisCache interface { Get(ctx context.Context, key string, value any) (bool, error) Set(ctx context.Context, key string, value any, ttl time.Duration) error Del(ctx context.Context, keys ...string) error + + mget(ctx context.Context, key []string) rueidis.RedisResult } // NewRedisCache create a redis backed cache. -func NewRedisCache(cli *redis.Client) RedisCache { - return redisCache{r: cli} +func NewRedisCache(cli *redis.Client, ru rueidis.Client) RedisCache { + return redisCache{r: cli, ru: ru} } type redisCache struct { - r *redis.Client + r *redis.Client + ru rueidis.Client } func (c redisCache) Get(ctx context.Context, key string, value any) (bool, error) { @@ -66,6 +70,14 @@ func (c redisCache) Get(ctx context.Context, key string, value any) (bool, error return true, nil } +func (c redisCache) mget(ctx context.Context, keys []string) rueidis.RedisResult { + return c.ru.Do(ctx, c.ru.B().Mget().Key(keys...).Build()) +} + +func MGet[T any](c RedisCache, ctx context.Context, keys []string, value *[]T) error { + return rueidis.DecodeSliceOfJSON(c.mget(ctx, keys), value) +} + func (c redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error { b, err := marshalBytes(value) if err != nil { diff --git a/internal/pkg/driver/mysql.go b/internal/pkg/driver/mysql.go index 480ec3a54..03b65a5de 100644 --- a/internal/pkg/driver/mysql.go +++ b/internal/pkg/driver/mysql.go @@ -31,7 +31,8 @@ import ( var setLoggerOnce = sync.Once{} -func NewMysqlConnectionPool(c config.AppConfig) (*sql.DB, error) { +//nolint:stylecheck +func NewMysqlSqlDB(c config.AppConfig) (*sql.DB, error) { setLoggerOnce.Do(func() { _ = mysql.SetLogger(logger.StdAt(zap.ErrorLevel)) }) diff --git a/internal/pkg/driver/redis.go b/internal/pkg/driver/redis.go index 9eb4e3eaf..ea5d378fa 100644 --- a/internal/pkg/driver/redis.go +++ b/internal/pkg/driver/redis.go @@ -16,9 +16,12 @@ package driver import ( "context" + "fmt" + "net/url" "time" "github.com/redis/go-redis/v9" + "github.com/redis/rueidis" "github.com/trim21/errgo" "go.uber.org/zap" @@ -63,3 +66,21 @@ func NewRedisClientWithMetrics(c config.AppConfig) (*redis.Client, error) { return cli, nil } + +func NewRueidisClient(c config.AppConfig) (rueidis.Client, error) { + u, err := url.Parse(c.RedisURL) + if err != nil { + return nil, err + } + + password, _ := u.User.Password() + cli, err := rueidis.NewClient(rueidis.ClientOption{ + InitAddress: []string{fmt.Sprintf("%s:%s", u.Hostname(), u.Port())}, + Password: password, + }) + if err != nil { + return cli, err + } + + return cli, nil +} diff --git a/internal/pkg/test/fx.go b/internal/pkg/test/fx.go new file mode 100644 index 000000000..209897f57 --- /dev/null +++ b/internal/pkg/test/fx.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see + +package test + +import ( + "encoding/json" + "testing" + + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/require" + "go.uber.org/fx" + "go.uber.org/zap" + + "github.com/bangumi/server/config" + "github.com/bangumi/server/dal" + "github.com/bangumi/server/internal/pkg/cache" + "github.com/bangumi/server/internal/pkg/driver" +) + +func Fx(t *testing.T, target ...fx.Option) { + t.Helper() + err := fx.New( + append(target, fx.NopLogger, + + // driver and connector + fx.Provide( + config.AppConfigReader(config.AppTypeHTTP), + driver.NewRedisClient, // redis + driver.NewRueidisClient, // redis + driver.NewMysqlSqlDB, // mysql + func() *resty.Client { + httpClient := resty.New().SetJSONEscapeHTML(false) + httpClient.JSONUnmarshal = json.Unmarshal + httpClient.JSONMarshal = json.Marshal + return httpClient + }, + ), + + dal.Module, + + fx.Provide(cache.NewRedisCache, zap.NewNop), + )..., + ).Err() + + require.NoError(t, err) +} diff --git a/internal/pkg/test/gorm.go b/internal/pkg/test/gorm.go index 7c90a4eb2..0424f24af 100644 --- a/internal/pkg/test/gorm.go +++ b/internal/pkg/test/gorm.go @@ -56,7 +56,7 @@ func GetGorm(tb testing.TB) *gorm.DB { func newGorm(tb testing.TB, c config.AppConfig) (*gorm.DB, error) { tb.Helper() - conn, err := driver.NewMysqlConnectionPool(c) + conn, err := driver.NewMysqlSqlDB(c) if err != nil { return nil, errgo.Wrap(err, "sql.Open") } diff --git a/internal/subject/mysq_repository_compat.go b/internal/subject/mysq_repository_compat.go index a1d761142..b3c2ef2b4 100644 --- a/internal/subject/mysq_repository_compat.go +++ b/internal/subject/mysq_repository_compat.go @@ -24,8 +24,8 @@ import ( type Tag struct { Name *string `php:"tag_name"` - Count int `php:"result,string"` - TotalCount int `php:"tag_results,string"` + Count uint `php:"result,string"` + TotalCount uint `php:"tag_results,string"` } func ParseTags(b []byte) ([]model.Tag, error) { diff --git a/internal/tag/cache_repo.go b/internal/tag/cache_repo.go new file mode 100644 index 000000000..71570723e --- /dev/null +++ b/internal/tag/cache_repo.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see + +package tag + +import ( + "context" + "time" + + "github.com/samber/lo" + "github.com/trim21/errgo" + "go.uber.org/zap" + + "github.com/bangumi/server/internal/cachekey" + "github.com/bangumi/server/internal/model" + "github.com/bangumi/server/internal/pkg/cache" +) + +func NewCachedRepo(c cache.RedisCache, r Repo, log *zap.Logger) CachedRepo { + return cacheRepo{cache: c, repo: r, log: log.Named("subject.CachedRepo")} +} + +var _ CachedRepo = cacheRepo{} + +type cacheRepo struct { + cache cache.RedisCache + repo Repo + log *zap.Logger +} + +type cachedTags struct { + ID model.SubjectID + Tags []Tag +} + +func (r cacheRepo) Get(ctx context.Context, id model.SubjectID) ([]Tag, error) { + var key = cachekey.SubjectMetaTag(id) + + var s cachedTags + ok, err := r.cache.Get(ctx, key, &s) + if err != nil { + return s.Tags, errgo.Wrap(err, "cache.Get") + } + + if ok { + return s.Tags, nil + } + + tags, err := r.repo.Get(ctx, id) + if err != nil { + return tags, err + } + + if e := r.cache.Set(ctx, key, cachedTags{ + ID: id, + Tags: tags, + }, time.Minute); e != nil { + r.log.Error("can't set response to cache", zap.Error(e)) + } + + return tags, nil +} + +func (r cacheRepo) GetByIDs(ctx context.Context, ids []model.SubjectID) (map[model.SubjectID][]Tag, error) { + var tags []cachedTags + err := cache.MGet(r.cache, ctx, lo.Map(ids, func(item model.SubjectID, index int) string { + return cachekey.SubjectMetaTag(item) + }), &tags) + if err != nil { + return nil, errgo.Wrap(err, "cache.MGet") + } + + result := make(map[model.SubjectID][]Tag, len(ids)) + for _, tag := range tags { + result[tag.ID] = tag.Tags + } + + var missing = make([]model.SubjectID, 0, len(ids)) + for _, id := range ids { + if _, ok := result[id]; !ok { + missing = append(missing, id) + } + } + + missingFromCache, err := r.repo.GetByIDs(ctx, missing) + if err != nil { + return nil, err + } + for id, tag := range missingFromCache { + result[id] = tag + err = r.cache.Set(ctx, cachekey.SubjectMetaTag(id), cachedTags{ + ID: id, + Tags: tag, + }, time.Hour) + if err != nil { + return nil, errgo.Wrap(err, "cache.Set") + } + } + + return result, nil +} diff --git a/internal/tag/cache_repo_test.go b/internal/tag/cache_repo_test.go new file mode 100644 index 000000000..cbb61927b --- /dev/null +++ b/internal/tag/cache_repo_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see + +package tag_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/fx" + + "github.com/bangumi/server/internal/model" + "github.com/bangumi/server/internal/pkg/test" + "github.com/bangumi/server/internal/tag" +) + +func getCacheRepo(t *testing.T) tag.CachedRepo { + t.Helper() + + var r tag.CachedRepo + + test.Fx(t, fx.Provide(tag.NewCachedRepo, tag.NewMysqlRepo), fx.Populate(&r)) + + return r +} + +func TestCacheGet(t *testing.T) { + test.RequireEnv(t, test.EnvMysql, test.EnvRedis) + t.Parallel() + + repo := getCacheRepo(t) + + _, err := repo.Get(context.Background(), 8) + require.NoError(t, err) +} + +func TestCacheGetTags(t *testing.T) { + test.RequireEnv(t, test.EnvMysql, test.EnvRedis) + t.Parallel() + + repo := getCacheRepo(t) + + _, err := repo.GetByIDs(context.Background(), []model.SubjectID{1, 2, 8}) + require.NoError(t, err) +} diff --git a/internal/subject/repo2.go b/internal/tag/domain.go similarity index 54% rename from internal/subject/repo2.go rename to internal/tag/domain.go index 32f36ee71..375a7e1ce 100644 --- a/internal/subject/repo2.go +++ b/internal/tag/domain.go @@ -12,4 +12,36 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see -package subject +package tag + +import ( + "context" + + "github.com/bangumi/server/internal/model" +) + +// CatSubject 条目tag. +const CatSubject = 0 + +// CatMeta 官方tag. +const CatMeta = 3 + +type Tag struct { + Name string + Count uint + // TotalCount count for all tags including all subject + TotalCount uint +} + +type CachedRepo interface { + read +} + +type Repo interface { + read +} + +type read interface { + Get(ctx context.Context, id model.SubjectID) ([]Tag, error) + GetByIDs(ctx context.Context, ids []model.SubjectID) (map[model.SubjectID][]Tag, error) +} diff --git a/internal/tag/mysql_repo.go b/internal/tag/mysql_repo.go new file mode 100644 index 000000000..475bb5cb8 --- /dev/null +++ b/internal/tag/mysql_repo.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see + +package tag + +import ( + "context" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + + "github.com/bangumi/server/dal/query" + "github.com/bangumi/server/internal/model" +) + +func NewMysqlRepo(q *query.Query, log *zap.Logger, db *sqlx.DB) (Repo, error) { + return mysqlRepo{q: q, log: log.Named("tag.mysqlRepo"), db: db}, nil +} + +type mysqlRepo struct { + q *query.Query + log *zap.Logger + db *sqlx.DB +} + +func (r mysqlRepo) Get(ctx context.Context, id model.SubjectID) ([]Tag, error) { + var s []struct { + Tid uint `db:"tlt_tid"` + Name string `db:"tag_name"` + TotalCount uint `db:"tag_results"` + } + + err := r.db.SelectContext(ctx, &s, ` + select tlt_tid, tag_name, tag_results + from chii_tag_neue_list + inner join chii_tag_neue_index on tlt_tid = tag_id and tlt_type = tag_type + where tlt_uid = 0 and tag_cat = ? and tlt_mid = ? + `, CatSubject, id) + if err != nil { + return nil, err + } + + tags := make([]Tag, len(s)) + for i, t := range s { + tags[i] = Tag{ + Name: t.Name, + TotalCount: t.TotalCount, + } + } + + return tags, nil +} + +func (r mysqlRepo) GetByIDs(ctx context.Context, ids []model.SubjectID) (map[model.SubjectID][]Tag, error) { + var s []struct { + Tid uint `db:"tlt_tid"` + Name string `db:"tag_name"` + TotalCount uint `db:"tag_results"` + Mid model.SubjectID `db:"tlt_mid"` + } + + q, v, err := sqlx.In(` + select tlt_tid, tag_name, tag_results, tlt_mid + from chii_tag_neue_list + inner join chii_tag_neue_index on tlt_tid = tag_id and tlt_type = tag_type + where tlt_uid = 0 and tag_cat = ? and tlt_mid IN (?) + `, CatSubject, ids) + if err != nil { + return nil, err + } + + err = r.db.SelectContext(ctx, &s, q, v...) + if err != nil { + return nil, err + } + + tags := make(map[model.SubjectID][]Tag, len(s)) + for _, t := range s { + tags[t.Mid] = append(tags[t.Mid], Tag{ + Name: t.Name, + TotalCount: t.TotalCount, + }) + } + + return tags, nil +} diff --git a/internal/tag/mysql_repo_test.go b/internal/tag/mysql_repo_test.go new file mode 100644 index 000000000..31cc1585d --- /dev/null +++ b/internal/tag/mysql_repo_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see + +package tag_test + +import ( + "context" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/bangumi/server/dal/query" + "github.com/bangumi/server/internal/model" + "github.com/bangumi/server/internal/pkg/test" + "github.com/bangumi/server/internal/tag" +) + +func getRepo(t *testing.T) tag.Repo { + t.Helper() + q := query.Use(test.GetGorm(t)) + repo, err := tag.NewMysqlRepo(q, zap.NewNop(), sqlx.NewDb(lo.Must(q.DB().DB()), "mysql")) + require.NoError(t, err) + + return repo +} + +func TestGet(t *testing.T) { + test.RequireEnv(t, test.EnvMysql) + t.Parallel() + + repo := getRepo(t) + + _, err := repo.Get(context.Background(), 8) + require.NoError(t, err) +} + +func TestGetTags(t *testing.T) { + test.RequireEnv(t, test.EnvMysql) + t.Parallel() + + repo := getRepo(t) + + _, err := repo.GetByIDs(context.Background(), []model.SubjectID{1, 2, 8}) + require.NoError(t, err) +} diff --git a/web/handler/subject/browse.go b/web/handler/subject/browse.go index ebfb7b5f3..2968a425b 100644 --- a/web/handler/subject/browse.go +++ b/web/handler/subject/browse.go @@ -60,7 +60,7 @@ func (h Subject) Browse(c echo.Context) error { } data := make([]res.SubjectV0, 0, len(subjects)) for _, s := range subjects { - data = append(data, convertModelSubject(s, 0)) + data = append(data, convertModelSubject(s, 0, nil)) } return c.JSON(http.StatusOK, res.Paged{Data: data, Total: count, Limit: page.Limit, Offset: page.Offset}) diff --git a/web/handler/subject/get.go b/web/handler/subject/get.go index e7f0f623a..178842db5 100644 --- a/web/handler/subject/get.go +++ b/web/handler/subject/get.go @@ -20,6 +20,7 @@ import ( "net/http" "github.com/labstack/echo/v4" + "github.com/samber/lo" "github.com/trim21/errgo" "go.uber.org/zap" @@ -31,6 +32,7 @@ import ( "github.com/bangumi/server/internal/pkg/logger" "github.com/bangumi/server/internal/pkg/null" "github.com/bangumi/server/internal/subject" + "github.com/bangumi/server/internal/tag" "github.com/bangumi/server/pkg/vars" "github.com/bangumi/server/pkg/wiki" "github.com/bangumi/server/web/accessor" @@ -66,7 +68,12 @@ func (h Subject) Get(c echo.Context) error { return errgo.Wrap(err, "episode.Count") } - return c.JSON(http.StatusOK, convertModelSubject(s, totalEpisode)) + metaTags, err := h.tag.Get(c.Request().Context(), s.ID) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, convertModelSubject(s, totalEpisode, metaTags)) } func platformString(s model.Subject) *string { @@ -114,7 +121,7 @@ func (h Subject) GetImage(c echo.Context) error { return c.Redirect(http.StatusFound, l) } -func convertModelSubject(s model.Subject, totalEpisode int64) res.SubjectV0 { +func convertModelSubject(s model.Subject, totalEpisode int64, metaTags []tag.Tag) res.SubjectV0 { return res.SubjectV0{ TotalEpisodes: totalEpisode, ID: s.ID, @@ -128,6 +135,13 @@ func convertModelSubject(s model.Subject, totalEpisode int64) res.SubjectV0 { Volumes: s.Volumes, Redirect: s.Redirect, Eps: s.Eps, + MetaTags: lo.Map(metaTags, func(item tag.Tag, index int) res.SubjectTag { + return res.SubjectTag{ + Name: item.Name, + Count: item.Count, + TotalCont: item.TotalCount, + } + }), Tags: slice.Map(s.Tags, func(tag model.Tag) res.SubjectTag { return res.SubjectTag{ Name: tag.Name, diff --git a/web/handler/subject/subject.go b/web/handler/subject/subject.go index d7cbf6055..aaa64c008 100644 --- a/web/handler/subject/subject.go +++ b/web/handler/subject/subject.go @@ -21,6 +21,7 @@ import ( "github.com/bangumi/server/internal/episode" "github.com/bangumi/server/internal/person" "github.com/bangumi/server/internal/subject" + "github.com/bangumi/server/internal/tag" ) type Subject struct { @@ -28,6 +29,7 @@ type Subject struct { episode episode.Repo personRepo person.Repo subject subject.Repo + tag tag.CachedRepo c character.Repo } @@ -37,6 +39,7 @@ func New( personRepo person.Repo, c character.Repo, episode episode.Repo, + tag tag.CachedRepo, ) (Subject, error) { return Subject{ c: c, @@ -44,6 +47,7 @@ func New( personRepo: personRepo, subject: subject, person: p, + tag: tag, }, nil } diff --git a/web/res/subject.go b/web/res/subject.go index ba3bde9d4..8fb78cfb1 100644 --- a/web/res/subject.go +++ b/web/res/subject.go @@ -29,8 +29,9 @@ const defaultShortSummaryLength = 120 type v0wiki = []any type SubjectTag struct { - Name string `json:"name"` - Count int `json:"count"` + Name string `json:"name"` + Count uint `json:"count"` + TotalCont uint `json:"total_cont"` } type SubjectV0 struct { @@ -47,6 +48,7 @@ type SubjectV0 struct { Collection SubjectCollectionStat `json:"collection"` ID model.SubjectID `json:"id"` Eps uint32 `json:"eps"` + MetaTags []SubjectTag `json:"meta_tags"` Volumes uint32 `json:"volumes"` Series bool `json:"series"` Locked bool `json:"locked"`