From 176cd20847b231af4fa3a5934189257069ce5dc3 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 05:18:30 +0800 Subject: [PATCH] refactor: remove Gorse integration and implement HotRank feature - Removed Gorse-related configurations, handlers, and dependencies from the codebase. - Introduced HotRank feature with configuration options for ranking posts based on recent activity. - Updated application structure to support HotRank processing, including new caching mechanisms and database interactions. - Cleaned up related DTOs and repository methods to reflect the removal of Gorse and the addition of HotRank functionality. --- .gitea/workflows/deploy.yml | 15 +- cmd/server/app.go | 39 ++- cmd/server/wire.go | 2 - cmd/server/wire_gen.go | 13 +- configs/config.yaml | 21 +- go.mod | 10 +- go.sum | 7 - internal/cache/cache.go | 11 + internal/cache/keys.go | 16 +- internal/cache/layered_cache.go | 16 + internal/cache/redis_cache.go | 23 +- internal/config/config.go | 23 +- internal/config/external.go | 22 +- internal/dto/dto.go | 1 - internal/handler/gorse_handler.go | 255 --------------- internal/handler/post_handler.go | 6 - internal/model/init.go | 23 ++ internal/model/post.go | 3 +- internal/pkg/gorse/client.go | 290 ----------------- internal/pkg/gorse/config.go | 23 -- internal/pkg/gorse/embedding.go | 107 ------- internal/repository/comment_repo.go | 2 - internal/repository/post_repo.go | 114 ++++++- internal/router/router.go | 14 - internal/service/admin_post_service.go | 1 - internal/service/comment_service.go | 15 +- internal/service/hot_rank_worker.go | 335 ++++++++++++++++++++ internal/service/post_service.go | 419 ++++++++----------------- internal/wire/handler.go | 8 - internal/wire/infrastructure.go | 7 - internal/wire/service.go | 12 +- start-docker.sh | 10 +- 32 files changed, 735 insertions(+), 1128 deletions(-) delete mode 100644 internal/handler/gorse_handler.go delete mode 100644 internal/pkg/gorse/client.go delete mode 100644 internal/pkg/gorse/config.go delete mode 100644 internal/pkg/gorse/embedding.go create mode 100644 internal/service/hot_rank_worker.go diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 1fc0586..bd28610 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -34,7 +34,8 @@ jobs: deploy: runs-on: ubuntu-latest if: ${{ (github.event.pull_request.merged == true) || (github.event_name == 'workflow_dispatch') }} - environment: production + # workflow_dispatch 可选 staging;合并进主分支默认 production + environment: ${{ github.event.inputs.environment || 'production' }} steps: - name: Deploy via SSH uses: appleboy/ssh-action@v1.0.3 @@ -84,12 +85,12 @@ jobs: -e APP_ENCRYPTION_ENABLED=true \ -e "APP_ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }}" \ -e APP_ENCRYPTION_KEY_VERSION=1 \ - -e APP_GORSE_ENABLED=true \ - -e APP_GORSE_ADDRESS=http://111.170.19.33:8088 \ - -e "APP_GORSE_IMPORT_PASSWORD=${{ secrets.GORSE_IMPORT_PASSWORD }}" \ - -e "APP_GORSE_EMBEDDING_API_KEY=${{ secrets.GORSE_EMBEDDING_API_KEY }}" \ - -e APP_GORSE_EMBEDDING_URL=https://api.littlelan.cn/v1/embeddings \ - -e APP_GORSE_EMBEDDING_MODEL=BAAI/bge-m3 \ + -e APP_HOT_RANK_ENABLED=true \ + -e APP_HOT_RANK_REFRESH_INTERVAL_SECONDS=300 \ + -e APP_HOT_RANK_TOP_N=100 \ + -e APP_HOT_RANK_RECENT_WINDOW_HOURS=168 \ + -e APP_HOT_RANK_RECENT_FETCH_LIMIT=500 \ + -e APP_HOT_RANK_CANDIDATE_CAP=800 \ -e APP_OPENAI_ENABLED=true \ -e APP_OPENAI_BASE_URL=https://api.littlelan.cn/ \ -e "APP_OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" \ diff --git a/cmd/server/app.go b/cmd/server/app.go index e61c248..4c59d2d 100644 --- a/cmd/server/app.go +++ b/cmd/server/app.go @@ -16,13 +16,14 @@ import ( // App 应用程序结构体 type App struct { - Config *config.Config - DB *gorm.DB - Router *router.Router - PushService service.PushService - GRPCServer *runner.Server - Server *http.Server - Logger *zap.Logger + Config *config.Config + DB *gorm.DB + Router *router.Router + PushService service.PushService + HotRankWorker *service.HotRankWorker + GRPCServer *runner.Server + Server *http.Server + Logger *zap.Logger } // NewApp 创建应用程序 @@ -31,16 +32,18 @@ func NewApp( db *gorm.DB, r *router.Router, pushService service.PushService, + hotRankWorker *service.HotRankWorker, grpcServer *runner.Server, logger *zap.Logger, ) *App { return &App{ - Config: cfg, - DB: db, - Router: r, - PushService: pushService, - GRPCServer: grpcServer, - Logger: logger, + Config: cfg, + DB: db, + Router: r, + PushService: pushService, + HotRankWorker: hotRankWorker, + GRPCServer: grpcServer, + Logger: logger, } } @@ -57,6 +60,11 @@ func (a *App) Start(ctx context.Context) error { // 2. 启动推送 Worker a.PushService.StartPushWorker(ctx) + // 2.1 热门榜定时重算 + if a.HotRankWorker != nil { + a.HotRankWorker.Start(ctx) + } + // 3. 创建 HTTP 服务器 addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port) a.Logger.Info("Starting HTTP server", @@ -104,6 +112,11 @@ func (a *App) Stop(ctx context.Context) error { a.Logger.Info("Stopping push worker") a.PushService.StopPushWorker() + if a.HotRankWorker != nil { + a.Logger.Info("Stopping hot rank worker") + a.HotRankWorker.Stop() + } + // 4. 关闭数据库连接 a.Logger.Info("Closing database connection") sqlDB, err := a.DB.DB() diff --git a/cmd/server/wire.go b/cmd/server/wire.go index 6f6f587..f358563 100644 --- a/cmd/server/wire.go +++ b/cmd/server/wire.go @@ -35,7 +35,6 @@ func ProvideRouter( systemMessageHandler *handler.SystemMessageHandler, groupHandler *handler.GroupHandler, stickerHandler *handler.StickerHandler, - gorseHandler *handler.GorseHandler, voteHandler *handler.VoteHandler, scheduleHandler *handler.ScheduleHandler, roleHandler *handler.RoleHandler, @@ -62,7 +61,6 @@ func ProvideRouter( systemMessageHandler, groupHandler, stickerHandler, - gorseHandler, voteHandler, scheduleHandler, roleHandler, diff --git a/cmd/server/wire_gen.go b/cmd/server/wire_gen.go index 8d9c820..88828d9 100644 --- a/cmd/server/wire_gen.go +++ b/cmd/server/wire_gen.go @@ -42,7 +42,6 @@ func InitializeApp() (*App, error) { userService := wire.ProvideUserService(userRepository, systemMessageService, emailService, cache) userActivityRepository := wire.ProvideUserActivityRepository(db, cache) userActivityService := wire.ProvideUserActivityService(userActivityRepository) - gorseClient := wire.ProvideGorseClient(config) openaiClient := wire.ProvideOpenAIClient(config) postAIService := wire.ProvidePostAIService(openaiClient) transactionManager := wire.ProvideTransactionManager(db) @@ -50,8 +49,8 @@ func InitializeApp() (*App, error) { commentRepository := repository.NewCommentRepository(db) moderationHooks := wire.ProvideModerationHooks(manager, postAIService, postRepository, commentRepository, config) builtinHooks := wire.ProvideBuiltinHooks(manager) - postService := wire.ProvidePostService(postRepository, systemMessageService, gorseClient, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks) - commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, gorseClient, postAIService, cache, manager) + postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks) + commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager) operationLogRepository := repository.NewOperationLogRepository(db) loginLogRepository := repository.NewLoginLogRepository(db) dataChangeLogRepository := repository.NewDataChangeLogRepository(db) @@ -85,7 +84,6 @@ func InitializeApp() (*App, error) { stickerRepository := repository.NewStickerRepository(db) stickerService := wire.ProvideStickerService(stickerRepository) stickerHandler := handler.NewStickerHandler(stickerService) - gorseHandler := wire.ProvideGorseHandler(config, db) voteRepository := repository.NewVoteRepository(db) voteService := wire.ProvideVoteService(voteRepository, postRepository, cache, postAIService, systemMessageService) voteHandler := handler.NewVoteHandler(voteService, postService) @@ -113,8 +111,9 @@ func InitializeApp() (*App, error) { adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService) qrCodeLoginService := wire.ProvideQRCodeLoginService(client, hub, jwtService, userService, userActivityService, logService) qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService) - router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, gorseHandler, voteHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, logService, userActivityService, casbinService) - app := NewApp(config, db, router, pushService, server, logger) + router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, logService, userActivityService, casbinService) + hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, cache) + app := NewApp(config, db, router, pushService, hotRankWorker, server, logger) return app, nil } @@ -133,7 +132,6 @@ func ProvideRouter( systemMessageHandler *handler.SystemMessageHandler, groupHandler *handler.GroupHandler, stickerHandler *handler.StickerHandler, - gorseHandler *handler.GorseHandler, voteHandler *handler.VoteHandler, scheduleHandler *handler.ScheduleHandler, roleHandler *handler.RoleHandler, @@ -160,7 +158,6 @@ func ProvideRouter( systemMessageHandler, groupHandler, stickerHandler, - gorseHandler, voteHandler, scheduleHandler, roleHandler, diff --git a/configs/config.yaml b/configs/config.yaml index 3cab524..fe10a42 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -64,6 +64,15 @@ cache: unread_count_ttl: 30 group_members_ttl: 120 +# 热门榜:定时在「候选池」上重算,只写入 Redis ZSET(TopN)+ top_ids(供本地缓存);不写数据库 +hot_rank: + enabled: true + refresh_interval_seconds: 300 + top_n: 100 + recent_window_hours: 168 # 7 天内新帖进入候选 + recent_fetch_limit: 500 + candidate_cap: 800 # 上届 Top + 新帖 ID 合并后上限 + # S3对象存储配置 # 环境变量: APP_S3_ENDPOINT, APP_S3_ACCESS_KEY, APP_S3_SECRET_KEY, APP_S3_BUCKET, APP_S3_DOMAIN s3: @@ -153,18 +162,6 @@ audit: baidu_api_key: "" baidu_secret_key: "" -# Gorse推荐系统配置 -# 环境变量: APP_GORSE_ADDRESS, APP_GORSE_API_KEY, APP_GORSE_DASHBOARD, APP_GORSE_IMPORT_PASSWORD -gorse: - enabled: true - address: "http://111.170.19.33:8088" # Gorse server地址 - api_key: "" # API密钥 - dashboard: "" # Gorse dashboard地址 - import_password: "lanyimin123" # 导入数据密码 - embedding_api_key: "sk-ZPN5NMPSqEaOGCPfD2LqndZ5Wwmw3DC4CQgzgKhM35fI3RpD" - embedding_url: "https://api.littlelan.cn/v1/embeddings" - embedding_model: "BAAI/bge-m3" - # OpenAI兼容接口配置(用于帖子审核,支持图文) # 环境变量: # APP_OPENAI_ENABLED, APP_OPENAI_BASE_URL, APP_OPENAI_API_KEY diff --git a/go.mod b/go.mod index 272bbc7..1a28737 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 - github.com/gorse-io/gorse-go v0.5.0-alpha.3 github.com/minio/minio-go/v7 v7.0.99 github.com/redis/go-redis/v9 v9.18.0 github.com/spf13/viper v1.21.0 @@ -37,15 +36,12 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-ini/ini v1.67.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect @@ -80,6 +76,7 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect @@ -92,11 +89,8 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index a4e74c7..dc0f158 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,6 @@ github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/ github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -80,7 +78,6 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -127,8 +124,6 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/gorse-io/gorse-go v0.5.0-alpha.3 h1:GR/OWzq016VyFyTTxgQWeayGahRVzB1cGFIW/AaShC4= -github.com/gorse-io/gorse-go v0.5.0-alpha.3/go.mod h1:ZxmVHzZPKm5pmEIlqaRDwK0rkfTRHlfziO033XZ+RW0= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -265,8 +260,6 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 29009a8..fe09972 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -8,6 +8,8 @@ import ( "math/rand" "sync" "time" + + "github.com/redis/go-redis/v9" ) var ErrKeyNotFound = errors.New("key not found") @@ -52,6 +54,10 @@ type Cache interface { ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error) // ZRevRangeByScore 按分数范围获取成员(降序) ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error) + // ZRevRange 按排名倒序取成员(score 从高到低,start/stop 为排名下标,含端点) + ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) + // ZReplaceSortedSet 原子替换整个 ZSET(先删后批量写入) + ZReplaceSortedSet(ctx context.Context, key string, members []redis.Z) error // ZRem 删除 Sorted Set 成员 ZRem(ctx context.Context, key string, members ...interface{}) error // ZCard 获取 Sorted Set 成员数量 @@ -130,6 +136,11 @@ func normalizeKey(key string) string { return cmp.Or(settings.KeyPrefix, "") + ":" + key } +// ResolveKey 返回带全局前缀的实际 Redis 键(供需直连 Redis 的组件使用) +func ResolveKey(key string) string { + return normalizeKey(key) +} + func normalizePrefix(prefix string) string { return cmp.Or(settings.KeyPrefix, "") + ":" + prefix } diff --git a/internal/cache/keys.go b/internal/cache/keys.go index 7fc08e5..0406b46 100644 --- a/internal/cache/keys.go +++ b/internal/cache/keys.go @@ -9,6 +9,10 @@ const ( // 帖子相关 PrefixPostList = "posts:list" PrefixPost = "posts:detail" + // HotRankZSet 热门榜(有序集合,member=post_id,score=归一化热度,仅存 TopN) + HotRankZSet = "posts:hot:zset" + // HotRankTopIDs 热门榜有序 post_id 列表(JSON []string,供分层缓存本地层命中) + HotRankTopIDs = "posts:hot:top_ids" // 会话相关 PrefixConversationList = "conversations:list" @@ -36,10 +40,20 @@ const ( ) // PostListKey 生成帖子列表缓存键 -// postType: 帖子类型 (recommend, hot, follow, latest) +// postType: 帖子类型 (hot, follow, latest) // page: 页码 // pageSize: 每页数量 // userID: 用户维度(仅在个性化列表如 follow 场景使用) +// HotRankZSetKey 热门榜 Redis ZSET 逻辑键(仅存 TopN,会经 normalizeKey 加前缀) +func HotRankZSetKey() string { + return HotRankZSet +} + +// HotRankTopIDsKey 热门榜 Top 顺序 ID 列表键(会经 normalizeKey 加前缀) +func HotRankTopIDsKey() string { + return HotRankTopIDs +} + func PostListKey(postType string, userID string, page, pageSize int) string { if userID == "" { return fmt.Sprintf("%s:%s:%d:%d", PrefixPostList, postType, page, pageSize) diff --git a/internal/cache/layered_cache.go b/internal/cache/layered_cache.go index 50a094a..10d04be 100644 --- a/internal/cache/layered_cache.go +++ b/internal/cache/layered_cache.go @@ -7,6 +7,7 @@ import ( "sync" "time" + redislib "github.com/redis/go-redis/v9" "go.uber.org/zap" redisPkg "carrot_bbs/internal/pkg/redis" @@ -294,6 +295,21 @@ func (c *LayeredCache) ZRevRangeByScore(ctx context.Context, key string, max, mi return c.redis.ZRevRangeByScore(ctx, key, max, min, offset, count) } +func (c *LayeredCache) ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) { + if !c.enabled || c.redis == nil { + return nil, ErrKeyNotFound + } + return c.redis.ZRevRange(ctx, key, start, stop) +} + +func (c *LayeredCache) ZReplaceSortedSet(ctx context.Context, key string, members []redislib.Z) error { + if !c.enabled || c.redis == nil { + return ErrKeyNotFound + } + c.local.Delete(key) + return c.redis.ZReplaceSortedSet(ctx, key, members) +} + func (c *LayeredCache) ZRem(ctx context.Context, key string, members ...any) error { if !c.enabled || c.redis == nil { return nil diff --git a/internal/cache/redis_cache.go b/internal/cache/redis_cache.go index 8b59f80..b30d837 100644 --- a/internal/cache/redis_cache.go +++ b/internal/cache/redis_cache.go @@ -5,7 +5,7 @@ import ( "encoding/json" "time" - "github.com/redis/go-redis/v9" + redislib "github.com/redis/go-redis/v9" "go.uber.org/zap" redisPkg "carrot_bbs/internal/pkg/redis" @@ -53,7 +53,7 @@ func (c *RedisCache) Get(key string) (interface{}, bool) { key = normalizeKey(key) data, err := c.client.Get(c.ctx, key) if err != nil { - if err == redis.Nil { + if err == redislib.Nil { return nil, false } zap.L().Error("Failed to get key", @@ -219,6 +219,25 @@ func (c *RedisCache) ZRevRangeByScore(ctx context.Context, key string, max, min return c.client.ZRevRangeByScore(ctx, key, max, min, offset, count) } +// ZRevRange 按排名倒序获取成员 +func (c *RedisCache) ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) { + key = normalizeKey(key) + return c.client.ZRevRange(ctx, key, start, stop) +} + +// ZReplaceSortedSet 删除后整表重写 ZSET +func (c *RedisCache) ZReplaceSortedSet(ctx context.Context, key string, members []redislib.Z) error { + key = normalizeKey(key) + rdb := c.client.GetClient() + if err := rdb.Del(ctx, key).Err(); err != nil { + return err + } + if len(members) == 0 { + return nil + } + return c.client.ZAddArgs(ctx, key, members...) +} + // ZRem 删除 Sorted Set 成员 func (c *RedisCache) ZRem(ctx context.Context, key string, members ...interface{}) error { key = normalizeKey(key) diff --git a/internal/config/config.go b/internal/config/config.go index c3357b0..43601b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,13 +22,13 @@ type Config struct { Log LogConfig `mapstructure:"log"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` Upload UploadConfig `mapstructure:"upload"` - Gorse GorseConfig `mapstructure:"gorse"` OpenAI OpenAIConfig `mapstructure:"openai"` Email EmailConfig `mapstructure:"email"` ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"` GRPC GRPCConfig `mapstructure:"grpc"` Casbin CasbinConfig `mapstructure:"casbin"` Encryption EncryptionConfig `mapstructure:"encryption"` + HotRank HotRankConfig `mapstructure:"hot_rank"` } // Load 加载配置文件 @@ -97,14 +97,6 @@ func Load(configPath string) (*Config, error) { viper.SetDefault("sensitive.replace_str", "***") viper.SetDefault("audit.enabled", false) viper.SetDefault("audit.provider", "local") - viper.SetDefault("gorse.enabled", false) - viper.SetDefault("gorse.address", "http://localhost:8087") - viper.SetDefault("gorse.api_key", "") - viper.SetDefault("gorse.dashboard", "http://localhost:8088") - viper.SetDefault("gorse.import_password", "") - viper.SetDefault("gorse.embedding_api_key", "") - viper.SetDefault("gorse.embedding_url", "https://api.littlelan.cn/v1/embeddings") - viper.SetDefault("gorse.embedding_model", "BAAI/bge-m3") viper.SetDefault("openai.enabled", true) viper.SetDefault("openai.base_url", "https://api.littlelan.cn/") viper.SetDefault("openai.api_key", "") @@ -152,6 +144,12 @@ func Load(configPath string) (*Config, error) { viper.SetDefault("encryption.enabled", false) viper.SetDefault("encryption.key", "") viper.SetDefault("encryption.key_version", 1) + viper.SetDefault("hot_rank.enabled", true) + viper.SetDefault("hot_rank.refresh_interval_seconds", 300) + viper.SetDefault("hot_rank.top_n", 100) + viper.SetDefault("hot_rank.recent_window_hours", 168) + viper.SetDefault("hot_rank.recent_fetch_limit", 500) + viper.SetDefault("hot_rank.candidate_cap", 800) if err := viper.ReadInConfig(); err != nil { return nil, fmt.Errorf("failed to read config: %w", err) @@ -203,13 +201,6 @@ func Load(configPath string) (*Config, error) { cfg.Server.Host = getEnvOrDefault("APP_SERVER_HOST", cfg.Server.Host) cfg.Server.Port, _ = strconv.Atoi(getEnvOrDefault("APP_SERVER_PORT", fmt.Sprintf("%d", cfg.Server.Port))) cfg.Server.Mode = getEnvOrDefault("APP_SERVER_MODE", cfg.Server.Mode) - cfg.Gorse.Address = getEnvOrDefault("APP_GORSE_ADDRESS", cfg.Gorse.Address) - cfg.Gorse.APIKey = getEnvOrDefault("APP_GORSE_API_KEY", cfg.Gorse.APIKey) - cfg.Gorse.Dashboard = getEnvOrDefault("APP_GORSE_DASHBOARD", cfg.Gorse.Dashboard) - cfg.Gorse.ImportPassword = getEnvOrDefault("APP_GORSE_IMPORT_PASSWORD", cfg.Gorse.ImportPassword) - cfg.Gorse.EmbeddingAPIKey = getEnvOrDefault("APP_GORSE_EMBEDDING_API_KEY", cfg.Gorse.EmbeddingAPIKey) - cfg.Gorse.EmbeddingURL = getEnvOrDefault("APP_GORSE_EMBEDDING_URL", cfg.Gorse.EmbeddingURL) - cfg.Gorse.EmbeddingModel = getEnvOrDefault("APP_GORSE_EMBEDDING_MODEL", cfg.Gorse.EmbeddingModel) cfg.OpenAI.BaseURL = getEnvOrDefault("APP_OPENAI_BASE_URL", cfg.OpenAI.BaseURL) cfg.OpenAI.APIKey = getEnvOrDefault("APP_OPENAI_API_KEY", cfg.OpenAI.APIKey) cfg.OpenAI.ModerationModel = getEnvOrDefault("APP_OPENAI_MODERATION_MODEL", cfg.OpenAI.ModerationModel) diff --git a/internal/config/external.go b/internal/config/external.go index 8866f6f..fb16378 100644 --- a/internal/config/external.go +++ b/internal/config/external.go @@ -1,15 +1,17 @@ package config -// GorseConfig Gorse 推荐系统配置 -type GorseConfig struct { - Address string `mapstructure:"address"` - APIKey string `mapstructure:"api_key"` - Enabled bool `mapstructure:"enabled"` - Dashboard string `mapstructure:"dashboard"` - ImportPassword string `mapstructure:"import_password"` - EmbeddingAPIKey string `mapstructure:"embedding_api_key"` - EmbeddingURL string `mapstructure:"embedding_url"` - EmbeddingModel string `mapstructure:"embedding_model"` +// HotRankConfig 热门榜定时重算(仅 Redis ZSET + 分层缓存中的 Top ID 列表,不写库) +type HotRankConfig struct { + Enabled bool `mapstructure:"enabled"` + RefreshIntervalSeconds int `mapstructure:"refresh_interval_seconds"` + // TopN 写入缓存的热门条数(ZSET 与 top_ids 列表长度上限) + TopN int `mapstructure:"top_n"` + // RecentWindowHours 候选池:该时间窗内新发的帖子 ID 会参与本轮重算 + RecentWindowHours int `mapstructure:"recent_window_hours"` + // RecentFetchLimit 时间窗内最多拉取的帖子 ID 数(按 created_at 倒序) + RecentFetchLimit int `mapstructure:"recent_fetch_limit"` + // CandidateCap 候选 ID 合并后的上限(上届 Top + 新帖去重后截断) + CandidateCap int `mapstructure:"candidate_cap"` } // OpenAIConfig OpenAI 配置 diff --git a/internal/dto/dto.go b/internal/dto/dto.go index 2293f0d..ab3de99 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -940,7 +940,6 @@ type AdminPostDetailResponse struct { FavoritesCount int `json:"favorites_count,omitzero"` SharesCount int `json:"shares_count,omitzero"` ViewsCount int `json:"views_count,omitzero"` - HotScore float64 `json:"hot_score"` IsPinned bool `json:"is_pinned"` IsFeatured bool `json:"is_featured"` IsLocked bool `json:"is_locked"` diff --git a/internal/handler/gorse_handler.go b/internal/handler/gorse_handler.go deleted file mode 100644 index bfb6708..0000000 --- a/internal/handler/gorse_handler.go +++ /dev/null @@ -1,255 +0,0 @@ -package handler - -import ( - "context" - "strings" - "time" - - "carrot_bbs/internal/config" - "carrot_bbs/internal/model" - "carrot_bbs/internal/pkg/gorse" - "carrot_bbs/internal/pkg/response" - - "github.com/gin-gonic/gin" - gorseio "github.com/gorse-io/gorse-go" - "go.uber.org/zap" - "gorm.io/gorm" -) - -// GorseHandler Gorse推荐处理器 -type GorseHandler struct { - importPassword string - gorseConfig config.GorseConfig - db *gorm.DB -} - -// NewGorseHandler 创建Gorse处理器 -func NewGorseHandler(cfg config.GorseConfig, db *gorm.DB) *GorseHandler { - return &GorseHandler{ - importPassword: cfg.ImportPassword, - gorseConfig: cfg, - db: db, - } -} - -// ImportRequest 导入请求 -type ImportRequest struct { - Password string `json:"password"` -} - -// ImportData 导入数据到Gorse -// POST /api/v1/gorse/import -func (h *GorseHandler) ImportData(c *gin.Context) { - // 验证密码 - if h.importPassword == "" { - response.BadRequest(c, "Gorse import is disabled") - return - } - - var req ImportRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "invalid request body") - return - } - - if req.Password != h.importPassword { - response.Unauthorized(c, "invalid password") - return - } - - ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute) - defer cancel() - - stats, err := h.importAllData(ctx) - if err != nil { - zap.L().Error("gorse import failed", zap.Error(err)) - response.InternalServerError(c, "gorse import failed: "+err.Error()) - return - } - - response.Success(c, gin.H{ - "message": "import completed", - "status": "done", - "stats": stats, - }) -} - -// GetStatus 获取Gorse状态 -// GET /api/v1/gorse/status -func (h *GorseHandler) GetStatus(c *gin.Context) { - hasPassword := h.importPassword != "" - response.Success(c, gin.H{ - "enabled": h.gorseConfig.Enabled, - "has_password": hasPassword, - }) -} - -func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) { - gorseClient := gorseio.NewGorseClient(h.gorseConfig.Address, h.gorseConfig.APIKey) - gorse.InitEmbeddingWithConfig(h.gorseConfig.EmbeddingAPIKey, h.gorseConfig.EmbeddingURL, h.gorseConfig.EmbeddingModel) - - stats := gin.H{ - "items": 0, - "users": 0, - "likes": 0, - "favorites": 0, - "comments": 0, - } - - // 导入帖子 - var posts []model.Post - if err := h.db.Find(&posts).Error; err != nil { - return nil, err - } - for _, post := range posts { - embedding, err := gorse.GetEmbedding(strings.TrimSpace(post.Title + " " + post.Content)) - if err != nil { - zap.L().Warn("get embedding failed for post", - zap.String("postID", post.ID), - zap.Error(err), - ) - embedding = make([]float64, 1024) - } - _, err = gorseClient.InsertItem(ctx, gorseio.Item{ - ItemId: post.ID, - IsHidden: post.DeletedAt.Valid, - Categories: buildPostCategories(&post), - Comment: post.Title, - Timestamp: post.CreatedAt.UTC().Truncate(time.Second), - Labels: map[string]any{ - "embedding": embedding, - }, - }) - if err != nil { - zap.L().Warn("import item failed", - zap.String("postID", post.ID), - zap.Error(err), - ) - continue - } - stats["items"] = stats["items"].(int) + 1 - } - - // 导入用户 - var users []model.User - if err := h.db.Find(&users).Error; err != nil { - return nil, err - } - for _, user := range users { - _, err := gorseClient.InsertUser(ctx, gorseio.User{ - UserId: user.ID, - Labels: map[string]any{ - "posts_count": user.PostsCount, - "followers_count": user.FollowersCount, - "following_count": user.FollowingCount, - }, - Comment: user.Nickname, - }) - if err != nil { - zap.L().Warn("import user failed", - zap.String("userID", user.ID), - zap.Error(err), - ) - continue - } - stats["users"] = stats["users"].(int) + 1 - } - - // 导入点赞 - var likes []model.PostLike - if err := h.db.Find(&likes).Error; err != nil { - return nil, err - } - for _, like := range likes { - _, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{ - FeedbackType: string(gorse.FeedbackTypeLike), - UserId: like.UserID, - ItemId: like.PostID, - Timestamp: like.CreatedAt.UTC().Truncate(time.Second), - }}) - if err != nil { - zap.L().Warn("import like failed", - zap.String("userID", like.UserID), - zap.String("postID", like.PostID), - zap.Error(err), - ) - continue - } - stats["likes"] = stats["likes"].(int) + 1 - } - - // 导入收藏 - var favorites []model.Favorite - if err := h.db.Find(&favorites).Error; err != nil { - return nil, err - } - for _, fav := range favorites { - _, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{ - FeedbackType: string(gorse.FeedbackTypeStar), - UserId: fav.UserID, - ItemId: fav.PostID, - Timestamp: fav.CreatedAt.UTC().Truncate(time.Second), - }}) - if err != nil { - zap.L().Warn("import favorite failed", - zap.String("userID", fav.UserID), - zap.String("postID", fav.PostID), - zap.Error(err), - ) - continue - } - stats["favorites"] = stats["favorites"].(int) + 1 - } - - // 导入评论(按用户-帖子去重) - var comments []model.Comment - if err := h.db.Where("status = ?", model.CommentStatusPublished).Find(&comments).Error; err != nil { - return nil, err - } - seen := make(map[string]struct{}) - for _, cm := range comments { - key := cm.UserID + ":" + cm.PostID - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - _, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{ - FeedbackType: string(gorse.FeedbackTypeComment), - UserId: cm.UserID, - ItemId: cm.PostID, - Timestamp: cm.CreatedAt.UTC().Truncate(time.Second), - }}) - if err != nil { - zap.L().Warn("import comment failed", - zap.String("userID", cm.UserID), - zap.String("postID", cm.PostID), - zap.Error(err), - ) - continue - } - stats["comments"] = stats["comments"].(int) + 1 - } - - return stats, nil -} - -func buildPostCategories(post *model.Post) []string { - var categories []string - if post.ViewsCount > 1000 { - categories = append(categories, "hot_high") - } else if post.ViewsCount > 100 { - categories = append(categories, "hot_medium") - } - if post.LikesCount > 100 { - categories = append(categories, "likes_100+") - } else if post.LikesCount > 10 { - categories = append(categories, "likes_10+") - } - age := time.Since(post.CreatedAt) - if age < 24*time.Hour { - categories = append(categories, "today") - } else if age < 7*24*time.Hour { - categories = append(categories, "this_week") - } - return categories -} diff --git a/internal/handler/post_handler.go b/internal/handler/post_handler.go index 7662b79..4249e5b 100644 --- a/internal/handler/post_handler.go +++ b/internal/handler/post_handler.go @@ -181,9 +181,6 @@ func (h *PostHandler) List(c *gin.Context) { case "hot": // 获取热门帖子 posts, total, err = h.postService.GetHotPosts(c.Request.Context(), page, pageSize) - case "recommend": - // 推荐帖子(从Gorse获取个性化推荐) - posts, total, err = h.postService.GetRecommendedPosts(c.Request.Context(), currentUserID, page, pageSize) case "latest": // 最新帖子 if userID != "" && userID == currentUserID { @@ -245,9 +242,6 @@ func (h *PostHandler) ListByCursor(c *gin.Context) { case "hot": // 获取热门帖子 result, err = h.postService.GetHotPostsByCursor(c.Request.Context(), req) - case "recommend": - // 推荐帖子(从Gorse获取个性化推荐) - result, err = h.postService.GetRecommendedPostsByCursor(c.Request.Context(), currentUserID, req) case "latest": // 最新帖子 result, err = h.postService.ListByCursor(c.Request.Context(), userID, includePending, req) diff --git a/internal/model/init.go b/internal/model/init.go index 2a4c9c5..d343900 100644 --- a/internal/model/init.go +++ b/internal/model/init.go @@ -163,6 +163,11 @@ func autoMigrate(db *gorm.DB) error { return err } + // AutoMigrate 不会删除列:清理已废弃的 posts.hot_score + if err := dropPostsHotScoreColumnIfExists(db); err != nil { + return fmt.Errorf("drop legacy posts.hot_score: %w", err) + } + // 初始化角色种子数据 if err := seedRoles(db); err != nil { return fmt.Errorf("failed to seed roles: %w", err) @@ -176,6 +181,24 @@ func autoMigrate(db *gorm.DB) error { return nil } +// postWithHotScore 仅用于 Migrator 检测/删除旧列(Post 模型已移除 HotScore) +type postWithHotScore struct { + HotScore float64 `gorm:"column:hot_score"` +} + +func (postWithHotScore) TableName() string { return "posts" } + +func dropPostsHotScoreColumnIfExists(db *gorm.DB) error { + m := db.Migrator() + if !m.HasTable(&Post{}) { + return nil + } + if !m.HasColumn(&postWithHotScore{}, "HotScore") { + return nil + } + return m.DropColumn(&postWithHotScore{}, "HotScore") +} + // seedRoles 初始化角色种子数据 func seedRoles(db *gorm.DB) error { // 检查是否已有角色数据 diff --git a/internal/model/post.go b/internal/model/post.go index 22fd82d..d95adb5 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -43,7 +43,6 @@ type Post struct { FavoritesCount int `json:"favorites_count" gorm:"column:favorites_count;default:0"` SharesCount int `json:"shares_count" gorm:"column:shares_count;default:0"` ViewsCount int `json:"views_count" gorm:"column:views_count;default:0"` - HotScore float64 `json:"hot_score" gorm:"column:hot_score;default:0;index:idx_posts_hot_score_created,priority:1"` // 置顶/锁定 IsPinned bool `json:"is_pinned" gorm:"default:false"` @@ -58,7 +57,7 @@ type Post struct { DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 时间戳 - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_posts_status_created,priority:2,sort:desc;index:idx_posts_user_status_created,priority:3,sort:desc;index:idx_posts_hot_score_created,priority:2,sort:desc"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_posts_status_created,priority:2,sort:desc;index:idx_posts_user_status_created,priority:3,sort:desc"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime:false"` // ContentEditedAt 仅在实际修改标题/正文/图片时更新,供前端展示「已编辑」;与统计类更新解耦 ContentEditedAt *time.Time `json:"content_edited_at,omitempty" gorm:"column:content_edited_at"` diff --git a/internal/pkg/gorse/client.go b/internal/pkg/gorse/client.go deleted file mode 100644 index 0e2fb8e..0000000 --- a/internal/pkg/gorse/client.go +++ /dev/null @@ -1,290 +0,0 @@ -package gorse - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "time" - - gorseio "github.com/gorse-io/gorse-go" - "go.uber.org/zap" -) - -// FeedbackType 反馈类型 -type FeedbackType string - -const ( - FeedbackTypeLike FeedbackType = "like" // 点赞 - FeedbackTypeStar FeedbackType = "star" // 收藏 - FeedbackTypeComment FeedbackType = "comment" // 评论 - FeedbackTypeRead FeedbackType = "read" // 浏览 -) - -// Score 非个性化推荐返回的评分项 -type Score struct { - Id string `json:"Id"` - Score float64 `json:"Score"` -} - -// Client Gorse客户端接口 -type Client interface { - // InsertFeedback 插入用户反馈 - InsertFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error - // DeleteFeedback 删除用户反馈 - DeleteFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error - // GetRecommend 获取个性化推荐列表 - GetRecommend(ctx context.Context, userID string, n int, offset int) ([]string, error) - // GetNonPersonalized 获取非个性化推荐(通过名称) - GetNonPersonalized(ctx context.Context, name string, n int, offset int, userID string) ([]string, error) - // UpsertItem 插入或更新物品(无embedding) - UpsertItem(ctx context.Context, itemID string, categories []string, comment string) error - // UpsertItemWithEmbedding 插入或更新物品(带embedding) - UpsertItemWithEmbedding(ctx context.Context, itemID string, categories []string, comment string, textToEmbed string) error - // DeleteItem 删除物品 - DeleteItem(ctx context.Context, itemID string) error - // UpsertUser 插入或更新用户 - UpsertUser(ctx context.Context, userID string, labels map[string]any) error - // IsEnabled 检查是否启用 - IsEnabled() bool -} - -// client Gorse客户端实现 -type client struct { - config Config - gorse *gorseio.GorseClient - httpClient *http.Client -} - -// NewClient 创建新的Gorse客户端 -func NewClient(cfg Config) Client { - if !cfg.Enabled { - return &noopClient{} - } - - gorse := gorseio.NewGorseClient(cfg.Address, cfg.APIKey) - return &client{ - config: cfg, - gorse: gorse, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -// IsEnabled 检查是否启用 -func (c *client) IsEnabled() bool { - return c.config.Enabled -} - -// InsertFeedback 插入用户反馈 -func (c *client) InsertFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error { - if !c.config.Enabled { - return nil - } - - _, err := c.gorse.InsertFeedback(ctx, []gorseio.Feedback{ - { - FeedbackType: string(feedbackType), - UserId: userID, - ItemId: itemID, - Timestamp: time.Now().UTC().Truncate(time.Second), - }, - }) - return err -} - -// DeleteFeedback 删除用户反馈 -func (c *client) DeleteFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error { - if !c.config.Enabled { - return nil - } - - _, err := c.gorse.DeleteFeedback(ctx, string(feedbackType), userID, itemID) - return err -} - -// GetRecommend 获取个性化推荐列表 -func (c *client) GetRecommend(ctx context.Context, userID string, n int, offset int) ([]string, error) { - if !c.config.Enabled { - return nil, nil - } - - result, err := c.gorse.GetRecommend(ctx, userID, "", n, offset) - if err != nil { - return nil, err - } - - return result, nil -} - -// GetNonPersonalized 获取非个性化推荐 -// name: 推荐器名称,如 "most_liked_weekly" -// n: 返回数量 -// offset: 偏移量 -// userID: 可选,用于排除用户已读物品 -func (c *client) GetNonPersonalized(ctx context.Context, name string, n int, offset int, userID string) ([]string, error) { - if !c.config.Enabled { - return nil, nil - } - - // 构建URL - url := fmt.Sprintf("%s/api/non-personalized/%s?n=%d&offset=%d", c.config.Address, name, n, offset) - if userID != "" { - url += fmt.Sprintf("&user-id=%s", userID) - } - - // 创建请求 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // 设置API Key - if c.config.APIKey != "" { - req.Header.Set("X-API-Key", c.config.APIKey) - } - - // 发送请求 - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - // 读取响应 - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("gorse api error: status=%d, body=%s", resp.StatusCode, string(body)) - } - - // 解析响应 - var scores []Score - if err := json.Unmarshal(body, &scores); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - // 提取ID - ids := make([]string, len(scores)) - for i, score := range scores { - ids[i] = score.Id - } - - return ids, nil -} - -// UpsertItem 插入或更新物品 -func (c *client) UpsertItem(ctx context.Context, itemID string, categories []string, comment string) error { - if !c.config.Enabled { - return nil - } - - _, err := c.gorse.InsertItem(ctx, gorseio.Item{ - ItemId: itemID, - IsHidden: false, - Categories: categories, - Comment: comment, - Timestamp: time.Now().UTC().Truncate(time.Second), - }) - return err -} - -// UpsertItemWithEmbedding 插入或更新物品(带embedding) -func (c *client) UpsertItemWithEmbedding(ctx context.Context, itemID string, categories []string, comment string, textToEmbed string) error { - if !c.config.Enabled { - return nil - } - - // 生成embedding - var embedding []float64 - if textToEmbed != "" { - var err error - embedding, err = GetEmbedding(textToEmbed) - if err != nil { - zap.L().Warn("Failed to get embedding for item, using zero vector", - zap.String("itemID", itemID), - zap.Error(err), - ) - embedding = make([]float64, 1024) - } - } else { - embedding = make([]float64, 1024) - } - - _, err := c.gorse.InsertItem(ctx, gorseio.Item{ - ItemId: itemID, - IsHidden: false, - Categories: categories, - Comment: comment, - Timestamp: time.Now().UTC().Truncate(time.Second), - Labels: map[string]any{ - "embedding": embedding, - }, - }) - return err -} - -// DeleteItem 删除物品 -func (c *client) DeleteItem(ctx context.Context, itemID string) error { - if !c.config.Enabled { - return nil - } - - _, err := c.gorse.DeleteItem(ctx, itemID) - return err -} - -// UpsertUser 插入或更新用户 -func (c *client) UpsertUser(ctx context.Context, userID string, labels map[string]any) error { - if !c.config.Enabled { - return nil - } - - _, err := c.gorse.InsertUser(ctx, gorseio.User{ - UserId: userID, - Labels: labels, - }) - return err -} - -// noopClient 空操作客户端(用于未启用推荐功能时) -type noopClient struct{} - -func (c *noopClient) IsEnabled() bool { return false } -func (c *noopClient) InsertFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error { - return nil -} -func (c *noopClient) DeleteFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error { - return nil -} -func (c *noopClient) GetRecommend(ctx context.Context, userID string, n int, offset int) ([]string, error) { - return nil, nil -} -func (c *noopClient) GetNonPersonalized(ctx context.Context, name string, n int, offset int, userID string) ([]string, error) { - return nil, nil -} -func (c *noopClient) UpsertItem(ctx context.Context, itemID string, categories []string, comment string) error { - return nil -} -func (c *noopClient) UpsertItemWithEmbedding(ctx context.Context, itemID string, categories []string, comment string, textToEmbed string) error { - return nil -} -func (c *noopClient) DeleteItem(ctx context.Context, itemID string) error { return nil } -func (c *noopClient) UpsertUser(ctx context.Context, userID string, labels map[string]any) error { - return nil -} - -// 确保实现了接口 -var _ Client = (*client)(nil) -var _ Client = (*noopClient)(nil) - -// log 用于内部日志 -func init() { - log.SetFlags(log.LstdFlags | log.Lshortfile) -} diff --git a/internal/pkg/gorse/config.go b/internal/pkg/gorse/config.go deleted file mode 100644 index 9d3329a..0000000 --- a/internal/pkg/gorse/config.go +++ /dev/null @@ -1,23 +0,0 @@ -package gorse - -import ( - "carrot_bbs/internal/config" -) - -// Config Gorse客户端配置(从config.GorseConfig转换) -type Config struct { - Address string - APIKey string - Enabled bool - Dashboard string -} - -// ConfigFromAppConfig 从应用配置创建Gorse配置 -func ConfigFromAppConfig(cfg *config.GorseConfig) Config { - return Config{ - Address: cfg.Address, - APIKey: cfg.APIKey, - Enabled: cfg.Enabled, - Dashboard: cfg.Dashboard, - } -} diff --git a/internal/pkg/gorse/embedding.go b/internal/pkg/gorse/embedding.go deleted file mode 100644 index eab0941..0000000 --- a/internal/pkg/gorse/embedding.go +++ /dev/null @@ -1,107 +0,0 @@ -package gorse - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "go.uber.org/zap" -) - -// EmbeddingConfig embedding服务配置 -type EmbeddingConfig struct { - APIKey string - URL string - Model string -} - -var defaultEmbeddingConfig = EmbeddingConfig{ - APIKey: "sk-ZPN5NMPSqEaOGCPfD2LqndZ5Wwmw3DC4CQgzgKhM35fI3RpD", - URL: "https://api.littlelan.cn/v1/embeddings", - Model: "BAAI/bge-m3", -} - -// SetEmbeddingConfig 设置embedding配置 -func SetEmbeddingConfig(apiKey, url, model string) { - if apiKey != "" { - defaultEmbeddingConfig.APIKey = apiKey - } - if url != "" { - defaultEmbeddingConfig.URL = url - } - if model != "" { - defaultEmbeddingConfig.Model = model - } -} - -// GetEmbedding 获取文本的embedding -func GetEmbedding(text string) ([]float64, error) { - type embeddingRequest struct { - Input string `json:"input"` - Model string `json:"model"` - } - - type embeddingResponse struct { - Data []struct { - Embedding []float64 `json:"embedding"` - } `json:"data"` - } - - reqBody := embeddingRequest{ - Input: text, - Model: defaultEmbeddingConfig.Model, - } - - jsonData, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequest("POST", defaultEmbeddingConfig.URL, bytes.NewReader(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+defaultEmbeddingConfig.APIKey) - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("embedding API error: status=%d, body=%s", resp.StatusCode, string(body)) - } - - var result embeddingResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - if len(result.Data) == 0 { - return nil, fmt.Errorf("no embedding returned") - } - - return result.Data[0].Embedding, nil -} - -// InitEmbeddingWithConfig 从应用配置初始化embedding -func InitEmbeddingWithConfig(apiKey, url, model string) { - if apiKey == "" { - zap.L().Warn("Gorse embedding API key not set, using default") - } - defaultEmbeddingConfig.APIKey = apiKey - if url != "" { - defaultEmbeddingConfig.URL = url - } - if model != "" { - defaultEmbeddingConfig.Model = model - } -} diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go index 286d693..1f9af9f 100644 --- a/internal/repository/comment_repo.go +++ b/internal/repository/comment_repo.go @@ -72,7 +72,6 @@ func (r *CommentRepository) Delete(id string) error { if err := tx.Model(&model.Post{}).Where("id = ?", comment.PostID). UpdateColumns(map[string]interface{}{ "comments_count": gorm.Expr("comments_count - 1"), - "hot_score": gorm.Expr("likes_count * 2 + (comments_count - 1) * 3 + views_count * 0.1"), "updated_at": gorm.Expr("updated_at"), }).Error; err != nil { return err @@ -102,7 +101,6 @@ func (r *CommentRepository) ApplyPublishedStats(comment *model.Comment) error { if err := tx.Model(&model.Post{}).Where("id = ?", comment.PostID). UpdateColumns(map[string]any{ "comments_count": gorm.Expr("comments_count + 1"), - "hot_score": gorm.Expr("likes_count * 2 + (comments_count + 1) * 3 + views_count * 0.1"), "updated_at": gorm.Expr("updated_at"), }).Error; err != nil { return err diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index ac090a3..23743ec 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -1,12 +1,13 @@ package repository import ( - "carrot_bbs/internal/model" - "carrot_bbs/internal/pkg/cursor" "context" "strings" "time" + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/cursor" + "gorm.io/gorm" ) @@ -258,12 +259,10 @@ func (r *PostRepository) Like(postID, userID string) error { return err } - // 增加帖子点赞数并同步热度分 - // 点赞属于统计字段更新,不应影响帖子内容更新时间(updated_at) + // 增加帖子点赞数(热门排序由定时任务写 Redis ZSET / top_ids,不写库) return tx.Model(&model.Post{}).Where("id = ?", postID). UpdateColumns(map[string]any{ "likes_count": gorm.Expr("likes_count + 1"), - "hot_score": gorm.Expr("(likes_count + 1) * 2 + comments_count * 3 + views_count * 0.1"), "updated_at": gorm.Expr("updated_at"), }).Error }) @@ -277,12 +276,9 @@ func (r *PostRepository) Unlike(postID, userID string) error { return result.Error } if result.RowsAffected > 0 { - // 减少帖子点赞数并同步热度分 - // 取消点赞属于统计字段更新,不应影响帖子内容更新时间(updated_at) return tx.Model(&model.Post{}).Where("id = ?", postID). UpdateColumns(map[string]any{ "likes_count": gorm.Expr("likes_count - 1"), - "hot_score": gorm.Expr("(likes_count - 1) * 2 + comments_count * 3 + views_count * 0.1"), "updated_at": gorm.Expr("updated_at"), }).Error } @@ -350,7 +346,7 @@ func (r *PostRepository) Favorite(postID, userID string) error { return tx.Model(&model.Post{}).Where("id = ?", postID). UpdateColumns(map[string]any{ "favorites_count": gorm.Expr("favorites_count + 1"), - "updated_at": gorm.Expr("updated_at"), + "updated_at": gorm.Expr("updated_at"), }).Error }) } @@ -367,7 +363,7 @@ func (r *PostRepository) Unfavorite(postID, userID string) error { return tx.Model(&model.Post{}).Where("id = ?", postID). UpdateColumns(map[string]any{ "favorites_count": gorm.Expr("favorites_count - 1"), - "updated_at": gorm.Expr("updated_at"), + "updated_at": gorm.Expr("updated_at"), }).Error } return nil @@ -411,10 +407,8 @@ func (r *PostRepository) IsFavoritedBatch(postIDs []string, userID string) map[s // IncrementViews 增加帖子观看量 func (r *PostRepository) IncrementViews(postID string) error { return r.db.Model(&model.Post{}).Where("id = ?", postID). - // 浏览量属于统计字段,不应影响帖子内容更新时间(updated_at) UpdateColumns(map[string]interface{}{ "views_count": gorm.Expr("views_count + 1"), - "hot_score": gorm.Expr("likes_count * 2 + comments_count * 3 + (views_count + 1) * 0.1"), "updated_at": gorm.Expr("updated_at"), }).Error } @@ -469,7 +463,7 @@ func (r *PostRepository) GetFollowingPosts(userID string, page, pageSize int) ([ return posts, total, err } -// GetHotPosts 获取热门帖子(按点赞数和评论数排序) +// GetHotPosts 热门榜降级:Redis 未就绪时按最新发布排序 func (r *PostRepository) GetHotPosts(page, pageSize int) ([]*model.Post, int64, error) { var posts []*model.Post var total int64 @@ -477,15 +471,101 @@ func (r *PostRepository) GetHotPosts(page, pageSize int) ([]*model.Post, int64, r.db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished).Count(&total) offset := (page - 1) * pageSize - // 热门排序使用预计算热度分,避免每次请求进行表达式排序计算 err := r.db.Where("status = ?", model.PostStatusPublished).Preload("User").Preload("Images"). Offset(offset).Limit(pageSize). - Order("hot_score DESC, created_at DESC"). + Order("created_at DESC"). Find(&posts).Error return posts, total, err } +// PostHotSnapshot 热门重算用字段 +type PostHotSnapshot struct { + ID string `gorm:"column:id"` + LikesCount int `gorm:"column:likes_count"` + CommentsCount int `gorm:"column:comments_count"` + FavoritesCount int `gorm:"column:favorites_count"` + SharesCount int `gorm:"column:shares_count"` + ViewsCount int `gorm:"column:views_count"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +// ListPublishedPostIDsSince 按发布时间倒序取 ID(热门候选池,避免全表扫快照) +func (r *PostRepository) ListPublishedPostIDsSince(since time.Time, limit int) ([]string, error) { + if limit <= 0 { + return nil, nil + } + type row struct { + ID string `gorm:"column:id"` + } + var rows []row + err := r.db.Model(&model.Post{}). + Select("id"). + Where("status = ? AND created_at >= ?", model.PostStatusPublished, since). + Order("created_at DESC"). + Limit(limit). + Find(&rows).Error + if err != nil { + return nil, err + } + out := make([]string, 0, len(rows)) + for _, x := range rows { + if x.ID != "" { + out = append(out, x.ID) + } + } + return out, nil +} + +// ListPinnedPublishedPostIDs 已发布且置顶的帖子 ID(越新发布的置顶越靠前) +func (r *PostRepository) ListPinnedPublishedPostIDs() ([]string, error) { + type row struct { + ID string `gorm:"column:id"` + } + var rows []row + err := r.db.Model(&model.Post{}). + Select("id"). + Where("status = ? AND is_pinned = ?", model.PostStatusPublished, true). + Order("created_at DESC"). + Find(&rows).Error + if err != nil { + return nil, err + } + out := make([]string, 0, len(rows)) + for _, x := range rows { + if x.ID != "" { + out = append(out, x.ID) + } + } + return out, nil +} + +// GetPostHotSnapshotsByIDs 仅拉取候选 ID 对应的热度字段(分批 IN 查询) +func (r *PostRepository) GetPostHotSnapshotsByIDs(ids []string) ([]PostHotSnapshot, error) { + if len(ids) == 0 { + return nil, nil + } + const chunkSize = 400 + var all []PostHotSnapshot + for i := 0; i < len(ids); i += chunkSize { + end := i + chunkSize + if end > len(ids) { + end = len(ids) + } + batch := ids[i:end] + var rows []PostHotSnapshot + err := r.db.Model(&model.Post{}). + Select("id", "likes_count", "comments_count", "favorites_count", "shares_count", "views_count", "created_at"). + Where("status = ? AND id IN ?", model.PostStatusPublished, batch). + Find(&rows).Error + if err != nil { + return nil, err + } + all = append(all, rows...) + } + return all, nil +} + // GetByIDs 根据ID列表获取帖子(保持传入顺序) func (r *PostRepository) GetByIDs(ids []string) ([]*model.Post, error) { if len(ids) == 0 { @@ -995,9 +1075,9 @@ func (r *PostRepository) GetFollowingPostsByCursor(ctx context.Context, userID s func (r *PostRepository) GetHotPostsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) { db := r.getDB(ctx).WithContext(ctx) - // 构建基础查询 - 热门帖子按热度分数排序 + // 热门游标降级:与 GetHotPosts 一致,按最新发布排序 query := db.Model(&model.Post{}).Where("status = ?", model.PostStatusPublished). - Order("hot_score DESC, created_at DESC") + Order("created_at DESC") // 使用游标构建器 builder := cursor.NewBuilder(query, cursor.SortByCreatedAtDesc). diff --git a/internal/router/router.go b/internal/router/router.go index 7611a33..103a13c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -22,7 +22,6 @@ type Router struct { systemMessageHandler *handler.SystemMessageHandler groupHandler *handler.GroupHandler stickerHandler *handler.StickerHandler - gorseHandler *handler.GorseHandler voteHandler *handler.VoteHandler scheduleHandler *handler.ScheduleHandler roleHandler *handler.RoleHandler @@ -51,7 +50,6 @@ func New( systemMessageHandler *handler.SystemMessageHandler, groupHandler *handler.GroupHandler, stickerHandler *handler.StickerHandler, - gorseHandler *handler.GorseHandler, voteHandler *handler.VoteHandler, scheduleHandler *handler.ScheduleHandler, roleHandler *handler.RoleHandler, @@ -83,7 +81,6 @@ func New( systemMessageHandler: systemMessageHandler, groupHandler: groupHandler, stickerHandler: stickerHandler, - gorseHandler: gorseHandler, voteHandler: voteHandler, scheduleHandler: scheduleHandler, roleHandler: roleHandler, @@ -415,17 +412,6 @@ func (r *Router) setupRoutes() { } } - // Gorse 管理路由(需要管理员权限) - if r.gorseHandler != nil { - gorseGroup := v1.Group("/gorse") - gorseGroup.Use(authMiddleware) - gorseGroup.Use(middleware.RequireRole(r.casbinService, model.RoleAdmin, model.RoleSuperAdmin)) - { - gorseGroup.GET("/status", r.gorseHandler.GetStatus) - gorseGroup.POST("/import", r.gorseHandler.ImportData) - } - } - // 管理路由(需要管理员权限) if r.roleHandler != nil { admin := v1.Group("/admin") diff --git a/internal/service/admin_post_service.go b/internal/service/admin_post_service.go index df4e96e..109f4e1 100644 --- a/internal/service/admin_post_service.go +++ b/internal/service/admin_post_service.go @@ -332,7 +332,6 @@ func convertPostToAdminDetailResponse(post *model.Post) *dto.AdminPostDetailResp FavoritesCount: post.FavoritesCount, SharesCount: post.SharesCount, ViewsCount: post.ViewsCount, - HotScore: post.HotScore, IsPinned: post.IsPinned, IsFeatured: post.IsFeatured, IsLocked: post.IsLocked, diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index 4021cbe..2417d9e 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -8,7 +8,6 @@ import ( "carrot_bbs/internal/cache" "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/cursor" - "carrot_bbs/internal/pkg/gorse" "carrot_bbs/internal/pkg/hook" "carrot_bbs/internal/repository" @@ -20,19 +19,17 @@ type CommentService struct { postRepo *repository.PostRepository systemMessageService SystemMessageService cache cache.Cache - gorseClient gorse.Client postAIService *PostAIService logService *LogService hookManager *hook.Manager } -func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, hookManager *hook.Manager) *CommentService { +func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService SystemMessageService, postAIService *PostAIService, cacheBackend cache.Cache, hookManager *hook.Manager) *CommentService { return &CommentService{ commentRepo: commentRepo, postRepo: postRepo, systemMessageService: systemMessageService, cache: cacheBackend, - gorseClient: gorseClient, postAIService: postAIService, logService: nil, hookManager: hookManager, @@ -225,16 +222,6 @@ func (s *CommentService) afterCommentPublished(userID, postID, commentID string, }() } - // 推送评论行为到Gorse(异步) - go func() { - if s.gorseClient.IsEnabled() { - if err := s.gorseClient.InsertFeedback(context.Background(), gorse.FeedbackTypeComment, userID, postID); err != nil { - zap.L().Warn("Failed to insert comment feedback to Gorse", - zap.Error(err), - ) - } - } - }() } func (s *CommentService) notifyCommentModerationRejected(userID, reason string) { diff --git a/internal/service/hot_rank_worker.go b/internal/service/hot_rank_worker.go new file mode 100644 index 0000000..f64e573 --- /dev/null +++ b/internal/service/hot_rank_worker.go @@ -0,0 +1,335 @@ +package service + +import ( + "context" + "errors" + "math" + "sort" + "sync" + "time" + + "carrot_bbs/internal/cache" + "carrot_bbs/internal/config" + "carrot_bbs/internal/repository" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +// HotRankWorker 定时在候选池上重算热门分,写入 Redis ZSET(仅 TopN)及 top_ids(Redis+本地缓存) +type HotRankWorker struct { + cfg *config.Config + postRepo *repository.PostRepository + c cache.Cache + mu sync.Mutex + stopOnce sync.Once + stopCh chan struct{} + doneCh chan struct{} +} + +// NewHotRankWorker 创建热门榜重算 worker(cache 需为 Redis 实现才有意义) +func NewHotRankWorker(cfg *config.Config, postRepo *repository.PostRepository, c cache.Cache) *HotRankWorker { + return &HotRankWorker{ + cfg: cfg, + postRepo: postRepo, + c: c, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +// Start 启动定时任务(启动时立即执行一次) +func (w *HotRankWorker) Start(ctx context.Context) { + if w == nil || w.cfg == nil || !w.cfg.HotRank.Enabled || w.c == nil { + close(w.doneCh) + return + } + interval := time.Duration(w.cfg.HotRank.RefreshIntervalSeconds) * time.Second + if interval < 30*time.Second { + interval = 30 * time.Second + } + + go func() { + defer close(w.doneCh) + + if err := w.Refresh(ctx); err != nil { + if !errors.Is(err, cache.ErrKeyNotFound) { + zap.L().Warn("hot rank initial refresh failed", zap.Error(err)) + } + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-w.stopCh: + return + case <-ticker.C: + if err := w.Refresh(ctx); err != nil { + if !errors.Is(err, cache.ErrKeyNotFound) { + zap.L().Warn("hot rank refresh failed", zap.Error(err)) + } + } + } + } + }() +} + +// Stop 停止 worker +func (w *HotRankWorker) Stop() { + if w == nil { + return + } + w.stopOnce.Do(func() { + close(w.stopCh) + }) + <-w.doneCh +} + +// Refresh 立即重算热门分(线程安全) +// 算法:候选 ID = 上一期 TopN(Redis)∪ 时间窗内新发帖子 ID,截断至 CandidateCap; +// 仅对候选拉取统计字段并打分,min-max 归一化;已发布置顶帖固定排在最前,其后接热门至 TopN; +// 写入 ZSET(置顶分数高于热门)及 top_ids。 +func (w *HotRankWorker) Refresh(ctx context.Context) error { + if w == nil || w.c == nil { + return cache.ErrKeyNotFound + } + + w.mu.Lock() + defer w.mu.Unlock() + + topN := w.cfg.HotRank.TopN + if topN <= 0 { + topN = 100 + } + if topN > 500 { + topN = 500 + } + recentHours := w.cfg.HotRank.RecentWindowHours + if recentHours <= 0 { + recentHours = 168 + } + recentLimit := w.cfg.HotRank.RecentFetchLimit + if recentLimit <= 0 { + recentLimit = 500 + } + candidateCap := w.cfg.HotRank.CandidateCap + if candidateCap <= 0 { + candidateCap = 800 + } + + zsetKey := cache.HotRankZSetKey() + topIDsKey := cache.HotRankTopIDsKey() + + pinned, err := w.postRepo.ListPinnedPublishedPostIDs() + if err != nil { + return err + } + pinned = dedupeIDsPreserveOrder(pinned) + + incumbent, _ := w.c.ZRevRange(ctx, zsetKey, 0, int64(topN-1)) + + since := time.Now().Add(-time.Duration(recentHours) * time.Hour) + recentIDs, err := w.postRepo.ListPublishedPostIDsSince(since, recentLimit) + if err != nil { + return err + } + + candidateIDs := mergeHotRankCandidates(incumbent, recentIDs, candidateCap) + if len(candidateIDs) == 0 && len(pinned) == 0 { + if err := w.c.ZReplaceSortedSet(ctx, zsetKey, nil); err != nil { + return err + } + w.c.Delete(topIDsKey) + return nil + } + + type scored struct { + idx int + norm float64 + } + + var snapshots []repository.PostHotSnapshot + var ranked []scored + if len(candidateIDs) > 0 { + snapshots, err = w.postRepo.GetPostHotSnapshotsByIDs(candidateIDs) + if err != nil { + return err + } + } + + if len(snapshots) > 0 { + now := time.Now() + raw := make([]float64, len(snapshots)) + for i := range snapshots { + p := &snapshots[i] + base := hotRankBaseScore(p.LikesCount, p.CommentsCount, p.FavoritesCount, p.SharesCount, p.ViewsCount) + tHours := now.Sub(p.CreatedAt).Hours() + if tHours < 0 { + tHours = 0 + } + denom := math.Pow(tHours+2.0, 0.5) + if denom < 1e-9 { + denom = 1e-9 + } + raw[i] = base / denom + } + + minR, maxR := raw[0], raw[0] + for _, v := range raw[1:] { + if v < minR { + minR = v + } + if v > maxR { + maxR = v + } + } + + ranked = make([]scored, len(snapshots)) + for i := range snapshots { + var norm float64 + if maxR > minR { + norm = (raw[i] - minR) / (maxR - minR) + } else { + norm = 1 + } + ranked[i] = scored{idx: i, norm: norm} + } + sort.Slice(ranked, func(a, b int) bool { + if ranked[a].norm != ranked[b].norm { + return ranked[a].norm > ranked[b].norm + } + return snapshots[ranked[a].idx].CreatedAt.After(snapshots[ranked[b].idx].CreatedAt) + }) + } + + pinnedSet := make(map[string]struct{}, len(pinned)) + for _, id := range pinned { + pinnedSet[id] = struct{}{} + } + + hotSlots := topN - len(pinned) + if hotSlots < 0 { + pinned = pinned[:topN] + pinnedSet = make(map[string]struct{}, len(pinned)) + for _, id := range pinned { + pinnedSet[id] = struct{}{} + } + hotSlots = 0 + } + + type hotPick struct { + id string + norm float64 + } + hotPicks := make([]hotPick, 0, hotSlots) + for _, r := range ranked { + id := snapshots[r.idx].ID + if _, isPin := pinnedSet[id]; isPin { + continue + } + hotPicks = append(hotPicks, hotPick{id: id, norm: r.norm}) + if len(hotPicks) >= hotSlots { + break + } + } + + orderedIDs := make([]string, 0, len(pinned)+len(hotPicks)) + orderedIDs = append(orderedIDs, pinned...) + for _, h := range hotPicks { + orderedIDs = append(orderedIDs, h.id) + } + + const pinnedScoreBase = 1000.0 + zMembers := make([]redis.Z, 0, len(orderedIDs)) + for i, id := range pinned { + zMembers = append(zMembers, redis.Z{ + Score: pinnedScoreBase - float64(i)*1e-6, + Member: id, + }) + } + for _, h := range hotPicks { + zMembers = append(zMembers, redis.Z{Score: h.norm * 0.99, Member: h.id}) + } + + if err := w.c.ZReplaceSortedSet(ctx, zsetKey, zMembers); err != nil { + return err + } + + ttl := time.Duration(w.cfg.HotRank.RefreshIntervalSeconds) * time.Second * 4 + if ttl < 10*time.Minute { + ttl = 10 * time.Minute + } + w.c.Set(topIDsKey, orderedIDs, ttl) + + zap.L().Info("hot rank refreshed", + zap.Int("pinned", len(pinned)), + zap.Int("candidates", len(candidateIDs)), + zap.Int("scored", len(snapshots)), + zap.Int("top_n", len(zMembers)), + ) + return nil +} + +func dedupeIDsPreserveOrder(ids []string) []string { + if len(ids) <= 1 { + return ids + } + seen := make(map[string]struct{}, len(ids)) + out := make([]string, 0, len(ids)) + for _, id := range ids { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func mergeHotRankCandidates(incumbent, recent []string, cap int) []string { + if cap <= 0 { + return nil + } + seen := make(map[string]struct{}, cap) + out := make([]string, 0, cap) + for _, id := range incumbent { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + if len(out) >= cap { + return out + } + } + for _, id := range recent { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + if len(out) >= cap { + break + } + } + return out +} + +func hotRankBaseScore(likes, comments, favorites, shares, views int) float64 { + return float64(likes)*3 + + float64(comments)*5 + + float64(favorites)*4 + + float64(shares)*6 + + float64(views)*0.15 +} diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 34f6bf7..ed81d10 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "math/rand" "strconv" "strings" "time" @@ -12,7 +11,6 @@ import ( "carrot_bbs/internal/cache" "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/cursor" - "carrot_bbs/internal/pkg/gorse" "carrot_bbs/internal/pkg/hook" "carrot_bbs/internal/repository" ) @@ -22,7 +20,6 @@ const ( PostListTTL = 30 * time.Second // 帖子列表缓存30秒 PostListNullTTL = 5 * time.Second PostListJitterRatio = 0.15 - anonymousViewUserID = "_anon_view" ) // PostService 帖子服务接口 @@ -48,12 +45,10 @@ type PostService interface { GetUserPostsByCursor(ctx context.Context, userID string, includePending bool, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) GetFollowingPostsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) GetHotPostsByCursor(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) - GetRecommendedPostsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) // 关注和推荐 GetFollowingPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) GetHotPosts(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) - GetRecommendedPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) // 交互功能 Like(ctx context.Context, postID, userID string) error @@ -79,19 +74,17 @@ type postServiceImpl struct { postRepo *repository.PostRepository systemMessageService SystemMessageService cache cache.Cache - gorseClient gorse.Client postAIService *PostAIService txManager repository.TransactionManager logService *LogService hookManager *hook.Manager } -func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager, hookManager *hook.Manager) PostService { +func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager, hookManager *hook.Manager) PostService { return &postServiceImpl{ postRepo: postRepo, systemMessageService: systemMessageService, cache: cacheBackend, - gorseClient: gorseClient, postAIService: postAIService, txManager: txManager, logService: nil, @@ -139,7 +132,7 @@ func (s *postServiceImpl) Create(ctx context.Context, userID, title, content str // 失效帖子列表缓存 cache.InvalidatePostList(s.cache) - // 同步到Gorse推荐系统(异步) + // 异步执行审核流 go s.reviewPostAsync(post.ID, userID, title, content, images) // 重新查询以获取关联的 User 和 Images @@ -205,19 +198,6 @@ func (s *postServiceImpl) reviewPostAsync(postID, userID, title, content string, } s.invalidatePostCaches(postID) - if s.gorseClient.IsEnabled() { - post, getErr := s.postRepo.GetByID(postID) - if getErr != nil { - log.Printf("[WARN] Failed to load published post for gorse sync: %v", getErr) - return - } - categories := s.buildPostCategories(post) - comment := post.Title - textToEmbed := post.Title + " " + post.Content - if upsertErr := s.gorseClient.UpsertItemWithEmbedding(context.Background(), post.ID, categories, comment, textToEmbed); upsertErr != nil { - log.Printf("[WARN] Failed to upsert item to Gorse: %v", upsertErr) - } - } } func (s *postServiceImpl) updateModerationStatusWithRetry(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error { @@ -302,15 +282,6 @@ func (s *postServiceImpl) Delete(ctx context.Context, id string) error { cache.InvalidatePostDetail(s.cache, id) cache.InvalidatePostList(s.cache) - // 从Gorse中删除帖子(异步) - go func() { - if s.gorseClient.IsEnabled() { - if err := s.gorseClient.DeleteItem(context.Background(), id); err != nil { - log.Printf("[WARN] Failed to delete item from Gorse: %v", err) - } - } - }() - return nil } @@ -420,15 +391,6 @@ func (s *postServiceImpl) Like(ctx context.Context, postID, userID string) error }() } - // 推送点赞行为到Gorse(异步) - go func() { - if s.gorseClient.IsEnabled() { - if err := s.gorseClient.InsertFeedback(context.Background(), gorse.FeedbackTypeLike, userID, postID); err != nil { - log.Printf("[WARN] Failed to insert like feedback to Gorse: %v", err) - } - } - }() - return nil } @@ -442,15 +404,6 @@ func (s *postServiceImpl) Unlike(ctx context.Context, postID, userID string) err // 失效帖子详情缓存 cache.InvalidatePostDetail(s.cache, postID) - // 删除Gorse中的点赞反馈(异步) - go func() { - if s.gorseClient.IsEnabled() { - if err := s.gorseClient.DeleteFeedback(context.Background(), gorse.FeedbackTypeLike, userID, postID); err != nil { - log.Printf("[WARN] Failed to delete like feedback from Gorse: %v", err) - } - } - }() - return nil } @@ -485,15 +438,6 @@ func (s *postServiceImpl) Favorite(ctx context.Context, postID, userID string) e }() } - // 推送收藏行为到Gorse(异步) - go func() { - if s.gorseClient.IsEnabled() { - if err := s.gorseClient.InsertFeedback(context.Background(), gorse.FeedbackTypeStar, userID, postID); err != nil { - log.Printf("[WARN] Failed to insert favorite feedback to Gorse: %v", err) - } - } - }() - return nil } @@ -507,15 +451,6 @@ func (s *postServiceImpl) Unfavorite(ctx context.Context, postID, userID string) // 失效帖子详情缓存 cache.InvalidatePostDetail(s.cache, postID) - // 删除Gorse中的收藏反馈(异步) - go func() { - if s.gorseClient.IsEnabled() { - if err := s.gorseClient.DeleteFeedback(context.Background(), gorse.FeedbackTypeStar, userID, postID); err != nil { - log.Printf("[WARN] Failed to delete favorite feedback from Gorse: %v", err) - } - } - }() - return nil } @@ -548,28 +483,12 @@ func (s *postServiceImpl) GetPostInteractionStatusSingle(ctx context.Context, po return s.postRepo.IsLiked(postID, userID), s.postRepo.IsFavorited(postID, userID) } -// IncrementViews 增加帖子观看量并同步到Gorse +// IncrementViews 增加帖子观看量 func (s *postServiceImpl) IncrementViews(ctx context.Context, postID, userID string) error { if err := s.postRepo.IncrementViews(postID); err != nil { return err } - // 同步浏览行为到Gorse(异步) - go func() { - if !s.gorseClient.IsEnabled() { - return - } - - feedbackUserID := userID - if feedbackUserID == "" { - feedbackUserID = anonymousViewUserID - } - - if err := s.gorseClient.InsertFeedback(context.Background(), gorse.FeedbackTypeRead, feedbackUserID, postID); err != nil { - log.Printf("[WARN] Failed to insert read feedback to Gorse: %v", err) - } - }() - return nil } @@ -625,43 +544,70 @@ func (s *postServiceImpl) GetFollowingPosts(ctx context.Context, userID string, return result.Posts, result.Total, nil } -// GetHotPosts 获取热门帖子(使用Gorse非个性化推荐) +// GetHotPosts 获取热门帖子(优先分层缓存 top_ids → Redis ZSET,未就绪时回源 DB 按最新排序) func (s *postServiceImpl) GetHotPosts(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) { - // 如果Gorse启用,使用自定义的非个性化推荐器 - if s.gorseClient.IsEnabled() { - offset := (page - 1) * pageSize - // 使用 most_liked_weekly 推荐器获取周热门 - // 多取1条用于判断是否还有下一页 - itemIDs, err := s.gorseClient.GetNonPersonalized(ctx, "most_liked_weekly", pageSize+1, offset, "") - if err != nil { - log.Printf("[WARN] Gorse GetNonPersonalized failed: %v, fallback to database", err) - return s.getHotPostsFromDB(ctx, page, pageSize) - } - if len(itemIDs) > 0 { - hasNext := len(itemIDs) > pageSize - if hasNext { - itemIDs = itemIDs[:pageSize] - } - posts, err := s.postRepo.GetByIDs(itemIDs) - if err != nil { - return nil, 0, err - } - // 近似 total:当 hasNext 为 true 时,按分页窗口估算,避免因脏数据/缺失数据导致总页数被低估 - estimatedTotal := int64(offset + len(posts)) - if hasNext { - estimatedTotal = int64(offset + pageSize + 1) - } - return posts, estimatedTotal, nil - } + offset := (page - 1) * pageSize + if posts, total, ok, err := s.tryHotPostsFromTopIDsCache(offset, pageSize); ok { + return posts, total, err + } else if err != nil { + return nil, 0, err + } + if posts, total, ok, err := s.tryHotPostsFromZSet(ctx, offset, pageSize); ok { + return posts, total, err + } else if err != nil { + return nil, 0, err } - - // 降级:从数据库获取 return s.getHotPostsFromDB(ctx, page, pageSize) } -// getHotPostsFromDB 从数据库获取热门帖子(降级路径) +// tryHotPostsFromTopIDsCache 从热门榜有序 ID 列表取帖(命中 LayeredCache 本地层时可不访问 Redis) +func (s *postServiceImpl) tryHotPostsFromTopIDsCache(offset, pageSize int) (posts []*model.Post, total int64, ok bool, err error) { + if s.cache == nil || pageSize <= 0 { + return nil, 0, false, nil + } + ids, hit := cache.GetTyped[[]string](s.cache, cache.HotRankTopIDsKey()) + if !hit || len(ids) == 0 { + return nil, 0, false, nil + } + if offset >= len(ids) { + return []*model.Post{}, int64(len(ids)), true, nil + } + end := offset + pageSize + if end > len(ids) { + end = len(ids) + } + pageIDs := ids[offset:end] + posts, err = s.postRepo.GetByIDs(pageIDs) + if err != nil { + return nil, 0, false, err + } + return posts, int64(len(ids)), true, nil +} + +// tryHotPostsFromZSet 从定时任务写入的 ZSET 按排名取帖;ok=false 表示应回源 +func (s *postServiceImpl) tryHotPostsFromZSet(ctx context.Context, offset, pageSize int) (posts []*model.Post, total int64, ok bool, err error) { + if s.cache == nil || pageSize <= 0 { + return nil, 0, false, nil + } + key := cache.HotRankZSetKey() + card, zerr := s.cache.ZCard(ctx, key) + if zerr != nil || card == 0 { + return nil, 0, false, nil + } + stop := int64(offset + pageSize - 1) + ids, zerr := s.cache.ZRevRange(ctx, key, int64(offset), stop) + if zerr != nil || len(ids) == 0 { + return nil, 0, false, nil + } + posts, err = s.postRepo.GetByIDs(ids) + if err != nil { + return nil, 0, false, err + } + return posts, card, true, nil +} + +// getHotPostsFromDB 热门未就绪时按最新发布降级 func (s *postServiceImpl) getHotPostsFromDB(ctx context.Context, page, pageSize int) ([]*model.Post, int64, error) { - // 直接查询数据库,不再使用本地缓存(Gorse失败降级时使用) posts, total, err := s.postRepo.GetHotPosts(page, pageSize) if err != nil { return nil, 0, err @@ -669,94 +615,6 @@ func (s *postServiceImpl) getHotPostsFromDB(ctx context.Context, page, pageSize return posts, total, nil } -// GetRecommendedPosts 获取推荐帖子 -func (s *postServiceImpl) GetRecommendedPosts(ctx context.Context, userID string, page, pageSize int) ([]*model.Post, int64, error) { - // 如果Gorse未启用或用户未登录,降级为热门帖子 - if !s.gorseClient.IsEnabled() || userID == "" { - return s.GetHotPosts(ctx, page, pageSize) - } - - // 计算偏移量:第一页使用随机偏移量,增加推荐多样性 - var offset int - if page == 1 { - // 第一页随机偏移 0-50,让每次刷新看到不同的推荐内容 - offset = rand.Intn(51) - } else { - offset = (page - 1) * pageSize - } - - // 从Gorse获取推荐列表 - // 多取1条用于判断是否还有下一页 - itemIDs, err := s.gorseClient.GetRecommend(ctx, userID, pageSize+1, offset) - if err != nil { - log.Printf("[WARN] Gorse recommendation failed: %v, fallback to hot posts", err) - return s.GetHotPosts(ctx, page, pageSize) - } - - // 如果没有推荐结果,降级为热门帖子 - if len(itemIDs) == 0 { - return s.GetHotPosts(ctx, page, pageSize) - } - - hasNext := len(itemIDs) > pageSize - if hasNext { - itemIDs = itemIDs[:pageSize] - } - - // 根据ID列表查询帖子详情 - posts, err := s.postRepo.GetByIDs(itemIDs) - if err != nil { - return nil, 0, err - } - - // 近似 total:当 hasNext 为 true 时,按分页窗口估算,避免因脏数据/缺失数据导致总页数被低估 - estimatedTotal := int64(offset + len(posts)) - if hasNext { - estimatedTotal = int64(offset + pageSize + 1) - } - return posts, estimatedTotal, nil -} - -// buildPostCategories 构建帖子的类别标签 -func (s *postServiceImpl) buildPostCategories(post *model.Post) []string { - var categories []string - - // 热度标签 - if post.ViewsCount > 1000 { - categories = append(categories, "hot_high") - } else if post.ViewsCount > 100 { - categories = append(categories, "hot_medium") - } - - // 点赞标签 - if post.LikesCount > 100 { - categories = append(categories, "likes_100+") - } else if post.LikesCount > 50 { - categories = append(categories, "likes_50+") - } else if post.LikesCount > 10 { - categories = append(categories, "likes_10+") - } - - // 评论标签 - if post.CommentsCount > 50 { - categories = append(categories, "comments_50+") - } else if post.CommentsCount > 10 { - categories = append(categories, "comments_10+") - } - - // 时间标签 - age := time.Since(post.CreatedAt) - if age < 24*time.Hour { - categories = append(categories, "today") - } else if age < 7*24*time.Hour { - categories = append(categories, "this_week") - } else if age < 30*24*time.Hour { - categories = append(categories, "this_month") - } - - return categories -} - // ========== 事务管理器示例方法 ========== // DeletePostWithTransaction 使用事务管理器删除帖子(示例) @@ -810,103 +668,92 @@ func (s *postServiceImpl) GetHotPostsByCursor(ctx context.Context, req *cursor.P // 规范化请求参数 req.Normalize() - // 如果Gorse启用,使用自定义的非个性化推荐器 - if s.gorseClient.IsEnabled() { - // 计算偏移量 - offset := 0 - if req.Cursor != "" { - // 尝试解析游标作为偏移量 - if parsed, err := strconv.Atoi(req.Cursor); err == nil { - offset = parsed - } - } - - // 使用 most_liked_weekly 推荐器获取周热门 - // 多取1条用于判断是否还有下一页 - itemIDs, err := s.gorseClient.GetNonPersonalized(ctx, "most_liked_weekly", req.PageSize+1, offset, "") - if err != nil { - log.Printf("[WARN] Gorse GetNonPersonalized failed: %v, fallback to database", err) - return s.getHotPostsByCursorFromDB(ctx, req) - } - if len(itemIDs) > 0 { - hasMore := len(itemIDs) > req.PageSize - if hasMore { - itemIDs = itemIDs[:req.PageSize] - } - posts, err := s.postRepo.GetByIDs(itemIDs) - if err != nil { - return nil, err - } - // 下一页的游标是当前偏移量 + 已获取数量 - nextCursor := "" - if hasMore { - nextCursor = strconv.Itoa(offset + req.PageSize) - } - return &cursor.CursorPageResult[*model.Post]{ - Items: posts, - NextCursor: nextCursor, - PrevCursor: "", - HasMore: hasMore, - }, nil - } - } - - // 降级:从数据库获取 return s.getHotPostsByCursorFromDB(ctx, req) } -// getHotPostsByCursorFromDB 从数据库游标分页获取热门帖子(降级路径) +// getHotPostsByCursorFromDB 热门游标:优先 ZSET(一次多取 1 条判断 hasMore),否则回源 DB func (s *postServiceImpl) getHotPostsByCursorFromDB(ctx context.Context, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) { - return s.postRepo.GetHotPostsByCursor(ctx, req) -} - -// GetRecommendedPostsByCursor 游标分页获取推荐帖子 -func (s *postServiceImpl) GetRecommendedPostsByCursor(ctx context.Context, userID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Post], error) { - // 规范化请求参数 - req.Normalize() - - // 如果Gorse未启用或用户未登录,降级为热门帖子 - if !s.gorseClient.IsEnabled() || userID == "" { - return s.GetHotPostsByCursor(ctx, req) - } - - // 计算偏移量:第一页使用随机偏移量,增加推荐多样性 - var offset int - if req.Cursor == "" { - // 第一页随机偏移 0-50,让每次刷新看到不同的推荐内容 - offset = rand.Intn(51) - } else { - // 尝试解析游标作为偏移量 - if parsed, err := strconv.Atoi(req.Cursor); err == nil { + offset := 0 + if req.Cursor != "" { + if parsed, err := strconv.Atoi(req.Cursor); err == nil && parsed >= 0 { offset = parsed } } - // 从Gorse获取推荐列表 - // 多取1条用于判断是否还有下一页 - itemIDs, err := s.gorseClient.GetRecommend(ctx, userID, req.PageSize+1, offset) - if err != nil { - log.Printf("[WARN] Gorse recommendation failed: %v, fallback to hot posts", err) - return s.GetHotPostsByCursor(ctx, req) + if s.cache != nil { + if ids, hit := cache.GetTyped[[]string](s.cache, cache.HotRankTopIDsKey()); hit && len(ids) > 0 { + if offset < len(ids) { + stop := offset + req.PageSize + 1 + if stop > len(ids) { + stop = len(ids) + } + slice := ids[offset:stop] + hasMore := len(slice) > req.PageSize + if hasMore { + slice = slice[:req.PageSize] + } + posts, gerr := s.postRepo.GetByIDs(slice) + if gerr != nil { + return nil, gerr + } + nextCursor := "" + if hasMore { + nextCursor = strconv.Itoa(offset + req.PageSize) + } + return &cursor.CursorPageResult[*model.Post]{ + Items: posts, + NextCursor: nextCursor, + PrevCursor: "", + HasMore: hasMore, + }, nil + } + return &cursor.CursorPageResult[*model.Post]{ + Items: []*model.Post{}, + NextCursor: "", + PrevCursor: "", + HasMore: false, + }, nil + } + + key := cache.HotRankZSetKey() + card, zerr := s.cache.ZCard(ctx, key) + if zerr == nil && card > 0 { + stop := int64(offset + req.PageSize) + ids, rerr := s.cache.ZRevRange(ctx, key, int64(offset), stop) + if rerr == nil && len(ids) > 0 { + hasMore := len(ids) > req.PageSize + if hasMore { + ids = ids[:req.PageSize] + } + posts, gerr := s.postRepo.GetByIDs(ids) + if gerr != nil { + return nil, gerr + } + nextCursor := "" + if hasMore { + nextCursor = strconv.Itoa(offset + req.PageSize) + } + return &cursor.CursorPageResult[*model.Post]{ + Items: posts, + NextCursor: nextCursor, + PrevCursor: "", + HasMore: hasMore, + }, nil + } + } } - // 如果没有推荐结果,降级为热门帖子 - if len(itemIDs) == 0 { - return s.GetHotPostsByCursor(ctx, req) - } - - hasMore := len(itemIDs) > req.PageSize - if hasMore { - itemIDs = itemIDs[:req.PageSize] - } - - // 根据ID列表查询帖子详情 - posts, err := s.postRepo.GetByIDs(itemIDs) + page := offset/req.PageSize + 1 + posts, _, err := s.postRepo.GetHotPosts(page, req.PageSize+1) if err != nil { return nil, err } - // 下一页的游标是当前偏移量 + 已获取数量 + hasMore := len(posts) > req.PageSize + if hasMore { + posts = posts[:req.PageSize] + } + nextCursor := "" if hasMore { nextCursor = strconv.Itoa(offset + req.PageSize) diff --git a/internal/wire/handler.go b/internal/wire/handler.go index cb433ef..a50d03e 100644 --- a/internal/wire/handler.go +++ b/internal/wire/handler.go @@ -2,14 +2,12 @@ package wire import ( "carrot_bbs/internal/cache" - "carrot_bbs/internal/config" "carrot_bbs/internal/handler" "carrot_bbs/internal/pkg/sse" "carrot_bbs/internal/repository" "carrot_bbs/internal/service" "github.com/google/wire" - "gorm.io/gorm" ) // HandlerSet Handler 层 Provider Set @@ -36,7 +34,6 @@ var HandlerSet = wire.NewSet( ProvideMessageHandler, ProvideSystemMessageHandler, ProvideGroupHandler, - ProvideGorseHandler, ProvideScheduleHandler, ) @@ -115,11 +112,6 @@ func ProvideGroupHandler( return handler.NewGroupHandler(groupService, userService) } -// ProvideGorseHandler 提供 Gorse 处理器 -func ProvideGorseHandler(cfg *config.Config, db *gorm.DB) *handler.GorseHandler { - return handler.NewGorseHandler(cfg.Gorse, db) -} - // ProvideScheduleHandler 提供课表处理器 func ProvideScheduleHandler( scheduleService service.ScheduleService, diff --git a/internal/wire/infrastructure.go b/internal/wire/infrastructure.go index 797fe41..4298621 100644 --- a/internal/wire/infrastructure.go +++ b/internal/wire/infrastructure.go @@ -8,7 +8,6 @@ import ( "carrot_bbs/internal/model" "carrot_bbs/internal/pkg/crypto" "carrot_bbs/internal/pkg/email" - "carrot_bbs/internal/pkg/gorse" "carrot_bbs/internal/pkg/hook" "carrot_bbs/internal/pkg/openai" "carrot_bbs/internal/pkg/redis" @@ -49,7 +48,6 @@ var InfrastructureSet = wire.NewSet( ProvideSSEHub, // 外部服务客户端 - ProvideGorseClient, ProvideOpenAIClient, ProvideEmailClient, ProvideS3Client, @@ -163,11 +161,6 @@ func ProvideSSEHub() *sse.Hub { return sse.NewHub() } -// ProvideGorseClient 提供 Gorse 客户端 -func ProvideGorseClient(cfg *config.Config) gorse.Client { - return gorse.NewClient(gorse.ConfigFromAppConfig(&cfg.Gorse)) -} - // ProvideOpenAIClient 提供 OpenAI 客户端 func ProvideOpenAIClient(cfg *config.Config) openai.Client { return openai.NewClient(openai.ConfigFromAppConfig(&cfg.OpenAI)) diff --git a/internal/wire/service.go b/internal/wire/service.go index 084dd6f..8de4473 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -7,7 +7,6 @@ import ( "carrot_bbs/internal/config" "carrot_bbs/internal/grpc/runner" "carrot_bbs/internal/pkg/email" - "carrot_bbs/internal/pkg/gorse" "carrot_bbs/internal/pkg/hook" "carrot_bbs/internal/pkg/openai" "carrot_bbs/internal/pkg/redis" @@ -53,6 +52,7 @@ var ServiceSet = wire.NewSet( ProvideAdminGroupService, ProvideAdminDashboardService, ProvideQRCodeLoginService, + ProvideHotRankWorker, // 日志服务 ProvideAsyncLogManager, @@ -106,7 +106,6 @@ func ProvideSystemMessageService( func ProvidePostService( postRepo *repository.PostRepository, systemMessageService service.SystemMessageService, - gorseClient gorse.Client, postAIService *service.PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager, @@ -114,19 +113,18 @@ func ProvidePostService( _ *hook.ModerationHooks, _ *hook.BuiltinHooks, ) service.PostService { - return service.NewPostService(postRepo, systemMessageService, gorseClient, postAIService, cacheBackend, txManager, hookManager) + return service.NewPostService(postRepo, systemMessageService, postAIService, cacheBackend, txManager, hookManager) } func ProvideCommentService( commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService service.SystemMessageService, - gorseClient gorse.Client, postAIService *service.PostAIService, cacheBackend cache.Cache, hookManager *hook.Manager, ) *service.CommentService { - return service.NewCommentService(commentRepo, postRepo, systemMessageService, gorseClient, postAIService, cacheBackend, hookManager) + return service.NewCommentService(commentRepo, postRepo, systemMessageService, postAIService, cacheBackend, hookManager) } // ProvideMessageService 提供消息服务 @@ -349,6 +347,10 @@ func ProvideLogService( } // ProvideQRCodeLoginService 提供二维码登录服务 +func ProvideHotRankWorker(cfg *config.Config, postRepo *repository.PostRepository, cacheBackend cache.Cache) *service.HotRankWorker { + return service.NewHotRankWorker(cfg, postRepo, cacheBackend) +} + func ProvideQRCodeLoginService( redisClient *redis.Client, sseHub *sse.Hub, diff --git a/start-docker.sh b/start-docker.sh index 11fcd7c..6b123eb 100755 --- a/start-docker.sh +++ b/start-docker.sh @@ -20,10 +20,12 @@ docker run -d \ -e APP_S3_SECRET_KEY=4R9yjmwKNoHphiBkv05Oa8WGEIFbnlZeTLXfSgx3 \ -e APP_S3_BUCKET=test \ -e APP_S3_DOMAIN=files.littlelan.cn \ - -e APP_GORSE_ENABLED=true \ - -e APP_GORSE_ADDRESS=http://111.170.19.33:8088 \ - -e APP_GORSE_IMPORT_PASSWORD=lanyimin123 \ - -e APP_GORSE_EMBEDDING_API_KEY=sk-ZPN5NMPSqEaOGCPfD2LqndZ5Wwmw3DC4CQgzgKhM35fI3RpD \ + -e APP_HOT_RANK_ENABLED=true \ + -e APP_HOT_RANK_REFRESH_INTERVAL_SECONDS=300 \ + -e APP_HOT_RANK_TOP_N=100 \ + -e APP_HOT_RANK_RECENT_WINDOW_HOURS=168 \ + -e APP_HOT_RANK_RECENT_FETCH_LIMIT=500 \ + -e APP_HOT_RANK_CANDIDATE_CAP=800 \ -e APP_OPENAI_ENABLED=true \ -e APP_OPENAI_BASE_URL=https://api.littlelan.cn/ \ -e APP_OPENAI_API_KEY=sk-y7LOeKsNfzbZWTRSFsTs79jd8WYlezbIVgdVPgMvG4Xz2AlV \