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.
This commit is contained in:
11
internal/cache/cache.go
vendored
11
internal/cache/cache.go
vendored
@@ -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
|
||||
}
|
||||
|
||||
16
internal/cache/keys.go
vendored
16
internal/cache/keys.go
vendored
@@ -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)
|
||||
|
||||
16
internal/cache/layered_cache.go
vendored
16
internal/cache/layered_cache.go
vendored
@@ -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
|
||||
|
||||
23
internal/cache/redis_cache.go
vendored
23
internal/cache/redis_cache.go
vendored
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 配置
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
// 检查是否已有角色数据
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
335
internal/service/hot_rank_worker.go
Normal file
335
internal/service/hot_rank_worker.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user