设计思路关联文章地址:Gin+GORM实现聚合推特数据进行情感分析 技术设计-将持续更新并且开源
开源地址:github开源地址
一、项目技术栈与核心设计思路
- Web框架:Gin(高性能HTTP框架,快速开发API)
- ORM:GORM v2(简化MySQL操作,支持自动迁移、关联查询)
- 数据来源:Twitter(X)API v2(基于官方返回字段设计表结构,确保数据完整性)
- 核心功能:大V订阅、推文拉取(增量去重)、情感分析、向量存储(Qdrant)、API查询
- 设计原则:字段与Twitter API返回对齐,冗余必要字段,支持增量拉取和快速查询
二、MySQL表设计(与Twitter API字段对齐)
1. 大V订阅表(influencers)
存储Twitter大V的核心信息,字段对应Twitter API GET /users/by/username/{username} 响应
CREATE TABLE `influencers` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`twitter_user_id` varchar(64) NOT NULL COMMENT 'Twitter官方用户ID(API返回的id_str)',
`username` varchar(64) NOT NULL COMMENT 'Twitter用户名(如elonmusk,API返回的username)',
`name` varchar(128) NOT NULL COMMENT '大V昵称(API返回的name)',
`avatar_url` varchar(255) DEFAULT '' COMMENT '头像URL(API返回的profile_image_url)',
`description` text COMMENT '个人简介(API返回的description)',
`location` varchar(128) DEFAULT '' COMMENT '所在地(API返回的location)',
`url` varchar(255) DEFAULT '' COMMENT '个人网站(API返回的url)',
`public_metrics_followers` bigint NOT NULL DEFAULT '0' COMMENT '粉丝数(API返回的public_metrics.followers_count)',
`public_metrics_following` int NOT NULL DEFAULT '0' COMMENT '关注数(API返回的public_metrics.following_count)',
`public_metrics_tweet_count` int NOT NULL DEFAULT '0' COMMENT '推文总数(API返回的public_metrics.tweet_count)',
`is_verified` tinyint NOT NULL DEFAULT '0' COMMENT '是否认证(API返回的verified)',
`is_active` tinyint NOT NULL DEFAULT '1' COMMENT '是否活跃(1=拉取推文,0=暂停)',
`pull_frequency` int NOT NULL DEFAULT '30' COMMENT '拉取频率(分钟)',
`last_pull_tweet_id` varchar(64) DEFAULT '' COMMENT '上次拉取的最后一条推文ID(用于增量拉取)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '订阅时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '信息更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_twitter_user_id` (`twitter_user_id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Twitter大V订阅表';
2. 推文表(tweets)
存储拉取的推文原始数据,字段对应Twitter API GET /users/:id/tweets 响应
CREATE TABLE `tweets` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tweet_id` varchar(64) NOT NULL COMMENT 'Twitter推文ID(API返回的id_str)',
`twitter_user_id` varchar(64) NOT NULL COMMENT '推文作者ID(关联influencers.twitter_user_id)',
`content` text NOT NULL COMMENT '推文内容(API返回的text)',
`created_at` datetime NOT NULL COMMENT '发布时间(API返回的created_at,转本地时间)',
`lang` varchar(16) DEFAULT '' COMMENT '语言(API返回的lang)',
`source` varchar(128) DEFAULT '' COMMENT '发布来源(API返回的source)',
`is_original` tinyint NOT NULL DEFAULT '1' COMMENT '是否原创(1=原创,0=转发/引用)',
`referenced_tweet_id` varchar(64) DEFAULT '' COMMENT '引用/转发的推文ID(API返回的referenced_tweets.id_str)',
`referenced_tweet_type` varchar(32) DEFAULT '' COMMENT '引用类型(retweeted、quoted、replied_to)',
`public_metrics_like_count` int NOT NULL DEFAULT '0' COMMENT '点赞数(API返回的public_metrics.like_count)',
`public_metrics_retweet_count` int NOT NULL DEFAULT '0' COMMENT '转发数(API返回的public_metrics.retweet_count)',
`public_metrics_reply_count` int NOT NULL DEFAULT '0' COMMENT '回复数(API返回的public_metrics.reply_count)',
`public_metrics_quote_count` int NOT NULL DEFAULT '0' COMMENT '引用数(API返回的public_metrics.quote_count)',
`public_metrics_impression_count` bigint NOT NULL DEFAULT '0' COMMENT '曝光量(API返回的public_metrics.impression_count)',
`pulled_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '拉取时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tweet_id` (`tweet_id`),
KEY `idx_twitter_user_id` (`twitter_user_id`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_tweet_influencer` FOREIGN KEY (`twitter_user_id`) REFERENCES `influencers` (`twitter_user_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Twitter推文原始表';
3. 推文情感分析结果表(tweet_sentiments)
存储情感分析结果,与推文表一对一关联
CREATE TABLE `tweet_sentiments` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tweet_id` varchar(64) NOT NULL COMMENT '关联推文ID(tweets.tweet_id)',
`sentiment_score` decimal(5,4) NOT NULL COMMENT '情感得分(-1~1,越接近1越正面)',
`sentiment_label` varchar(16) NOT NULL COMMENT '情感标签(positive/negative/neutral)',
`confidence` decimal(5,4) NOT NULL DEFAULT '0.0000' COMMENT '分析置信度(0~1)',
`keyword_score` decimal(5,4) NOT NULL DEFAULT '0.0000' COMMENT '领域关键词加权分(如比特币相关词汇)',
`similar_tweet_count` int NOT NULL DEFAULT '0' COMMENT '相似历史推文数量(用于优化得分)',
`analyzed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '分析时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tweet_id` (`tweet_id`),
CONSTRAINT `fk_sentiment_tweet` FOREIGN KEY (`tweet_id`) REFERENCES `tweets` (`tweet_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='推文情感分析结果表';
4. 情感词典表(sentiment_keywords)
存储领域情感关键词(优化情感分析精度)
CREATE TABLE `sentiment_keywords` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`keyword` varchar(64) NOT NULL COMMENT '情感关键词(如bullish、crash、FOMO)',
`weight` decimal(3,2) NOT NULL COMMENT '情感权重(正数=正面,负数=负面,绝对值越大权重越高)',
`category` varchar(32) NOT NULL DEFAULT 'crypto' COMMENT '关键词分类(crypto/stock/tech等)',
`frequency` int NOT NULL DEFAULT '0' COMMENT '使用频率(优化关键词优先级)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '添加时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_keyword_category` (`keyword`,`category`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='情感分析关键词词典表';
三、GORM Model定义(Go代码)
在 internal/model/ 目录下创建模型文件,与数据库表一一对应:
1. 大V模型(internal/model/influencer.go)
package model
import (
"time"
"gorm.io/gorm"
)
// Influencer 大V订阅模型(对应Twitter API /users/by/username响应)
type Influencer struct {
ID uint64 `gorm:"column:id;type:bigint unsigned;primaryKey;autoIncrement" json:"id"`
TwitterUserID string `gorm:"column:twitter_user_id;type:varchar(64);not null;uniqueIndex" json:"twitter_user_id"` // Twitter官方ID(id_str)
Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 用户名(如elonmusk)
Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 昵称
AvatarURL string `gorm:"column:avatar_url;type:varchar(255);default:''" json:"avatar_url"` // 头像URL
Description string `gorm:"column:description;type:text" json:"description"` // 个人简介
Location string `gorm:"column:location;type:varchar(128);default:''" json:"location"` // 所在地
URL string `gorm:"column:url;type:varchar(255);default:''" json:"url"` // 个人网站
PublicMetricsFollowers int64 `gorm:"column:public_metrics_followers;type:bigint;not null;default:0" json:"public_metrics_followers"` // 粉丝数
PublicMetricsFollowing int `gorm:"column:public_metrics_following;type:int;not null;default:0" json:"public_metrics_following"` // 关注数
PublicMetricsTweetCount int `gorm:"column:public_metrics_tweet_count;type:int;not null;default:0" json:"public_metrics_tweet_count"` // 推文总数
IsVerified int8 `gorm:"column:is_verified;type:tinyint;not null;default:0" json:"is_verified"` // 是否认证(1=是)
IsActive int8 `gorm:"column:is_active;type:tinyint;not null;default:1" json:"is_active"` // 是否活跃拉取
PullFrequency int `gorm:"column:pull_frequency;type:int;not null;default:30" json:"pull_frequency"` // 拉取频率(分钟)
LastPullTweetID string `gorm:"column:last_pull_tweet_id;type:varchar(64);default:''" json:"last_pull_tweet_id"` // 上次拉取的最后一条推文ID
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null;default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null;default:current_timestamp;autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:datetime;index" json:"-"` // 软删除
}
// TableName 表名映射
func (Influencer) TableName() string {
return "influencers"
}
2. 推文模型(internal/model/tweet.go)
package model
import (
"time"
"gorm.io/gorm"
)
// Tweet 推文原始模型(对应Twitter API /users/:id/tweets响应)
type Tweet struct {
ID uint64 `gorm:"column:id;type:bigint unsigned;primaryKey;autoIncrement" json:"id"`
TweetID string `gorm:"column:tweet_id;type:varchar(64);not null;uniqueIndex" json:"tweet_id"` // Twitter推文ID(id_str)
TwitterUserID string `gorm:"column:twitter_user_id;type:varchar(64);not null;index" json:"twitter_user_id"` // 作者ID
Content string `gorm:"column:content;type:text;not null" json:"content"` // 推文内容
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null;index" json:"created_at"` // 发布时间(转本地时间)
Lang string `gorm:"column:lang;type:varchar(16);default:''" json:"lang"` // 语言
Source string `gorm:"column:source;type:varchar(128);default:''" json:"source"` // 发布来源
IsOriginal int8 `gorm:"column:is_original;type:tinyint;not null;default:1" json:"is_original"` // 是否原创
ReferencedTweetID string `gorm:"column:referenced_tweet_id;type:varchar(64);default:''" json:"referenced_tweet_id"` // 引用/转发的推文ID
ReferencedTweetType string `gorm:"column:referenced_tweet_type;type:varchar(32);default:''" json:"referenced_tweet_type"` // 引用类型
PublicMetricsLikeCount int `gorm:"column:public_metrics_like_count;type:int;not null;default:0" json:"public_metrics_like_count"` // 点赞数
PublicMetricsRetweetCount int `gorm:"column:public_metrics_retweet_count;type:int;not null;default:0" json:"public_metrics_retweet_count"` // 转发数
PublicMetricsReplyCount int `gorm:"column:public_metrics_reply_count;type:int;not null;default:0" json:"public_metrics_reply_count"` // 回复数
PublicMetricsQuoteCount int `gorm:"column:public_metrics_quote_count;type:int;not null;default:0" json:"public_metrics_quote_count"` // 引用数
PublicMetricsImpressionCount int64 `gorm:"column:public_metrics_impression_count;type:bigint;not null;default:0" json:"public_metrics_impression_count"` // 曝光量
PulledAt time.Time `gorm:"column:pulled_at;type:datetime;not null;default:current_timestamp" json:"pulled_at"` // 拉取时间
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:datetime;index" json:"-"` // 软删除
}
// TableName 表名映射
func (Tweet) TableName() string {
return "tweets"
}
3. 情感分析结果模型(internal/model/tweet_sentiment.go)
package model
import (
"time"
"gorm.io/gorm"
)
// TweetSentiment 推文情感分析结果模型
type TweetSentiment struct {
ID uint64 `gorm:"column:id;type:bigint unsigned;primaryKey;autoIncrement" json:"id"`
TweetID string `gorm:"column:tweet_id;type:varchar(64);not null;uniqueIndex" json:"tweet_id"` // 关联推文ID
SentimentScore float64 `gorm:"column:sentiment_score;type:decimal(5,4);not null" json:"sentiment_score"` // 情感得分(-1~1)
SentimentLabel string `gorm:"column:sentiment_label;type:varchar(16);not null" json:"sentiment_label"` // 情感标签
Confidence float64 `gorm:"column:confidence;type:decimal(5,4);not null;default:0.0000" json:"confidence"` // 置信度
KeywordScore float64 `gorm:"column:keyword_score;type:decimal(5,4);not null;default:0.0000" json:"keyword_score"` // 关键词加权分
SimilarTweetCount int `gorm:"column:similar_tweet_count;type:int;not null;default:0" json:"similar_tweet_count"` // 相似推文数量
AnalyzedAt time.Time `gorm:"column:analyzed_at;type:datetime;not null;default:current_timestamp" json:"analyzed_at"` // 分析时间
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:datetime;index" json:"-"` // 软删除
}
// TableName 表名映射
func (TweetSentiment) TableName() string {
return "tweet_sentiments"
}
4. 情感关键词模型(internal/model/sentiment_keyword.go)
package model
import (
"time"
"gorm.io/gorm"
)
// SentimentKeyword 情感关键词模型
type SentimentKeyword struct {
ID uint64 `gorm:"column:id;type:bigint unsigned;primaryKey;autoIncrement" json:"id"`
Keyword string `gorm:"column:keyword;type:varchar(64);not null" json:"keyword"` // 关键词
Weight float64 `gorm:"column:weight;type:decimal(3,2);not null" json:"weight"` // 情感权重
Category string `gorm:"column:category;type:varchar(32);not null;default:'crypto'" json:"category"` // 分类
Frequency int `gorm:"column:frequency;type:int;not null;default:0" json:"frequency"` // 使用频率
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null;default:current_timestamp" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null;default:current_timestamp;autoUpdateTime" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:datetime;index" json:"-"` // 软删除
}
// TableName 表名映射
func (SentimentKeyword) TableName() string {
return "sentiment_keywords"
}
四、数据库初始化与GORM配置
1. 配置文件(configs/config.yaml)
# 数据库配置
mysql:
dsn: "root:123456@tcp(127.0.0.1:3306)/twitter_sentiment?charset=utf8mb4&parseTime=True&loc=Local"
max_open_conns: 20
max_idle_conns: 10
conn_max_lifetime: 3600 # 连接最大存活时间(秒)
# Twitter API配置(对应开发者平台的Token)
twitter:
api_key: "your_api_key"
api_secret: "your_api_secret"
access_token: "your_access_token"
access_token_secret: "your_access_token_secret"
api_base_url: "https://api.x.com/2" # Twitter API v2基础地址
# Qdrant配置
qdrant:
addr: "127.0.0.1:6333"
collection_name: "tweet_embeddings"
vector_dim: 384 # 与sentence-transformers模型维度一致
# 定时任务配置
cron:
pull_tweet_cron: "0 */30 * * * ?" # 每30分钟拉取一次推文
analyze_sentiment_cron: "0 */10 * * * ?" # 每10分钟分析一次未处理的推文
# 服务配置
server:
port: 8080
mode: "debug" # debug/release
2. GORM初始化(internal/config/db.go)
package config
import (
"log"
"time"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"twitter-sentiment/internal/model"
)
// DB 全局数据库连接
var DB *gorm.DB
// InitDB 初始化数据库连接并自动迁移表结构
func InitDB() {
dsn := viper.GetString("mysql.dsn")
maxOpenConns := viper.GetInt("mysql.max_open_conns")
maxIdleConns := viper.GetInt("mysql.max_idle_conns")
connMaxLifetime := viper.GetInt("mysql.conn_max_lifetime")
// 初始化GORM
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), // 打印SQL日志(debug模式)
NamingStrategy: schema.NamingStrategy{
SingularTable: true, // 禁用表名复数
},
})
if err != nil {
log.Fatalf("初始化数据库失败:%v", err)
}
// 配置连接池
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("获取数据库连接池失败:%v", err)
}
sqlDB.SetMaxOpenConns(maxOpenConns) // 最大打开连接数
sqlDB.SetMaxIdleConns(maxIdleConns) // 最大空闲连接数
sqlDB.SetConnMaxLifetime(time.Duration(connMaxLifetime) * time.Second) // 连接最大存活时间
// 自动迁移表结构(创建不存在的表,不删除已有字段)
err = db.AutoMigrate(
&model.Influencer{},
&model.Tweet{},
&model.TweetSentiment{},
&model.SentimentKeyword{},
)
if err != nil {
log.Fatalf("自动迁移表结构失败:%v", err)
}
DB = db
log.Println("数据库初始化成功")
}
五、核心API实现(Gin路由)
在 internal/handler/ 目录下实现API接口,路由注册在 internal/server/router.go:
1. 路由注册(internal/server/router.go)
package server
import (
"github.com/gin-gonic/gin"
"twitter-sentiment/internal/handler"
)
// InitRouter 初始化Gin路由
func InitRouter() *gin.Engine {
r := gin.Default()
// 基础路由组
api := r.Group("/api/v1")
{
// 大V相关接口
influencer := api.Group("/influencers")
{
influencer.GET("", handler.GetInfluencers) // 获取所有订阅大V
influencer.POST("", handler.AddInfluencer) // 添加订阅大V
influencer.GET("/:username", handler.GetInfluencerByUsername) // 获取单个大V详情
influencer.PUT("/:username/active", handler.UpdateInfluencerActive) // 暂停/恢复拉取
// 推文相关接口
influencer.GET("/:username/tweets", handler.GetInfluencerTweets) // 获取大V推文
influencer.GET("/:username/sentiment", handler.GetInfluencerSentimentSummary) // 获取大V情感分析汇总
}
// 情感关键词接口
keyword := api.Group("/keywords")
{
keyword.POST("", handler.AddSentimentKeyword) // 添加情感关键词
keyword.GET("/:category", handler.GetKeywordsByCategory) // 获取指定分类的关键词
}
}
return r
}
2. 核心接口实现示例(internal/handler/influencer.go)
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"twitter-sentiment/internal/config"
"twitter-sentiment/internal/model"
"twitter-sentiment/internal/twitter"
)
// GetInfluencers 获取所有订阅大V
func GetInfluencers(c *gin.Context) {
var influencers []model.Influencer
if err := config.DB.Find(&influencers).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "查询大V列表失败:" + err.Error(),
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"message": "success",
"data": influencers,
})
}
// AddInfluencer 添加订阅大V(通过Twitter用户名)
func AddInfluencer(c *gin.Context) {
type Request struct {
Username string `json:"username" binding:"required,min=1,max=64"` // Twitter用户名
PullFrequency int `json:"pull_frequency" binding:"min=5,max=1440"` // 拉取频率(分钟,5~1440)
}
var req Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": "参数校验失败:" + err.Error(),
"data": nil,
})
return
}
// 1. 检查是否已订阅
var existing model.Influencer
if err := config.DB.Where("username = ?", req.Username).First(&existing).Error; err != gorm.ErrRecordNotFound {
c.JSON(http.StatusConflict, gin.H{
"code": 409,
"message": "该大V已订阅",
"data": nil,
})
return
}
// 2. 调用Twitter API获取大V详情
twitterClient := twitter.NewClient()
influencerInfo, err := twitterClient.GetUserByUsername(req.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "获取Twitter大V信息失败:" + err.Error(),
"data": nil,
})
return
}
// 3. 保存到数据库
newInfluencer := model.Influencer{
TwitterUserID: influencerInfo.TwitterUserID,
Username: influencerInfo.Username,
Name: influencerInfo.Name,
AvatarURL: influencerInfo.AvatarURL,
Description: influencerInfo.Description,
Location: influencerInfo.Location,
URL: influencerInfo.URL,
PublicMetricsFollowers: influencerInfo.PublicMetricsFollowers,
PublicMetricsFollowing: influencerInfo.PublicMetricsFollowing,
PublicMetricsTweetCount: influencerInfo.PublicMetricsTweetCount,
IsVerified: influencerInfo.IsVerified,
IsActive: 1,
PullFrequency: req.PullFrequency,
LastPullTweetID: "",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := config.DB.Create(&newInfluencer).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "保存大V信息失败:" + err.Error(),
"data": nil,
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"message": "订阅大V成功",
"data": newInfluencer,
})
}
// GetInfluencerTweets 获取大V推文(支持分页、时间筛选)
func GetInfluencerTweets(c *gin.Context) {
username := c.Param("username")
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("pageSize", "20")
startTimeStr := c.Query("startTime")
endTimeStr := c.Query("endTime")
// 解析分页参数
page, _ := strconv.Atoi(pageStr)
pageSize, _ := strconv.Atoi(pageSizeStr)
offset := (page - 1) * pageSize
// 1. 获取大V的TwitterUserID
var influencer model.Influencer
if err := config.DB.Where("username = ?", username).First(&influencer).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"code": 404,
"message": "未找到该大V",
"data": nil,
})
return
}
// 2. 构建查询条件
db := config.DB.Model(&model.Tweet{}).Where("twitter_user_id = ?", influencer.TwitterUserID)
// 时间筛选
if startTimeStr != "" {
startTime, err := time.Parse("2006-01-02 15:04:05", startTimeStr)
if err == nil {
db = db.Where("created_at >= ?", startTime)
}
}
if endTimeStr != "" {
endTime, err := time.Parse("2006-01-02 15:04:05", endTimeStr)
if err == nil {
db = db.Where("created_at <= ?", endTime)
}
}
// 3. 分页查询
var tweets []model.Tweet
var total int64
if err := db.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "查询推文总数失败:" + err.Error(),
"data": nil,
})
return
}
if err := db.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&tweets).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "查询推文列表失败:" + err.Error(),
"data": nil,
})
return
}
// 4. 关联查询情感分析结果
tweetIDs := make([]string, len(tweets))
for i, tweet := range tweets {
tweetIDs[i] = tweet.TweetID
}
var sentiments []model.TweetSentiment
if err := config.DB.Where("tweet_id IN (?)", tweetIDs).Find(&sentiments).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "查询情感分析结果失败:" + err.Error(),
"data": nil,
})
return
}
// 5. 组装结果(推文+情感分析)
type TweetWithSentiment struct {
model.Tweet
Sentiment *model.TweetSentiment `json:"sentiment,omitempty"`
}
result := make([]TweetWithSentiment, len(tweets))
sentimentMap := make(map[string]*model.TweetSentiment)
for i, s := range sentiments {
sentimentMap[s.TweetID] = &sentiments[i]
}
for i, tweet := range tweets {
result[i] = TweetWithSentiment{
Tweet: tweet,
Sentiment: sentimentMap[tweet.TweetID],
}
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"message": "success",
"data": gin.H{
"list": result,
"total": total,
"page": page,
"size": pageSize,
},
})
}
六、Twitter API客户端封装(internal/twitter/client.go)
与Twitter API v2对接,拉取大V信息和推文(字段与Model对齐):
package twitter
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/dghubble/oauth1"
"twitter-sentiment/internal/config"
"github.com/spf13/viper"
)
// TwitterClient Twitter API客户端
type TwitterClient struct {
client *http.Client
baseURL string
}
// UserInfo Twitter大V信息(与API响应对齐)
type UserInfo struct {
TwitterUserID string `json:"twitter_user_id"`
Username string `json:"username"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
Description string `json:"description"`
Location string `json:"location"`
URL string `json:"url"`
PublicMetricsFollowers int64 `json:"public_metrics_followers"`
PublicMetricsFollowing int `json:"public_metrics_following"`
PublicMetricsTweetCount int `json:"public_metrics_tweet_count"`
IsVerified int8 `json:"is_verified"`
}
// NewClient 创建Twitter API客户端
func NewClient() *TwitterClient {
// 从配置文件读取Token
apiKey := viper.GetString("twitter.api_key")
apiSecret := viper.GetString("twitter.api_secret")
accessToken := viper.GetString("twitter.access_token")
accessTokenSecret := viper.GetString("twitter.access_token_secret")
// 初始化OAuth1认证
config := oauth1.NewConfig(apiKey, apiSecret)
token := oauth1.NewToken(accessToken, accessTokenSecret)
httpClient := config.Client(oauth1.NoContext, token)
return &TwitterClient{
client: httpClient,
baseURL: viper.GetString("twitter.api_base_url"),
}
}
// GetUserByUsername 通过用户名获取大V信息(调用API /users/by/username/:username)
func (c *TwitterClient) GetUserByUsername(username string) (*UserInfo, error) {
url := fmt.Sprintf("%s/users/by/username/%s?user.fields=id,name,username,profile_image_url,description,location,url,public_metrics,verified", c.baseURL, username)
resp, err := c.client.Get(url)
if err != nil {
return nil, fmt.Errorf("API请求失败:%v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API返回错误状态码:%d", resp.StatusCode)
}
// 解析API响应(Twitter API v2响应格式)
type APIResponse struct {
Data struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
ProfileImageURL string `json:"profile_image_url"`
Description string `json:"description"`
Location string `json:"location"`
URL string `json:"url"`
Verified bool `json:"verified"`
PublicMetrics struct {
FollowersCount int64 `json:"followers_count"`
FollowingCount int `json:"following_count"`
TweetCount int `json:"tweet_count"`
} `json:"public_metrics"`
} `json:"data"`
}
var apiResp APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("解析API响应失败:%v", err)
}
// 转换为本地Model结构
return &UserInfo{
TwitterUserID: apiResp.Data.ID,
Username: apiResp.Data.Username,
Name: apiResp.Data.Name,
AvatarURL: apiResp.Data.ProfileImageURL,
Description: apiResp.Data.Description,
Location: apiResp.Data.Location,
URL: apiResp.Data.URL,
PublicMetricsFollowers: apiResp.Data.PublicMetrics.FollowersCount,
PublicMetricsFollowing: apiResp.Data.PublicMetrics.FollowingCount,
PublicMetricsTweetCount: apiResp.Data.PublicMetrics.TweetCount,
IsVerified: func() int8 { if apiResp.Data.Verified { return 1 } else { return 0 } }(),
}, nil
}
// PullTweets 拉取大V推文(增量拉取,调用API /users/:id/tweets)
func (c *TwitterClient) PullTweets(twitterUserID, lastTweetID string, maxResults int) ([]model.Tweet, error) {
// 构建API参数(增量拉取:since_id=lastTweetID,只拉取比上次更新的推文)
params := fmt.Sprintf("tweet.fields=id,text,created_at,lang,source,public_metrics,referenced_tweets&max_results=%d", maxResults)
if lastTweetID != "" {
params += fmt.Sprintf("&since_id=%s", lastTweetID)
}
url := fmt.Sprintf("%s/users/%s/tweets?%s", c.baseURL, twitterUserID, params)
resp, err := c.client.Get(url)
if err != nil {
return nil, fmt.Errorf("API请求失败:%v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API返回错误状态码:%d", resp.StatusCode)
}
// 解析API响应
type ReferencedTweet struct {
ID string `json:"id"`
Type string `json:"type"`
}
type APITweet struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at"` // ISO8601格式(如2025-11-16T12:00:00Z)
Lang string `json:"lang"`
Source string `json:"source"`
ReferencedTweets []ReferencedTweet `json:"referenced_tweets"`
PublicMetrics struct {
LikeCount int `json:"like_count"`
RetweetCount int `json:"retweet_count"`
ReplyCount int `json:"reply_count"`
QuoteCount int `json:"quote_count"`
ImpressionCount int64 `json:"impression_count"`
} `json:"public_metrics"`
}
type APIResponse struct {
Data []APITweet `json:"data"`
}
var apiResp APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("解析API响应失败:%v", err)
}
// 转换为本地Tweet模型
tweets := make([]model.Tweet, 0, len(apiResp.Data))
for _, apiTweet := range apiResp.Data {
// 解析发布时间(ISO8601转本地时间)
createdAt, err := time.Parse(time.RFC3339, apiTweet.CreatedAt)
if err != nil {
continue // 时间解析失败则跳过
}
// 处理引用/转发信息
isOriginal := int8(1)
referencedTweetID := ""
referencedTweetType := ""
if len(apiTweet.ReferencedTweets) > 0 {
isOriginal = 0
referencedTweetID = apiTweet.ReferencedTweets[0].ID
referencedTweetType = apiTweet.ReferencedTweets[0].Type
}
tweets = append(tweets, model.Tweet{
TweetID: apiTweet.ID,
TwitterUserID: twitterUserID,
Content: apiTweet.Text,
CreatedAt: createdAt.Local(),
Lang: apiTweet.Lang,
Source: apiTweet.Source,
IsOriginal: isOriginal,
ReferencedTweetID: referencedTweetID,
ReferencedTweetType: referencedTweetType,
PublicMetricsLikeCount: apiTweet.PublicMetrics.LikeCount,
PublicMetricsRetweetCount: apiTweet.PublicMetrics.RetweetCount,
PublicMetricsReplyCount: apiTweet.PublicMetrics.ReplyCount,
PublicMetricsQuoteCount: apiTweet.PublicMetrics.QuoteCount,
PublicMetricsImpressionCount: apiTweet.PublicMetrics.ImpressionCount,
PulledAt: time.Now(),
})
}
return tweets, nil
}
七、项目启动入口(main.go)
package main
import (
"log"
"net/http"
"time"
"github.com/spf13/viper"
"twitter-sentiment/internal/config"
"twitter-sentiment/internal/server"
"twitter-sentiment/internal/cron"
)
func main() {
// 1. 加载配置文件
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./configs")
if err := viper.ReadInConfig(); err != nil {
log.Fatalf("加载配置文件失败:%v", err)
}
// 2. 初始化数据库
config.InitDB()
// 3. 启动定时任务(拉取推文、情感分析)
cron.InitCron()
// 4. 启动HTTP服务
r := server.InitRouter()
port := viper.GetString("server.port")
srv := &http.Server{
Addr: ":" + port,
Handler: r,
}
log.Printf("服务启动成功,监听端口:%s", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务启动失败:%v", err)
}
}
八、关键依赖与安装命令
1. go.mod 核心依赖
module twitter-sentiment
go 1.21
require (
github.com/dghubble/oauth1 v1.1.0 // Twitter API OAuth1认证
github.com/gin-gonic/gin v1.9.1 // Web框架
github.com/spf13/viper v1.18.2 // 配置文件解析
gorm.io/driver/mysql v1.5.5 // MySQL驱动
gorm.io/gorm v1.25.4 // ORM框架
github.com/robfig/cron/v3 v3.0.1 // 定时任务
github.com/jdkato/prose v1.2.1 // 文本情感分析基础库
github.com/qdrant/go-client/v2 v2.12.0 // Qdrant向量数据库客户端
)
2. 依赖安装命令
# 初始化项目
go mod init twitter-sentiment
# 安装核心依赖
go get github.com/gin-gonic/gin@v1.9.1
go get gorm.io/gorm@v1.25.4
go get gorm.io/driver/mysql@v1.5.5
go get github.com/spf13/viper@v1.18.2
go get github.com/dghubble/oauth1@v1.1.0
go get github.com/robfig/cron/v3@v3.0.1
go get github.com/jdkato/prose@v1.2.1
go get github.com/qdrant/go-client/v2@v2.12.0
# 下载依赖
go mod download
九、核心特性总结
- 字段对齐:数据库表和Model严格对应Twitter API v2返回字段,确保数据不丢失。
- 增量拉取:通过
last_pull_tweet_id记录上次拉取位置,避免重复拉取推文。 - 情感分析:分离推文原始数据和情感分析结果,支持后续优化算法。
- 高性能:GORM连接池配置、批量插入、索引优化,支持高并发查询。
- 可扩展:模块化设计(Twitter客户端、情感分析、向量存储),便于后续迭代。
3725

被折叠的 条评论
为什么被折叠?



