人人都会AI编程

请细化人人都会AI编程的 MVP 版推荐系统的 Go 代码实现框架

更新时间:2026-07-03

MVP 版推荐系统 Go 实现框架说明

本方案完全基于 Go 原生实现,无额外大数据/算法组件依赖,复用你现有的 PostgreSQL + Redis 存储,采用「多路召回 → 规则过滤 → 加权排序 → 结果缓存」的标准推荐架构,代码结构预留扩展位,后续可平滑升级到向量召回、模型精排。

技术栈底座:

  • Web 框架:Gin(与现有业务栈统一)
  • ORM:GORM
  • 缓存:go-redis v9
  • 核心算法:纯规则 + 标签权重计算,无第三方算法库

一、项目目录结构

ai-programming-site/
├── cmd/
│   └── api/
│       └── main.go                # 服务入口
├── internal/
│   ├── recommend/                 # 推荐系统核心模块
│   │   ├── model/                 # 数据模型(DB结构体)
│   │   ├── dao/                   # 数据访问层(DB/Redis)
│   │   ├── recall/                # 召回层:多路召回策略
│   │   │   ├── base.go            # 召回器通用接口
│   │   │   ├── tag_recall.go      # 标签兴趣召回(核心)
│   │   │   ├── hot_recall.go      # 热门内容召回
│   │   │   ├── new_recall.go      # 新品内容召回
│   │   │   └── similar_recall.go  # 相似文章召回(详情页用)
│   │   ├── filter/                # 过滤层
│   │   │   ├── read_filter.go     # 已读/已收藏过滤
│   │   │   └── diversity_filter.go# 多样性打散过滤
│   │   ├── rank/                  # 排序层
│   │   │   └── weight_rank.go     # 加权综合排序
│   │   ├── interest/              # 用户兴趣计算
│   │   │   └── interest_service.go# 兴趣标签权重更新
│   │   └── service.go             # 推荐主服务编排
│   ├── handler/                   # HTTP接口层
│   │   └── recommend_handler.go
│   └── dto/                       # 请求/响应结构体
├── pkg/
│   └── cache/                     # Redis通用封装
└── config/                        # 配置文件

二、核心数据模型(model 层)

对应数据库表设计,核心围绕「文章标签」和「用户兴趣权重」构建。

1. 文章与标签模型

// internal/recommend/model/article.go
package model

import "time"

// Article 文章主表
type Article struct {
    ID        uint      `gorm:"primaryKey"`
    Title     string    `gorm:"type:varchar(255);not null"`
    Category  string    `gorm:"type:varchar(50);comment:一级分类:教程/资讯/工具"`
    Difficulty string   `gorm:"type:varchar(20);comment:难度:入门/进阶/实战"`
    PublishAt time.Time `gorm:"comment:发布时间"`
    // 热度分(定时任务预计算)
    HotScore  float64   `gorm:"type:decimal(10,4);default:0;comment:热度分"`
}

// ArticleTag 文章-标签关联表(多对多)
type ArticleTag struct {
    ID        uint   `gorm:"primaryKey"`
    ArticleID uint   `gorm:"index;not null"`
    TagName   string `gorm:"type:varchar(50);index;not null"` // 如: Cursor、Go、React、Prompt
    Weight    float64 `gorm:"type:decimal(5,2);default:1.0;comment:标签在文章中的权重"`
}

2. 用户行为与兴趣模型

// internal/recommend/model/user.go
package model

import "time"

// UserBehavior 用户行为日志表
type UserBehavior struct {
    ID         uint      `gorm:"primaryKey"`
    UserID     uint      `gorm:"index;not null"`
    ArticleID  uint      `gorm:"index;not null"`
    Behavior   string    `gorm:"type:varchar(20);comment:view/like/favorite/read_complete"`
    StaySeconds int      `gorm:"comment:停留时长(秒)"`
    CreatedAt  time.Time `gorm:"autoCreateTime"`
}

// UserInterestTag 用户兴趣标签表
type UserInterestTag struct {
    ID        uint      `gorm:"primaryKey"`
    UserID    uint      `gorm:"index:idx_user_tag,unique;not null"`
    TagName   string    `gorm:"index:idx_user_tag,unique;type:varchar(50);not null"`
    Weight    float64   `gorm:"type:decimal(10,4);default:0;comment:兴趣权重"`
    UpdatedAt time.Time `gorm:"autoUpdateTime"`
}

三、召回层实现(recall 层)

统一召回器接口,所有召回策略实现该接口,方便后续新增召回通道。

1. 通用召回接口

// internal/recommend/recall/base.go
package recall

import "ai-programming-site/internal/recommend/model"

// RecallItem 召回结果项
type RecallItem struct {
    ArticleID uint
    Score     float64 // 召回通道内的原始得分
    Channel   string  // 召回通道标识
}

// Recaller 召回器接口
type Recaller interface {
    Name() string
    // DoRecall 执行召回,返回候选文章列表
    DoRecall(userID uint, limit int) ([]RecallItem, error)
}

2. 核心:标签兴趣召回

根据用户兴趣标签权重,匹配文章标签,计算相关度得分。

// internal/recommend/recall/tag_recall.go
package recall

import (
    "ai-programming-site/internal/recommend/dao"
    "ai-programming-site/internal/recommend/model"
)

type TagRecaller struct {
    articleDao  *dao.ArticleDao
    interestDao *dao.InterestDao
}

func NewTagRecaller() *TagRecaller {
    return &TagRecaller{
        articleDao:  dao.NewArticleDao(),
        interestDao: dao.NewInterestDao(),
    }
}

func (r *TagRecaller) Name() string {
    return "tag_recall"
}

func (r *TagRecaller) DoRecall(userID uint, limit int) ([]RecallItem, error) {
    // 1. 获取用户TopN兴趣标签(按权重倒序取前20个)
    userTags, err := r.interestDao.GetUserTopTags(userID, 20)
    if err != nil {
        return nil, err
    }
    if len(userTags) == 0 {
        return []RecallItem{}, nil
    }

    // 2. 根据标签批量查询关联文章
    tagNames := make([]string, 0, len(userTags))
    tagWeightMap := make(map[string]float64)
    for _, tag := range userTags {
        tagNames = append(tagNames, tag.TagName)
        tagWeightMap[tag.TagName] = tag.Weight
    }

    articleTags, err := r.articleDao.GetArticlesByTags(tagNames)
    if err != nil {
        return nil, err
    }

    // 3. 计算文章相关度得分 = 累加(用户标签权重 * 文章标签权重)
    scoreMap := make(map[uint]float64)
    for _, at := range articleTags {
        if userWeight, ok := tagWeightMap[at.TagName]; ok {
            scoreMap[at.ArticleID] += userWeight * at.Weight
        }
    }

    // 4. 按得分倒序,取前limit个
    items := make([]RecallItem, 0, len(scoreMap))
    for aid, score := range scoreMap {
        items = append(items, RecallItem{
            ArticleID: aid,
            Score:     score,
            Channel:   r.Name(),
        })
    }

    // 按得分排序,截断
    // 此处省略排序逻辑,可引入sort包实现
    if len(items) > limit {
        items = items[:limit]
    }
    return items, nil
}

3. 热门召回 + 新品召回

作为保底召回,解决冷启动与兴趣覆盖不足的问题。

// internal/recommend/recall/hot_recall.go
package recall

import "ai-programming-site/internal/recommend/dao"

type HotRecaller struct {
    articleDao *dao.ArticleDao
}

func NewHotRecaller() *HotRecaller {
    return &HotRecaller{articleDao: dao.NewArticleDao()}
}

func (r *HotRecaller) Name() string { return "hot_recall" }

func (r *HotRecaller) DoRecall(_ uint, limit int) ([]RecallItem, error) {
    // 取近7天热度分最高的文章
    articles, err := r.articleDao.GetHotArticles(7, limit)
    if err != nil {
        return nil, err
    }

    items := make([]RecallItem, 0, len(articles))
    for _, art := range articles {
        items = append(items, RecallItem{
            ArticleID: art.ID,
            Score:     art.HotScore,
            Channel:   r.Name(),
        })
    }
    return items, nil
}

新品召回逻辑类似,按发布时间倒序取最新文章,得分按时效衰减计算(发布越近得分越高)。


四、过滤层实现(filter 层)

对多路召回合并后的候选集进行过滤,保证体验。

1. 已读/已收藏过滤

// internal/recommend/filter/read_filter.go
package filter

import "ai-programming-site/internal/recommend/dao"

type ReadFilter struct {
    behaviorDao *dao.BehaviorDao
}

func NewReadFilter() *ReadFilter {
    return &ReadFilter{behaviorDao: dao.NewBehaviorDao()}
}

// DoFilter 过滤用户已读、已收藏、近30天已推荐过的文章
func (f *ReadFilter) DoFilter(userID uint, articleIDs []uint) ([]uint, error) {
    // 批量查询用户历史行为中的文章ID
    readIDs, err := f.behaviorDao.GetUserReadArticleIDs(userID, 30)
    if err != nil {
        return nil, err
    }

    readSet := make(map[uint]struct{})
    for _, id := range readIDs {
        readSet[id] = struct{}{}
    }

    result := make([]uint, 0, len(articleIDs))
    for _, id := range articleIDs {
        if _, ok := readSet[id]; !ok {
            result = append(result, id)
        }
    }
    return result, nil
}

2. 多样性过滤

控制同分类、同标签文章占比,避免结果同质化,例如同一分类连续出现不超过2篇。


五、排序层实现(rank 层)

将多路召回的得分做归一化处理后,按权重加权求和,得到最终排序分。

// internal/recommend/rank/weight_rank.go
package rank

import (
    "ai-programming-site/internal/recommend/model"
    "ai-programming-site/internal/recommend/recall"
    "math"
)

// 各通道权重(可配置化)
var channelWeight = map[string]float64{
    "tag_recall": 0.6, // 标签相关度权重最高
    "hot_recall": 0.3, // 热度权重次之
    "new_recall": 0.1, // 时效性权重最低
}

type WeightRanker struct{}

func NewWeightRanker() *WeightRanker {
    return &WeightRanker{}
}

// DoRank 对召回结果进行加权排序
func (r *WeightRanker) DoRank(items []recall.RecallItem) []recall.RecallItem {
    if len(items) == 0 {
        return items
    }

    // 1. 按通道分组,做min-max归一化,消除量纲差异
    channelItems := make(map[string][]recall.RecallItem)
    for _, item := range items {
        channelItems[item.Channel] = append(channelItems[item.Channel], item)
    }

    scoreMap := make(map[uint]float64)
    for channel, channelList := range channelItems {
        // 计算当前通道的最大最小得分
        minScore, maxScore := math.MaxFloat64, -math.MaxFloat64
        for _, item := range channelList {
            if item.Score < minScore {
                minScore = item.Score
            }
            if item.Score > maxScore {
                maxScore = item.Score
            }
        }

        // 归一化 + 加权
        weight := channelWeight[channel]
        for _, item := range channelList {
            normScore := 0.0
            if maxScore != minScore {
                normScore = (item.Score - minScore) / (maxScore - minScore)
            }
            scoreMap[item.ArticleID] += normScore * weight
        }
    }

    // 2. 按最终得分倒序排序
    // 此处省略排序逻辑,返回排序后的列表
    result := make([]recall.RecallItem, 0, len(scoreMap))
    for aid, score := range scoreMap {
        result = append(result, recall.RecallItem{
            ArticleID: aid,
            Score:     score,
        })
    }
    return result
}

六、用户兴趣更新服务

用户产生行为后,异步更新兴趣标签权重,是推荐效果的核心反馈机制。

// internal/recommend/interest/interest_service.go
package interest

import (
    "ai-programming-site/internal/recommend/dao"
    "ai-programming-site/internal/recommend/model"
)

// 行为权重系数(可配置)
var behaviorWeight = map[string]float64{
    "view":          1.0,  // 普通浏览
    "read_complete": 2.0,  // 读完
    "like":          3.0,  // 点赞
    "favorite":      5.0,  // 收藏
}

type InterestService struct {
    articleDao  *dao.ArticleDao
    interestDao *dao.InterestDao
}

func NewInterestService() *InterestService {
    return &InterestService{
        articleDao:  dao.NewArticleDao(),
        interestDao: dao.NewInterestDao(),
    }
}

// UpdateInterest 用户产生行为后,更新兴趣标签权重
func (s *InterestService) UpdateInterest(userID uint, articleID uint, behavior string) error {
    // 1. 获取文章的所有标签
    tags, err := s.articleDao.GetArticleTags(articleID)
    if err != nil {
        return err
    }
    if len(tags) == 0 {
        return nil
    }

    // 2. 获取行为对应的权重系数
    weightFactor, ok := behaviorWeight[behavior]
    if !ok {
        weightFactor = 1.0
    }

    // 3. 批量更新用户兴趣标签权重
    // 规则:新权重 = 旧权重 * 0.95(时间衰减) + 文章标签权重 * 行为系数
    for _, tag := range tags {
        addWeight := tag.Weight * weightFactor
        err := s.interestDao.UpsertUserTagWeight(userID, tag.TagName, addWeight, 0.95)
        if err != nil {
            return err
        }
    }
    return nil
}

// AsyncUpdateInterest 异步更新,不阻塞主请求
func (s *InterestService) AsyncUpdateInterest(userID uint, articleID uint, behavior string) {
    go func() {
        _ = s.UpdateInterest(userID, articleID, behavior)
    }()
}

七、推荐主服务编排

串联「缓存读取 → 多路召回 → 过滤 → 排序 → 回写缓存 → 返回结果」全流程。

// internal/recommend/service.go
package recommend

import (
    "ai-programming-site/internal/recommend/dao"
    "ai-programming-site/internal/recommend/filter"
    "ai-programming-site/internal/recommend/rank"
    "ai-programming-site/internal/recommend/recall"
    "ai-programming-site/pkg/cache"
    "encoding/json"
    "fmt"
    "time"
)

type RecommendService struct {
    recallers  []recall.Recaller
    readFilter *filter.ReadFilter
    ranker     *rank.WeightRanker
    articleDao *dao.ArticleDao
    cache      *cache.RedisClient
}

func NewRecommendService() *RecommendService {
    return &RecommendService{
        // 注册所有召回器
        recallers: []recall.Recaller{
            recall.NewTagRecaller(),
            recall.NewHotRecaller(),
            recall.NewNewRecaller(),
        },
        readFilter: filter.NewReadFilter(),
        ranker:     rank.NewWeightRanker(),
        articleDao: dao.NewArticleDao(),
        cache:      cache.GetRedis(),
    }
}

// GetHomeRecommend 获取首页个性化推荐
func (s *RecommendService) GetHomeRecommend(userID uint, page, pageSize int) ([]model.Article, error) {
    cacheKey := fmt.Sprintf("recommend:home:%d", userID)
    offset := (page - 1) * pageSize

    // 1. 先读缓存,命中直接返回
    cacheData, err := s.cache.Get(cacheKey)
    if err == nil && cacheData != "" {
        var allIDs []uint
        _ = json.Unmarshal([]byte(cacheData), &allIDs)
        // 分页截取,查询文章详情返回
        return s.articleDao.GetArticlesByIDs(allIDs[offset:offset+pageSize])
    }

    // 2. 多路召回并行执行
    allItems := make([]recall.RecallItem, 0)
    // 实际项目可用errgroup并发执行
    for _, r := range s.recallers {
        items, err := r.DoRecall(userID, 100) // 每路召回100条候选
        if err != nil {
            continue // 单路召回失败不影响整体
        }
        allItems = append(allItems, items...)
    }

    // 3. 提取文章ID,执行过滤
    articleIDs := make([]uint, 0, len(allItems))
    idMap := make(map[uint]recall.RecallItem)
    for _, item := range allItems {
        if _, ok := idMap[item.ArticleID]; !ok {
            idMap[item.ArticleID] = item
            articleIDs = append(articleIDs, item.ArticleID)
        }
    }

    filteredIDs, err := s.readFilter.DoFilter(userID, articleIDs)
    if err != nil {
        return nil, err
    }

    // 4. 还原召回项,执行排序
    filteredItems := make([]recall.RecallItem, 0, len(filteredIDs))
    for _, id := range filteredIDs {
        filteredItems = append(filteredItems, idMap[id])
    }

    rankedItems := s.ranker.DoRank(filteredItems)

    // 5. 提取最终ID列表,写入缓存(有效期1小时)
    finalIDs := make([]uint, 0, len(rankedItems))
    for _, item := range rankedItems {
        finalIDs = append(finalIDs, item.ArticleID)
    }

    cacheBytes, _ := json.Marshal(finalIDs)
    _ = s.cache.Set(cacheKey, string(cacheBytes), time.Hour)

    // 6. 分页返回文章详情
    end := offset + pageSize
    if end > len(finalIDs) {
        end = len(finalIDs)
    }
    return s.articleDao.GetArticlesByIDs(finalIDs[offset:end])
}

八、HTTP 接口层

// internal/handler/recommend_handler.go
package handler

import (
    "ai-programming-site/internal/recommend"
    "ai-programming-site/internal/recommend/interest"
    "github.com/gin-gonic/gin"
    "net/http"
)

type RecommendHandler struct {
    recommendSvc *recommend.RecommendService
    interestSvc  *interest.InterestService
}

func NewRecommendHandler() *RecommendHandler {
    return &RecommendHandler{
        recommendSvc: recommend.NewRecommendService(),
        interestSvc:  interest.NewInterestService(),
    }
}

// GetHomeRecommend 首页推荐接口
func (h *RecommendHandler) GetHomeRecommend(c *gin.Context) {
    userID := c.GetUint("user_id") // 从鉴权中获取用户ID
    page := c.DefaultInt("page", 1)
    pageSize := c.DefaultInt("page_size", 10)

    articles, err := h.recommendSvc.GetHomeRecommend(userID, page, pageSize)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取推荐失败"})
        return
    }
    c.JSON(http.StatusOK, gin.H{"code": 200, "data": articles})
}

// ReportBehavior 行为上报接口(浏览/点赞/收藏)
func (h *RecommendHandler) ReportBehavior(c *gin.Context) {
    var req struct {
        ArticleID uint   `json:"article_id" binding:"required"`
        Behavior  string `json:"behavior" binding:"required"`
    }
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"})
        return
    }

    userID := c.GetUint("user_id")
    // 异步更新兴趣,不阻塞
    h.interestSvc.AsyncUpdateInterest(userID, req.ArticleID, req.Behavior)
    c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "上报成功"})
}

九、冷启动与补充优化

  1. 新用户冷启动
  • 未登录/无行为用户,直接返回热门召回 + 入门分类内容,不执行标签召回;
  • 首次访问弹出兴趣选择弹窗(3-5个标签),选中后直接初始化用户兴趣权重,跳过冷启动期。
  1. 热度分预计算
  • 用定时任务(如 cron 库)每天凌晨计算近7天文章热度分,公式参考:

热度分 = 点击数0.4 + 读完数0.3 + 收藏数0.2 + 点赞数0.1,并做时间衰减。

  1. 缓存失效策略
  • 用户产生收藏、点赞等强行为时,主动删除该用户的推荐缓存,下次请求重新计算,保证实时反馈。
  1. 平滑升级路径
  • 后续新增向量召回:只需新增 vector_recall.go 实现 Recaller 接口,接入召回列表即可;
  • 后续替换模型排序:只需新增 model_rank.go 替换当前 WeightRanker,主流程无需改动。