feat: 部署初版测试
This commit is contained in:
49
server/.env.example
Normal file
49
server/.env.example
Normal file
@@ -0,0 +1,49 @@
|
||||
# ====================================
|
||||
# InsightReply 后端环境变量模板
|
||||
# 复制此文件为 .env 并填入真实值
|
||||
# cp .env.example .env
|
||||
# ====================================
|
||||
|
||||
# ---- 数据库 ----
|
||||
# DATABASE_URL=postgres://root:QG#7X*HHt3CqbZ@100.64.0.5:5432/InsightReply?sslmode=disable
|
||||
DATABASE_URL=postgres://root:QG%237X%2AHHt3CqbZ@100.64.0.5:5432/InsightReply?sslmode=disable
|
||||
|
||||
|
||||
# ---- LLM Configuration (Multi-Provider Support) ----
|
||||
# Supported Providers: openai, anthropic, deepseek, gemini
|
||||
LLM_PROVIDER=gemini
|
||||
LLM_MODEL=gemini-2.5-flash
|
||||
|
||||
# Provider: OpenAI (or compatible: Groq, Ollama, vLLM)
|
||||
OPENAI_API_KEY=sk-your-openai-api-key
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1 # 留空使用官方默认,可改写为代理地址
|
||||
OPENAI_AVAILABLE_MODELS=gpt-4o,gpt-4o-mini,o1-mini # 前端下拉菜单展示的模型列表,逗号分隔
|
||||
|
||||
# Provider: Anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-your-anthropic-api-key
|
||||
ANTHROPIC_BASE_URL=
|
||||
ANTHROPIC_AVAILABLE_MODELS=claude-3-5-sonnet-latest,claude-3-5-haiku-latest
|
||||
|
||||
# Provider: DeepSeek
|
||||
DEEPSEEK_API_KEY=sk-your-deepseek-api-key
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
DEEPSEEK_AVAILABLE_MODELS=deepseek-chat,deepseek-reasoner
|
||||
|
||||
# Provider: Gemini
|
||||
GEMINI_API_KEY=AIzaSy-your-gemini-api-key
|
||||
GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/models
|
||||
GEMINI_AVAILABLE_MODELS=gemini-2.5-flash,gemini-2.5-pro
|
||||
|
||||
# ---- JWT Authentication ----
|
||||
JWT_SECRET=your-random-32-char-secret-key-here
|
||||
|
||||
# ---- 服务器 ----
|
||||
SERVER_PORT=8080
|
||||
LOG_LEVEL=info
|
||||
|
||||
# ---- CORS ----
|
||||
CORS_ORIGINS=chrome-extension://*
|
||||
|
||||
# ---- LLM 韧性参数 ----
|
||||
LLM_TIMEOUT_SEC=30
|
||||
LLM_MAX_RETRIES=2
|
||||
@@ -10,6 +10,9 @@ WORKDIR /app
|
||||
COPY server_bin .
|
||||
RUN chmod +x server_bin
|
||||
|
||||
# 拷贝数据库迁移文件 (服务启动时自动执行)
|
||||
COPY migrations ./migrations
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./server_bin"]
|
||||
|
||||
BIN
server/bin/server
Executable file
BIN
server/bin/server
Executable file
Binary file not shown.
@@ -1,53 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
chiMiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/zs/InsightReply/internal/handler"
|
||||
appMiddleware "github.com/zs/InsightReply/internal/middleware"
|
||||
"github.com/zs/InsightReply/internal/repository"
|
||||
"github.com/zs/InsightReply/internal/service"
|
||||
"github.com/zs/InsightReply/internal/worker"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Database connection (Using the provided string)
|
||||
dsn := "postgres://root:QG%237X*HHt3CqbZ@100.64.0.5:5432/InsightReply?sslmode=disable"
|
||||
// 1. Load environment variables
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found, relying on system environment variables")
|
||||
}
|
||||
|
||||
// 1.5 Setup application file logging
|
||||
if logPath := os.Getenv("LOG_FILE_PATH"); logPath != "" {
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err == nil {
|
||||
multiWriter := io.MultiWriter(os.Stdout, logFile)
|
||||
log.SetOutput(multiWriter)
|
||||
log.Printf("Application logs are now being mirrored to: %s", logPath)
|
||||
} else {
|
||||
log.Printf("Failed to open log file %s: %v", logPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Database connection setup
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
log.Fatal("DATABASE_URL environment variable is required")
|
||||
}
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal("failed to connect database:", err)
|
||||
}
|
||||
|
||||
fmt.Println("Database connection established")
|
||||
|
||||
// Initialize Layers
|
||||
// 2.1 Run Database Migrations
|
||||
log.Println("Running database migrations...")
|
||||
m, err := migrate.New("file://migrations", dsn)
|
||||
if err != nil {
|
||||
log.Printf("Failed to initialize migrate, skipping: %v", err)
|
||||
} else {
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
log.Printf("Failed to run migrate (maybe tables already exist), continuing: %v", err)
|
||||
} else {
|
||||
log.Println("Database migrations applied successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Initialize Layers
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
userSvc := service.NewUserService(userRepo)
|
||||
userHandler := handler.NewUserHandler(userSvc)
|
||||
|
||||
// AI Service (Using Env Var for API Key)
|
||||
apiKey := os.Getenv("OPENAI_API_KEY")
|
||||
aiSvc := service.NewAIService(apiKey)
|
||||
aiHandler := handler.NewAIHandler(aiSvc)
|
||||
profileRepo := repository.NewProductProfileRepository(db)
|
||||
profileSvc := service.NewProductProfileService(profileRepo)
|
||||
profileHandler := handler.NewProductProfileHandler(profileSvc)
|
||||
|
||||
// Router setup
|
||||
strategyRepo := repository.NewCustomStrategyRepository(db)
|
||||
strategySvc := service.NewCustomStrategyService(strategyRepo)
|
||||
strategyHandler := handler.NewCustomStrategyHandler(strategySvc)
|
||||
|
||||
monitorRepo := repository.NewCompetitorMonitorRepository(db)
|
||||
monitorSvc := service.NewCompetitorMonitorService(monitorRepo)
|
||||
monitorHandler := handler.NewCompetitorMonitorHandler(monitorSvc)
|
||||
|
||||
// AI Service (Multi-LLM Routing Support)
|
||||
aiSvc := service.NewAIService()
|
||||
aiHandler := handler.NewAIHandler(aiSvc, profileSvc, strategySvc)
|
||||
|
||||
// Start Background Workers & Tweet Handling
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel() // Ensure cleanup on exit
|
||||
tweetRepo := repository.NewTweetRepository(db)
|
||||
replyRepo := repository.NewReplyRepository(db)
|
||||
|
||||
tweetHandler := handler.NewTweetHandler(tweetRepo) // Mount API handler
|
||||
replyHandler := handler.NewReplyHandler(replyRepo)
|
||||
|
||||
monitorWorker := worker.NewMonitorWorker(monitorRepo, tweetRepo)
|
||||
go monitorWorker.Start(ctx, 15*time.Minute)
|
||||
|
||||
performanceWorker := worker.NewPerformanceWorker(replyRepo, aiSvc)
|
||||
go performanceWorker.Start(ctx, 30*time.Minute) // Runs every 30 minutes to check 24h+ old replies
|
||||
|
||||
// 4. Router setup
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(chiMiddleware.Logger)
|
||||
r.Use(chiMiddleware.Recoverer)
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Post("/users/register", userHandler.Register)
|
||||
r.Get("/ai/test", aiHandler.Test)
|
||||
r.Post("/ai/generate", aiHandler.Generate)
|
||||
// CORS Configuration
|
||||
corsOrigins := os.Getenv("CORS_ORIGINS")
|
||||
if corsOrigins == "" {
|
||||
corsOrigins = "*" // default fallback
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{corsOrigins},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// 5. Routes
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"code":200,"message":"ok","data":{"status":"healthy","version":"1.0.0"}}`))
|
||||
})
|
||||
|
||||
fmt.Println("Server starting on :8080")
|
||||
if err := http.ListenAndServe(":8080", r); err != nil {
|
||||
log.Fatal(err)
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Public routes
|
||||
r.Post("/users/register", userHandler.Register)
|
||||
|
||||
// Protected routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(appMiddleware.JWTAuth)
|
||||
r.Use(appMiddleware.RateLimit(db))
|
||||
|
||||
// User and Profile APIs
|
||||
r.Get("/users/me", userHandler.GetProfile)
|
||||
r.Put("/users/me/preferences", userHandler.UpdatePreferences)
|
||||
r.Get("/users/me/product_profiles", profileHandler.GetProfile)
|
||||
r.Put("/users/me/product_profiles", profileHandler.SaveProfile)
|
||||
|
||||
// Strategy APIs
|
||||
r.Get("/users/me/strategies", strategyHandler.ListStrategies)
|
||||
r.Post("/users/me/strategies", strategyHandler.CreateStrategy)
|
||||
r.Delete("/users/me/strategies/{id}", strategyHandler.DeleteStrategy)
|
||||
|
||||
// Monitor APIs
|
||||
r.Get("/monitors/competitors", monitorHandler.ListMonitors)
|
||||
r.Post("/monitors/competitors", monitorHandler.CreateMonitor)
|
||||
r.Delete("/monitors/competitors/{id}", monitorHandler.DeleteMonitor)
|
||||
|
||||
// Hot Opportunity Tweers API
|
||||
r.Get("/tweets/hot", tweetHandler.GetHotTweets)
|
||||
r.Get("/tweets/search", tweetHandler.GetSearchTweets)
|
||||
|
||||
// AI APIs
|
||||
r.Get("/ai/test", aiHandler.Test)
|
||||
r.Post("/ai/generate", aiHandler.Generate)
|
||||
r.Post("/replies/record", replyHandler.RecordReply)
|
||||
r.Get("/replies", replyHandler.GetReplies)
|
||||
})
|
||||
})
|
||||
|
||||
// 6. Graceful Shutdown Setup
|
||||
port := os.Getenv("SERVER_PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
serverErrors := make(chan error, 1)
|
||||
|
||||
// Start the server
|
||||
go func() {
|
||||
fmt.Printf("Server starting on :%s\n", port)
|
||||
serverErrors <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
// Channel to listen for an interrupt or terminate signal from the OS.
|
||||
shutdown := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Blocking main and waiting for shutdown.
|
||||
select {
|
||||
case err := <-serverErrors:
|
||||
log.Fatalf("Error starting server: %v", err)
|
||||
case sig := <-shutdown:
|
||||
log.Printf("Start shutdown... signal: %v", sig)
|
||||
|
||||
// Create context with timeout for shutdown
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("Graceful shutdown did not complete in time: %v", err)
|
||||
if err := srv.Close(); err != nil {
|
||||
log.Fatalf("Could not stop server gracefully: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Println("Server stopped")
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ services:
|
||||
container_name: insight-reply-server
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "8009:8080"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# 这里可以读取宿主机的环境变量或 .env 文件内容
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- LOG_FILE_PATH=/app/logs/InsightReply.log
|
||||
volumes:
|
||||
# Map the host's /root/logs to the container's /app/logs directory
|
||||
- /root/logs:/app/logs
|
||||
networks:
|
||||
- insight_network
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
module github.com/zs/InsightReply
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-chi/cors v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -11,10 +16,16 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/sashabaranov/go-openai v1.41.2 // indirect
|
||||
github.com/sony/gobreaker/v2 v2.4.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -15,18 +26,94 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
|
||||
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
|
||||
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/zs/InsightReply/internal/service"
|
||||
)
|
||||
|
||||
type AIHandler struct {
|
||||
svc *service.AIService
|
||||
svc *service.AIService
|
||||
profileSvc *service.ProductProfileService
|
||||
strategySvc *service.CustomStrategyService
|
||||
}
|
||||
|
||||
func NewAIHandler(svc *service.AIService) *AIHandler {
|
||||
return &AIHandler{svc: svc}
|
||||
func NewAIHandler(svc *service.AIService, profileSvc *service.ProductProfileService, strategySvc *service.CustomStrategyService) *AIHandler {
|
||||
return &AIHandler{
|
||||
svc: svc,
|
||||
profileSvc: profileSvc,
|
||||
strategySvc: strategySvc,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AIHandler) Test(w http.ResponseWriter, r *http.Request) {
|
||||
// ...
|
||||
ctx := r.Context()
|
||||
msg, err := h.svc.TestConnection(ctx)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5000, err.Error())
|
||||
return
|
||||
}
|
||||
SendSuccess(w, map[string]string{"status": msg})
|
||||
}
|
||||
|
||||
func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -24,6 +37,8 @@ func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) {
|
||||
TweetContent string `json:"tweet_content"`
|
||||
Strategy string `json:"strategy"`
|
||||
Identity string `json:"identity"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
@@ -37,13 +52,56 @@ func (h *AIHandler) Generate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
reply, err := h.svc.GenerateReply(ctx, body.TweetContent, body.Strategy, body.Identity)
|
||||
userID := ctx.Value("userID").(string)
|
||||
|
||||
// Fetch Product Profile Context
|
||||
var productContext string
|
||||
if profile, err := h.profileSvc.GetProfile(userID); err == nil && profile.IsActive {
|
||||
productContext = "Product Context: " + profile.ProductName
|
||||
if profile.Tagline != "" {
|
||||
productContext += " - " + profile.Tagline
|
||||
}
|
||||
if profile.KeyFeatures != "" && profile.KeyFeatures != "[]" {
|
||||
productContext += ". Key Features: " + profile.KeyFeatures
|
||||
}
|
||||
if profile.CustomContext != "" {
|
||||
productContext += ". Context: " + profile.CustomContext
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Custom Strategies Context
|
||||
if strategies, err := h.strategySvc.ListStrategies(userID); err == nil && len(strategies) > 0 {
|
||||
productContext += "\n\nAvailable User Custom Strategies:\n"
|
||||
for _, s := range strategies {
|
||||
productContext += "- " + s.StrategyKey + " (" + s.Label + "): " + s.Description + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
replyString, err := h.svc.GenerateReply(ctx, body.TweetContent, productContext, body.Identity, body.Provider, body.Model)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusBadGateway, 5002, "Failed to generate AI reply: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, map[string]string{
|
||||
"reply": reply,
|
||||
// Clean up potential markdown wrappers from LLM output
|
||||
cleanReply := strings.TrimSpace(replyString)
|
||||
cleanReply = strings.TrimPrefix(cleanReply, "```json")
|
||||
cleanReply = strings.TrimPrefix(cleanReply, "```")
|
||||
cleanReply = strings.TrimSuffix(cleanReply, "```")
|
||||
cleanReply = strings.TrimSpace(cleanReply)
|
||||
|
||||
var replies []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(cleanReply), &replies); err != nil {
|
||||
// Fallback: return as single string object if parsing totally fails
|
||||
replies = []map[string]interface{}{
|
||||
{
|
||||
"strategy": "Fallback",
|
||||
"content": replyString,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
SendSuccess(w, map[string]interface{}{
|
||||
"replies": replies,
|
||||
})
|
||||
}
|
||||
|
||||
67
server/internal/handler/competitor_monitor_handler.go
Normal file
67
server/internal/handler/competitor_monitor_handler.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"github.com/zs/InsightReply/internal/service"
|
||||
)
|
||||
|
||||
type CompetitorMonitorHandler struct {
|
||||
svc *service.CompetitorMonitorService
|
||||
}
|
||||
|
||||
func NewCompetitorMonitorHandler(svc *service.CompetitorMonitorService) *CompetitorMonitorHandler {
|
||||
return &CompetitorMonitorHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *CompetitorMonitorHandler) ListMonitors(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("userID").(string)
|
||||
|
||||
monitors, err := h.svc.ListMonitors(userID)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5001, "Failed to list monitors")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, monitors)
|
||||
}
|
||||
|
||||
func (h *CompetitorMonitorHandler) CreateMonitor(w http.ResponseWriter, r *http.Request) {
|
||||
userIDStr := r.Context().Value("userID").(string)
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusUnauthorized, 4010, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
var monitor model.CompetitorMonitor
|
||||
if err := json.NewDecoder(r.Body).Decode(&monitor); err != nil {
|
||||
SendError(w, http.StatusBadRequest, 4001, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
monitor.UserID = userID
|
||||
|
||||
if err := h.svc.CreateMonitor(&monitor); err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5001, "Failed to create monitor")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, monitor)
|
||||
}
|
||||
|
||||
func (h *CompetitorMonitorHandler) DeleteMonitor(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("userID").(string)
|
||||
monitorID := chi.URLParam(r, "id")
|
||||
|
||||
if err := h.svc.DeleteMonitor(monitorID, userID); err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5001, "Failed to delete monitor")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, map[string]string{"status": "deleted"})
|
||||
}
|
||||
67
server/internal/handler/custom_strategy_handler.go
Normal file
67
server/internal/handler/custom_strategy_handler.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"github.com/zs/InsightReply/internal/service"
|
||||
)
|
||||
|
||||
type CustomStrategyHandler struct {
|
||||
svc *service.CustomStrategyService
|
||||
}
|
||||
|
||||
func NewCustomStrategyHandler(svc *service.CustomStrategyService) *CustomStrategyHandler {
|
||||
return &CustomStrategyHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *CustomStrategyHandler) ListStrategies(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("userID").(string)
|
||||
|
||||
strategies, err := h.svc.ListStrategies(userID)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5001, "Failed to list strategies")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, strategies)
|
||||
}
|
||||
|
||||
func (h *CustomStrategyHandler) CreateStrategy(w http.ResponseWriter, r *http.Request) {
|
||||
userIDStr := r.Context().Value("userID").(string)
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusUnauthorized, 4010, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
var strategy model.UserCustomStrategy
|
||||
if err := json.NewDecoder(r.Body).Decode(&strategy); err != nil {
|
||||
SendError(w, http.StatusBadRequest, 4001, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
strategy.UserID = userID
|
||||
|
||||
if err := h.svc.CreateStrategy(&strategy); err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5001, "Failed to create strategy")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, strategy)
|
||||
}
|
||||
|
||||
func (h *CustomStrategyHandler) DeleteStrategy(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("userID").(string)
|
||||
strategyID := chi.URLParam(r, "id")
|
||||
|
||||
if err := h.svc.DeleteStrategy(strategyID, userID); err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5001, "Failed to delete strategy")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, map[string]string{"status": "deleted"})
|
||||
}
|
||||
54
server/internal/handler/product_profile_handler.go
Normal file
54
server/internal/handler/product_profile_handler.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"github.com/zs/InsightReply/internal/service"
|
||||
)
|
||||
|
||||
type ProductProfileHandler struct {
|
||||
svc *service.ProductProfileService
|
||||
}
|
||||
|
||||
func NewProductProfileHandler(svc *service.ProductProfileService) *ProductProfileHandler {
|
||||
return &ProductProfileHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *ProductProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("userID").(string)
|
||||
|
||||
profile, err := h.svc.GetProfile(userID)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusNotFound, 4004, "Product profile not found")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, profile)
|
||||
}
|
||||
|
||||
func (h *ProductProfileHandler) SaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userIDStr := r.Context().Value("userID").(string)
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusUnauthorized, 4010, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
var profile model.UserProductProfile
|
||||
if err := json.NewDecoder(r.Body).Decode(&profile); err != nil {
|
||||
SendError(w, http.StatusBadRequest, 4001, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
profile.UserID = userID // Ensure user cannot overwrite other's profile
|
||||
|
||||
if err := h.svc.SaveProfile(&profile); err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5001, "Failed to save product profile")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, profile)
|
||||
}
|
||||
97
server/internal/handler/reply_handler.go
Normal file
97
server/internal/handler/reply_handler.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"github.com/zs/InsightReply/internal/repository"
|
||||
)
|
||||
|
||||
type ReplyHandler struct {
|
||||
repo *repository.ReplyRepository
|
||||
}
|
||||
|
||||
func NewReplyHandler(repo *repository.ReplyRepository) *ReplyHandler {
|
||||
return &ReplyHandler{repo: repo}
|
||||
}
|
||||
|
||||
func (h *ReplyHandler) RecordReply(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
TweetID string `json:"tweet_id"`
|
||||
StrategyType string `json:"strategy_type"`
|
||||
Content string `json:"content"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
SendError(w, http.StatusBadRequest, 4001, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
userID, ok := ctx.Value("userID").(string)
|
||||
if !ok || userID == "" {
|
||||
SendError(w, http.StatusUnauthorized, 4002, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusBadRequest, 4003, "Invalid user ID format")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the raw string X_Tweet_ID into our internal UUID
|
||||
// Create a dummy tweet entry via Upsert if it doesn't exist yet so foreign keys don't panic
|
||||
tweet := &model.Tweet{
|
||||
XTweetID: body.TweetID,
|
||||
Content: body.Content, // Temporarily store AI content as a placeholder if original is missing
|
||||
IsProcessed: false,
|
||||
}
|
||||
|
||||
err = h.repo.UpsertDummyTweet(tweet)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5001, "Failed to resolve tweet reference")
|
||||
return
|
||||
}
|
||||
|
||||
reply := &model.GeneratedReply{
|
||||
UserID: userUUID,
|
||||
TweetID: tweet.ID,
|
||||
StrategyType: body.StrategyType,
|
||||
Content: body.Content,
|
||||
Status: "copied",
|
||||
Language: body.Language,
|
||||
}
|
||||
|
||||
if err := h.repo.CreateGeneratedReply(reply); err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5002, "Failed to log generated reply")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, map[string]string{ "message": "Reply recorded successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetReplies handles GET /api/v1/replies
|
||||
func (h *ReplyHandler) GetReplies(w http.ResponseWriter, r *http.Request) {
|
||||
userIDStr := r.Header.Get("X-User-ID")
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
replies, err := h.repo.GetGeneratedRepliesByUser(userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get replies: %v", err)
|
||||
http.Error(w, "Failed to get replies", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(replies)
|
||||
}
|
||||
48
server/internal/handler/tweet_handler.go
Normal file
48
server/internal/handler/tweet_handler.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/zs/InsightReply/internal/repository"
|
||||
)
|
||||
|
||||
type TweetHandler struct {
|
||||
repo *repository.TweetRepository
|
||||
}
|
||||
|
||||
func NewTweetHandler(repo *repository.TweetRepository) *TweetHandler {
|
||||
return &TweetHandler{repo: repo}
|
||||
}
|
||||
|
||||
// GetHotTweets returns the top heating tweets spanning across all tracking targets
|
||||
func (h *TweetHandler) GetHotTweets(w http.ResponseWriter, r *http.Request) {
|
||||
// Standardize to take the top 50 hottest tweets that haven't been manually marked as processed
|
||||
tweets, err := h.repo.GetTopHeatingTweets(50)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to retrieve hot tweets"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tweets)
|
||||
}
|
||||
|
||||
// SearchTweets provides the multi-rule filtering API for Epic 5
|
||||
func (h *TweetHandler) GetSearchTweets(w http.ResponseWriter, r *http.Request) {
|
||||
keyword := r.URL.Query().Get("keyword")
|
||||
handle := r.URL.Query().Get("handle")
|
||||
|
||||
tweets, err := h.repo.SearchTweets(keyword, handle, 50)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"error": "failed to search tweets"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tweets)
|
||||
}
|
||||
@@ -34,3 +34,38 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
SendSuccess(w, user)
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
// Assumes JWTAuth middleware has placed userID in context
|
||||
userID := r.Context().Value("userID").(string)
|
||||
|
||||
user, err := h.svc.GetUserByID(userID)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusNotFound, 4004, "User not found")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, user)
|
||||
}
|
||||
|
||||
func (h *UserHandler) UpdatePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("userID").(string)
|
||||
|
||||
var body struct {
|
||||
IdentityLabel string `json:"identity_label"`
|
||||
LanguagePreference string `json:"language_preference"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
SendError(w, http.StatusBadRequest, 4001, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.svc.UpdatePreferences(userID, body.IdentityLabel, body.LanguagePreference)
|
||||
if err != nil {
|
||||
SendError(w, http.StatusInternalServerError, 5001, "Failed to update preferences")
|
||||
return
|
||||
}
|
||||
|
||||
SendSuccess(w, user)
|
||||
}
|
||||
|
||||
65
server/internal/middleware/jwt.go
Normal file
65
server/internal/middleware/jwt.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserIDKey contextKey = "userID"
|
||||
|
||||
func JWTAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, `{"code":401, "message":"Unauthorized: missing token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
http.Error(w, `{"code":401, "message":"Unauthorized: invalid token format"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, `{"code":401, "message":"Unauthorized: invalid or expired token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
http.Error(w, `{"code":401, "message":"Unauthorized: invalid token claims"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := claims["sub"].(string)
|
||||
if !ok || userID == "" {
|
||||
// fallback check inside user_id or id if sub doesn't exist
|
||||
userID, _ = claims["user_id"].(string)
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
http.Error(w, `{"code":401, "message":"Unauthorized: user ID not found in token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
82
server/internal/middleware/ratelimit.go
Normal file
82
server/internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
limiters = make(map[string]*rate.Limiter)
|
||||
limiterMux sync.RWMutex
|
||||
)
|
||||
|
||||
// getLimiter retrieves or creates a rate limiter for a specific user.
|
||||
// Uses a simple token bucket. For strict "10 per day" with distributed persistence,
|
||||
// this should be refactored to use Redis or DB API usage counters.
|
||||
func getLimiter(userID string, tier string) *rate.Limiter {
|
||||
limiterMux.Lock()
|
||||
defer limiterMux.Unlock()
|
||||
|
||||
if limiter, exists := limiters[userID]; exists {
|
||||
return limiter
|
||||
}
|
||||
|
||||
var limiter *rate.Limiter
|
||||
if tier == "Pro" || tier == "Premium" {
|
||||
// Unlimited (e.g., 20 requests per second burst)
|
||||
limiter = rate.NewLimiter(rate.Limit(20), 100)
|
||||
} else {
|
||||
// Free: 10 per day -> replenishes 1 token every 2.4 hours, bucket size 10
|
||||
limiter = rate.NewLimiter(rate.Every(24*time.Hour/10), 10)
|
||||
}
|
||||
|
||||
limiters[userID] = limiter
|
||||
return limiter
|
||||
}
|
||||
|
||||
// RateLimit middleware enforces rate limits based on user tier.
|
||||
// It expects JWTAuth to have already populated UserIDKey in the context.
|
||||
func RateLimit(db *gorm.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userIDVal := r.Context().Value(UserIDKey)
|
||||
if userIDVal == nil {
|
||||
// Allow if not authenticated strictly, or rate limit by IP
|
||||
// For now, fallback to generic tight limit for anonymous usage
|
||||
ipLimiter := getLimiter(r.RemoteAddr, "Free")
|
||||
if !ipLimiter.Allow() {
|
||||
http.Error(w, `{"code":429, "message":"Too Many Requests: Rate limit exceeded"}`, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID := userIDVal.(string)
|
||||
|
||||
// Fast DB query to get user tier (ideally cached in Redis in prod)
|
||||
var tier string
|
||||
// Look up active subscription for this user
|
||||
err := db.Table("subscriptions").
|
||||
Select("tier").
|
||||
Where("user_id = ? AND status = 'active'", userID).
|
||||
Scan(&tier).Error
|
||||
|
||||
if err != nil || tier == "" {
|
||||
tier = "Free" // defaults to Free if no active sub
|
||||
}
|
||||
|
||||
limiter := getLimiter(userID, tier)
|
||||
if !limiter.Allow() {
|
||||
http.Error(w, `{"code":429, "message":"Too Many Requests: Daily quota or rate limit exceeded"}`, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
16
server/internal/model/competitor_monitor.go
Normal file
16
server/internal/model/competitor_monitor.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CompetitorMonitor struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_competitor" json:"user_id"`
|
||||
BrandName string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_competitor" json:"brand_name"`
|
||||
XHandle string `gorm:"type:varchar(255)" json:"x_handle"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
18
server/internal/model/generated_reply.go
Normal file
18
server/internal/model/generated_reply.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GeneratedReply struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_generated_replies_user_id" json:"user_id"`
|
||||
TweetID uuid.UUID `gorm:"type:uuid;not null;index:idx_generated_replies_tweet_id" json:"tweet_id"`
|
||||
StrategyType string `gorm:"type:varchar(100);not null" json:"strategy_type"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status string `gorm:"type:varchar(50);default:'draft'" json:"status"` // draft, copied, posted
|
||||
Language string `gorm:"type:varchar(10);default:'en'" json:"language"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
17
server/internal/model/reply_performance.go
Normal file
17
server/internal/model/reply_performance.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ReplyPerformance struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ReplyID uuid.UUID `gorm:"type:uuid;not null;index:idx_reply_performance_reply_id" json:"reply_id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_reply_performance_user_id" json:"user_id"`
|
||||
LikeCountIncrease int `gorm:"default:0" json:"like_count_increase"`
|
||||
ReplyCountIncrease int `gorm:"default:0" json:"reply_count_increase"`
|
||||
InteractionRate float64 `gorm:"default:0.0" json:"interaction_rate"`
|
||||
CheckTime time.Time `json:"check_time"`
|
||||
}
|
||||
24
server/internal/model/tweet.go
Normal file
24
server/internal/model/tweet.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Tweet struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
XTweetID string `gorm:"type:varchar(255);uniqueIndex:idx_tweets_x_tweet_id;not null" json:"x_tweet_id"`
|
||||
AuthorID string `gorm:"type:varchar(255)" json:"author_id"`
|
||||
AuthorHandle string `gorm:"type:varchar(255)" json:"author_handle"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
PostedAt time.Time `json:"posted_at"`
|
||||
LikeCount int `gorm:"default:0" json:"like_count"`
|
||||
RetweetCount int `gorm:"default:0" json:"retweet_count"`
|
||||
ReplyCount int `gorm:"default:0" json:"reply_count"`
|
||||
HeatScore float64 `gorm:"default:0.0;index:idx_tweets_heat_score" json:"heat_score"`
|
||||
CrawlQueue string `gorm:"type:varchar(20);default:'normal';index:idx_tweets_crawl_queue" json:"crawl_queue"`
|
||||
IsProcessed bool `gorm:"default:false" json:"is_processed"`
|
||||
LastCrawledAt time.Time `gorm:"index:idx_tweets_crawl_queue" json:"last_crawled_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Email string `gorm:"unique;not null" json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
SubscriptionTier string `gorm:"default:'Free'" json:"subscription_tier"`
|
||||
IdentityLabel string `json:"identity_label"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Email string `gorm:"unique;not null" json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
SubscriptionTier string `gorm:"default:'Free'" json:"subscription_tier"`
|
||||
IdentityLabel string `json:"identity_label"`
|
||||
LanguagePreference string `gorm:"default:'auto'" json:"language_preference"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
21
server/internal/model/user_custom_strategy.go
Normal file
21
server/internal/model/user_custom_strategy.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserCustomStrategy struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_strategy_key" json:"user_id"`
|
||||
StrategyKey string `gorm:"type:varchar(100);not null;uniqueIndex:idx_user_strategy_key" json:"strategy_key"`
|
||||
Label string `gorm:"type:varchar(255);not null" json:"label"`
|
||||
Icon string `gorm:"type:varchar(10)" json:"icon"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
PromptTemplate string `gorm:"type:text" json:"prompt_template"`
|
||||
FewShotExamples string `gorm:"type:jsonb;default:'[]'" json:"few_shot_examples"` // Stored as JSON string
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
26
server/internal/model/user_product_profile.go
Normal file
26
server/internal/model/user_product_profile.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserProductProfile struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;unique;not null" json:"user_id"`
|
||||
ProductName string `gorm:"type:varchar(255)" json:"product_name"`
|
||||
Tagline string `gorm:"type:text" json:"tagline"`
|
||||
Domain string `gorm:"type:varchar(255)" json:"domain"`
|
||||
KeyFeatures string `gorm:"type:jsonb;default:'[]'" json:"key_features"` // Stored as JSON string
|
||||
TargetUsers string `gorm:"type:text" json:"target_users"`
|
||||
ProductUrl string `gorm:"type:varchar(500)" json:"product_url"`
|
||||
Competitors string `gorm:"type:jsonb;default:'[]'" json:"competitors"` // Stored as JSON string
|
||||
RelevanceKeywords string `gorm:"type:jsonb;default:'[]'" json:"relevance_keywords"` // Stored as JSON string
|
||||
CustomContext string `gorm:"type:text" json:"custom_context"`
|
||||
DefaultLLMProvider string `gorm:"type:varchar(50)" json:"default_llm_provider"` // User preferred LLM provider
|
||||
DefaultLLMModel string `gorm:"type:varchar(100)" json:"default_llm_model"` // User preferred LLM model
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
34
server/internal/repository/competitor_monitor_repository.go
Normal file
34
server/internal/repository/competitor_monitor_repository.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CompetitorMonitorRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCompetitorMonitorRepository(db *gorm.DB) *CompetitorMonitorRepository {
|
||||
return &CompetitorMonitorRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CompetitorMonitorRepository) ListByUserID(userID string) ([]model.CompetitorMonitor, error) {
|
||||
var monitors []model.CompetitorMonitor
|
||||
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Order("created_at desc").Find(&monitors).Error
|
||||
return monitors, err
|
||||
}
|
||||
|
||||
func (r *CompetitorMonitorRepository) ListAllActive() ([]model.CompetitorMonitor, error) {
|
||||
var monitors []model.CompetitorMonitor
|
||||
err := r.db.Where("is_active = ?", true).Find(&monitors).Error
|
||||
return monitors, err
|
||||
}
|
||||
|
||||
func (r *CompetitorMonitorRepository) Create(monitor *model.CompetitorMonitor) error {
|
||||
return r.db.Create(monitor).Error
|
||||
}
|
||||
|
||||
func (r *CompetitorMonitorRepository) Delete(id string, userID string) error {
|
||||
return r.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.CompetitorMonitor{}).Error
|
||||
}
|
||||
38
server/internal/repository/custom_strategy_repository.go
Normal file
38
server/internal/repository/custom_strategy_repository.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CustomStrategyRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomStrategyRepository(db *gorm.DB) *CustomStrategyRepository {
|
||||
return &CustomStrategyRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CustomStrategyRepository) ListByUserID(userID string) ([]model.UserCustomStrategy, error) {
|
||||
var strategies []model.UserCustomStrategy
|
||||
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Order("sort_order asc, created_at desc").Find(&strategies).Error
|
||||
return strategies, err
|
||||
}
|
||||
|
||||
func (r *CustomStrategyRepository) Create(strategy *model.UserCustomStrategy) error {
|
||||
return r.db.Create(strategy).Error
|
||||
}
|
||||
|
||||
func (r *CustomStrategyRepository) Update(strategy *model.UserCustomStrategy) error {
|
||||
return r.db.Save(strategy).Error
|
||||
}
|
||||
|
||||
func (r *CustomStrategyRepository) Delete(id string, userID string) error {
|
||||
return r.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.UserCustomStrategy{}).Error
|
||||
}
|
||||
|
||||
func (r *CustomStrategyRepository) GetByIDAndUser(id string, userID string) (*model.UserCustomStrategy, error) {
|
||||
var strategy model.UserCustomStrategy
|
||||
err := r.db.Where("id = ? AND user_id = ?", id, userID).First(&strategy).Error
|
||||
return &strategy, err
|
||||
}
|
||||
25
server/internal/repository/product_profile_repository.go
Normal file
25
server/internal/repository/product_profile_repository.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProductProfileRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewProductProfileRepository(db *gorm.DB) *ProductProfileRepository {
|
||||
return &ProductProfileRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ProductProfileRepository) GetByUserID(userID string) (*model.UserProductProfile, error) {
|
||||
var profile model.UserProductProfile
|
||||
err := r.db.Where("user_id = ?", userID).First(&profile).Error
|
||||
return &profile, err
|
||||
}
|
||||
|
||||
func (r *ProductProfileRepository) Save(profile *model.UserProductProfile) error {
|
||||
// Use Save to either create or update based on primary key
|
||||
return r.db.Save(profile).Error
|
||||
}
|
||||
74
server/internal/repository/reply_repository.go
Normal file
74
server/internal/repository/reply_repository.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ReplyRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewReplyRepository(db *gorm.DB) *ReplyRepository {
|
||||
return &ReplyRepository{db: db}
|
||||
}
|
||||
|
||||
// CreateGeneratedReply logs an AI generated response when it is copied/used by the user
|
||||
func (r *ReplyRepository) CreateGeneratedReply(reply *model.GeneratedReply) error {
|
||||
return r.db.Create(reply).Error
|
||||
}
|
||||
|
||||
// GetPendingPerformanceChecks returns copied replies that need their performance checked (e.g. older than 24h)
|
||||
func (r *ReplyRepository) GetPendingPerformanceChecks() ([]model.GeneratedReply, error) {
|
||||
var replies []model.GeneratedReply
|
||||
|
||||
// Complex: Fetch replies that are "copied", created more than 24 hours ago,
|
||||
// and DO NOT already have a corresponding entry in reply_performance.
|
||||
err := r.db.Table("generated_replies").
|
||||
Select("generated_replies.*").
|
||||
Joins("LEFT JOIN reply_performance rp ON rp.reply_id = generated_replies.id").
|
||||
Where("generated_replies.status = ?", "copied").
|
||||
Where("generated_replies.created_at < NOW() - INTERVAL '1 day'").
|
||||
Where("rp.id IS NULL").
|
||||
Find(&replies).Error
|
||||
|
||||
return replies, err
|
||||
}
|
||||
|
||||
// SaveReplyPerformance persists the checked engagement scores of a generated reply
|
||||
func (r *ReplyRepository) SaveReplyPerformance(perf *model.ReplyPerformance) error {
|
||||
return r.db.Create(perf).Error
|
||||
}
|
||||
|
||||
// UpsertDummyTweet acts as a safety hook to guarantee foreign key integrity exists before recording a reply onto an un-crawled Tweet.
|
||||
func (r *ReplyRepository) UpsertDummyTweet(tweet *model.Tweet) error {
|
||||
return r.db.Where("x_tweet_id = ?", tweet.XTweetID).FirstOrCreate(tweet).Error
|
||||
}
|
||||
|
||||
// GetTweetXTweetID returns the string identifier string X uses, converting backwards from the postgres UUID
|
||||
func (r *ReplyRepository) GetTweetXTweetID(tweetID uuid.UUID) (string, error) {
|
||||
var tweet model.Tweet
|
||||
err := r.db.Where("id = ?", tweetID).First(&tweet).Error
|
||||
return tweet.XTweetID, err
|
||||
}
|
||||
|
||||
// SaveStyleExtraction commits an AI-learned writing style profile against the user for future inference injection
|
||||
func (r *ReplyRepository) SaveStyleExtraction(userID uuid.UUID, styleDesc string) error {
|
||||
// user_style_profiles might not exist yet; use raw SQL or Gorm Upsert
|
||||
return r.db.Exec(`
|
||||
INSERT INTO user_style_profiles (user_id, tone_preference)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET tone_preference = EXCLUDED.tone_preference, updated_at = NOW()
|
||||
`, userID, styleDesc).Error
|
||||
}
|
||||
|
||||
// GetGeneratedRepliesByUser retrieves all AI replies for a user to display in the History dashboard
|
||||
func (r *ReplyRepository) GetGeneratedRepliesByUser(userID uuid.UUID) ([]model.GeneratedReply, error) {
|
||||
var replies []model.GeneratedReply
|
||||
// Preload the performance data if it exists. Preloading "Performance" requires GORM association.
|
||||
// We'll just fetch replies and order by newest first.
|
||||
err := r.db.Where("user_id = ?", userID).Order("created_at desc").Limit(100).Find(&replies).Error
|
||||
return replies, err
|
||||
}
|
||||
82
server/internal/repository/tweet_repository.go
Normal file
82
server/internal/repository/tweet_repository.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type TweetRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTweetRepository(db *gorm.DB) *TweetRepository {
|
||||
return &TweetRepository{db: db}
|
||||
}
|
||||
|
||||
// Upsert intelligently inserts a new tweet or updates an existing one.
|
||||
// Crucially, on conflict, it dynamically calculates the 'heat_score' by
|
||||
// comparing the new metrics against the old metrics currently in the database.
|
||||
func (r *TweetRepository) Upsert(tweet *model.Tweet) error {
|
||||
// For new tweets being inserted, their base heat score evaluates to their current absolute metrics.
|
||||
// For existing tweets, we calculate the delta and add it to their existing heat score.
|
||||
tweet.HeatScore = float64(tweet.LikeCount*1 + tweet.RetweetCount*2 + tweet.ReplyCount*3)
|
||||
|
||||
err := r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "x_tweet_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"author_id": clause.Column{Table: "excluded", Name: "author_id"},
|
||||
"author_handle": clause.Column{Table: "excluded", Name: "author_handle"},
|
||||
"content": clause.Column{Table: "excluded", Name: "content"},
|
||||
"posted_at": clause.Column{Table: "excluded", Name: "posted_at"},
|
||||
"last_crawled_at": clause.Column{Table: "excluded", Name: "last_crawled_at"},
|
||||
"like_count": clause.Column{Table: "excluded", Name: "like_count"},
|
||||
"retweet_count": clause.Column{Table: "excluded", Name: "retweet_count"},
|
||||
"reply_count": clause.Column{Table: "excluded", Name: "reply_count"},
|
||||
// Calculate delta only if the old values exist and are lower than the new values (to prevent negative spikes from X UI glitches).
|
||||
// heatTracker = old.heat_score + MAX(0, new.like - old.like)*1 + MAX(0, new.rt - old.rt)*2 + MAX(0, new.reply - old.reply)*3
|
||||
"heat_score": gorm.Expr("tweets.heat_score + GREATEST(0, EXCLUDED.like_count - tweets.like_count) * 1.0 + GREATEST(0, EXCLUDED.retweet_count - tweets.retweet_count) * 2.0 + GREATEST(0, EXCLUDED.reply_count - tweets.reply_count) * 3.0"),
|
||||
|
||||
// Smart Crawling logic: If heat score breaches threshold (e.g. 50), promote to high. If old & cold, demote.
|
||||
"crawl_queue": gorm.Expr(`
|
||||
CASE
|
||||
WHEN tweets.heat_score + GREATEST(0, EXCLUDED.like_count - tweets.like_count) * 1.0 + GREATEST(0, EXCLUDED.retweet_count - tweets.retweet_count) * 2.0 + GREATEST(0, EXCLUDED.reply_count - tweets.reply_count) * 3.0 > 50 THEN 'high'
|
||||
WHEN EXCLUDED.last_crawled_at - tweets.posted_at > INTERVAL '7 days' THEN 'low'
|
||||
ELSE 'normal'
|
||||
END
|
||||
`),
|
||||
}),
|
||||
}).Create(tweet).Error
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetTopHeatingTweets returns unprocessed tweets ordered by their generated heat score
|
||||
func (r *TweetRepository) GetTopHeatingTweets(limit int) ([]model.Tweet, error) {
|
||||
var tweets []model.Tweet
|
||||
err := r.db.Where("is_processed = ?", false).Order("heat_score desc").Limit(limit).Find(&tweets).Error
|
||||
return tweets, err
|
||||
}
|
||||
|
||||
// MarkAsProcessed tags a tweet so we don't present it to the user repeatedly
|
||||
func (r *TweetRepository) MarkAsProcessed(id string) error {
|
||||
return r.db.Model(&model.Tweet{}).Where("id = ?", id).Update("is_processed", true).Error
|
||||
}
|
||||
|
||||
// SearchTweets allows dynamic multi-rule filtering
|
||||
func (r *TweetRepository) SearchTweets(keyword, handle string, limit int) ([]model.Tweet, error) {
|
||||
var tweets []model.Tweet
|
||||
query := r.db.Model(&model.Tweet{})
|
||||
|
||||
if keyword != "" {
|
||||
// PostgreSQL ILIKE for case-insensitive keyword searching
|
||||
query = query.Where("content ILIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
if handle != "" {
|
||||
query = query.Where("author_handle = ?", handle)
|
||||
}
|
||||
|
||||
err := query.Order("heat_score desc, posted_at desc").Limit(limit).Find(&tweets).Error
|
||||
return tweets, err
|
||||
}
|
||||
@@ -22,3 +22,13 @@ func (r *UserRepository) GetByEmail(email string) (*model.User, error) {
|
||||
err := r.db.Where("email = ?", email).First(&user).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(id string) (*model.User, error) {
|
||||
var user model.User
|
||||
err := r.db.Where("id = ?", id).First(&user).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(user *model.User) error {
|
||||
return r.db.Save(user).Error
|
||||
}
|
||||
|
||||
133
server/internal/scraper/client.go
Normal file
133
server/internal/scraper/client.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sony/gobreaker/v2"
|
||||
"golang.org/x/exp/rand"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCircuitOpen = errors.New("scraper circuit breaker is open")
|
||||
ErrRateLimited = errors.New("scraper hit rate limit (429)")
|
||||
ErrUnavailable = errors.New("scraper target unavailable (503)")
|
||||
)
|
||||
|
||||
type ScraperClient struct {
|
||||
http *http.Client
|
||||
breaker *gobreaker.CircuitBreaker[[]byte]
|
||||
mu sync.Mutex
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
var userAgents = []string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/112.0",
|
||||
}
|
||||
|
||||
func NewScraperClient() *ScraperClient {
|
||||
// Custom transport to mask TLS fingerprints somewhat and set timeouts
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
// Circuit Breaker: Trip on 5 consecutive failures, wait 60 seconds (Exponential behavior is often custom, but standard half-open helps)
|
||||
st := gobreaker.Settings{
|
||||
Name: "NitterScraperCB",
|
||||
MaxRequests: 1,
|
||||
Interval: 0,
|
||||
Timeout: 60 * time.Second, // Wait 60s before allowing retry if Open
|
||||
ReadyToTrip: func(counts gobreaker.Counts) bool {
|
||||
return counts.ConsecutiveFailures >= 3
|
||||
},
|
||||
}
|
||||
|
||||
return &ScraperClient{
|
||||
http: client,
|
||||
breaker: gobreaker.NewCircuitBreaker[[]byte](st),
|
||||
rng: rand.New(rand.NewSource(uint64(time.Now().UnixNano()))),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ScraperClient) getRandomUserAgent() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return userAgents[c.rng.Intn(len(userAgents))]
|
||||
}
|
||||
|
||||
func (c *ScraperClient) JitterDelay(minMs, maxMs int) {
|
||||
c.mu.Lock()
|
||||
delay := minMs + c.rng.Intn(maxMs-minMs)
|
||||
c.mu.Unlock()
|
||||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||
}
|
||||
|
||||
// Fetch returns the raw body byte stream while handling Circuit Breaking and Status checking.
|
||||
func (c *ScraperClient) Fetch(url string) ([]byte, error) {
|
||||
respBody, err := c.breaker.Execute(func() ([]byte, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", c.getRandomUserAgent())
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read to memory in Execute block so if it fails, circuit tracks it. ReadAll is fine for HTML scrapes.
|
||||
var data []byte
|
||||
buf := make([]byte, 1024)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
data = append(data, buf[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if err == gobreaker.ErrOpenState {
|
||||
return nil, ErrCircuitOpen
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
146
server/internal/scraper/parser.go
Normal file
146
server/internal/scraper/parser.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
type ParsedTweet struct {
|
||||
ID string
|
||||
Author string
|
||||
Handle string
|
||||
Content string
|
||||
Likes int
|
||||
Retweets int
|
||||
Replies int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ParseTimeline extracts all tweets from a Nitter timeline HTML page.
|
||||
func ParseTimeline(htmlData []byte) ([]ParsedTweet, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load HTML document: %w", err)
|
||||
}
|
||||
|
||||
var tweets []ParsedTweet
|
||||
|
||||
doc.Find(".timeline-item").Each(func(i int, s *goquery.Selection) {
|
||||
// Only parse actual tweets (not "Show thread" links or "Load more")
|
||||
if s.HasClass("show-more") || s.HasClass("more-replies") {
|
||||
return
|
||||
}
|
||||
|
||||
tweet := ParsedTweet{}
|
||||
|
||||
// Author and Handle
|
||||
authorBlock := s.Find(".fullname")
|
||||
if authorBlock.Length() > 0 {
|
||||
tweet.Author = strings.TrimSpace(authorBlock.Text())
|
||||
}
|
||||
|
||||
handleBlock := s.Find(".username")
|
||||
if handleBlock.Length() > 0 {
|
||||
tweet.Handle = strings.TrimSpace(handleBlock.Text())
|
||||
}
|
||||
|
||||
// Content
|
||||
contentBlock := s.Find(".tweet-content")
|
||||
if contentBlock.Length() > 0 {
|
||||
tweet.Content = strings.TrimSpace(contentBlock.Text())
|
||||
}
|
||||
|
||||
// Link (to get ID)
|
||||
linkBlock := s.Find("a.tweet-link")
|
||||
if linkBlock.Length() > 0 {
|
||||
href, _ := linkBlock.Attr("href")
|
||||
parts := strings.Split(href, "/")
|
||||
if len(parts) > 0 {
|
||||
tweet.ID = parts[len(parts)-1]
|
||||
// Nitter sometimes adds #m at the end of links
|
||||
tweet.ID = strings.TrimSuffix(tweet.ID, "#m")
|
||||
}
|
||||
}
|
||||
|
||||
// Date
|
||||
dateBlock := s.Find(".tweet-date a[title]")
|
||||
if dateBlock.Length() > 0 {
|
||||
titleAttr, _ := dateBlock.Attr("title")
|
||||
// Nitter format: "Feb 28, 2026 · 1:23 PM UTC"
|
||||
// A rough parsing could be done here, or we just rely on standard formats.
|
||||
// For simplicity, we just leave it default Time if we can't parse it quickly.
|
||||
if titleAttr != "" {
|
||||
parsedTime, err := time.Parse("Jan 2, 2006 · 3:04 PM MST", titleAttr)
|
||||
if err == nil {
|
||||
tweet.CreatedAt = parsedTime
|
||||
} else {
|
||||
tweet.CreatedAt = time.Now() // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
statBlock := s.Find(".tweet-stat")
|
||||
statBlock.Each(func(j int, statSel *goquery.Selection) {
|
||||
iconContainer := statSel.Find("span.icon-container > span")
|
||||
class, exists := iconContainer.Attr("class")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the text value beside the icon
|
||||
valStr := strings.TrimSpace(statSel.Text())
|
||||
val := parseStatString(valStr)
|
||||
|
||||
if strings.Contains(class, "icon-comment") {
|
||||
tweet.Replies = val
|
||||
} else if strings.Contains(class, "icon-retweet") {
|
||||
tweet.Retweets = val
|
||||
} else if strings.Contains(class, "icon-heart") {
|
||||
tweet.Likes = val
|
||||
}
|
||||
})
|
||||
|
||||
// Only append if it's a valid parsed tweet
|
||||
if tweet.ID != "" && tweet.Content != "" {
|
||||
tweets = append(tweets, tweet)
|
||||
}
|
||||
})
|
||||
|
||||
return tweets, nil
|
||||
}
|
||||
|
||||
// parseStatString converts string representations like "15.4K" to integer 15400
|
||||
func parseStatString(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
s = strings.ReplaceAll(s, ",", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
|
||||
multiplier := 1.0
|
||||
lower := strings.ToLower(s)
|
||||
|
||||
if strings.HasSuffix(lower, "k") {
|
||||
multiplier = 1000.0
|
||||
s = s[:len(s)-1]
|
||||
} else if strings.HasSuffix(lower, "m") {
|
||||
multiplier = 1000000.0
|
||||
s = s[:len(s)-1]
|
||||
} else if strings.HasSuffix(lower, "b") {
|
||||
multiplier = 1000000000.0
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return int(val * multiplier)
|
||||
}
|
||||
@@ -3,57 +3,210 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sony/gobreaker/v2"
|
||||
"github.com/zs/InsightReply/internal/service/llm"
|
||||
)
|
||||
|
||||
type AIService struct {
|
||||
client *openai.Client
|
||||
providers map[string]llm.Provider
|
||||
breakers map[string]*gobreaker.CircuitBreaker[string]
|
||||
|
||||
defaultProvider string
|
||||
defaultModel string
|
||||
}
|
||||
|
||||
func NewAIService(apiKey string) *AIService {
|
||||
return &AIService{
|
||||
client: openai.NewClient(apiKey),
|
||||
func NewAIService() *AIService {
|
||||
s := &AIService{
|
||||
providers: make(map[string]llm.Provider),
|
||||
breakers: make(map[string]*gobreaker.CircuitBreaker[string]),
|
||||
}
|
||||
|
||||
// 1. Initialize Providers based on ENV
|
||||
if key := os.Getenv("OPENAI_API_KEY"); key != "" {
|
||||
s.providers["openai"] = llm.NewOpenAIProvider(key, os.Getenv("OPENAI_BASE_URL"), "openai")
|
||||
}
|
||||
if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" {
|
||||
s.providers["anthropic"] = llm.NewAnthropicProvider(key, os.Getenv("ANTHROPIC_BASE_URL"))
|
||||
}
|
||||
if key := os.Getenv("DEEPSEEK_API_KEY"); key != "" {
|
||||
baseURL := os.Getenv("DEEPSEEK_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.deepseek.com/v1" // Add v1 as expected by OpenAI SDK compatibility
|
||||
}
|
||||
s.providers["deepseek"] = llm.NewOpenAIProvider(key, baseURL, "deepseek")
|
||||
}
|
||||
if key := os.Getenv("GEMINI_API_KEY"); key != "" {
|
||||
s.providers["gemini"] = llm.NewGeminiProvider(key, os.Getenv("GEMINI_BASE_URL"))
|
||||
}
|
||||
|
||||
// 2. Initialize Circuit Breakers for each provider
|
||||
for name := range s.providers {
|
||||
st := gobreaker.Settings{
|
||||
Name: name + "_cb",
|
||||
MaxRequests: 3, // Requests allowed in half-open state
|
||||
Interval: 10 * time.Minute, // Cyclic period for closed state counters
|
||||
Timeout: 60 * time.Second, // Open state duration
|
||||
ReadyToTrip: func(counts gobreaker.Counts) bool {
|
||||
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
|
||||
return counts.Requests >= 5 && failureRatio >= 0.6 // Trip if 60% fail after 5 reqs
|
||||
},
|
||||
}
|
||||
s.breakers[name] = gobreaker.NewCircuitBreaker[string](st)
|
||||
}
|
||||
|
||||
s.defaultProvider = os.Getenv("LLM_PROVIDER")
|
||||
if s.defaultProvider == "" {
|
||||
s.defaultProvider = "openai"
|
||||
}
|
||||
s.defaultModel = os.Getenv("LLM_MODEL")
|
||||
if s.defaultModel == "" {
|
||||
s.defaultModel = "gpt-4o-mini"
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *AIService) TestConnection(ctx context.Context) (string, error) {
|
||||
// ... (same as before)
|
||||
return "Ready", nil // Simplified for brevity in this edit, but I'll keep the logic if needed
|
||||
if len(s.providers) == 0 {
|
||||
return "", fmt.Errorf("no LLM providers configured")
|
||||
}
|
||||
return "Ready (Multi-LLM configured)", nil
|
||||
}
|
||||
|
||||
func (s *AIService) GenerateReply(ctx context.Context, tweetContent string, strategy string, userIdentity string) (string, error) {
|
||||
prompt := fmt.Sprintf(`
|
||||
// GenerateReply dynamically routes to the preferred LLM and uses a fallback chain if it fails.
|
||||
func (s *AIService) GenerateReply(ctx context.Context, tweetContent, productContext, userIdentity string, preferredProvider, preferredModel string) (string, error) {
|
||||
systemPrompt := "You are a professional X (Twitter) ghostwriter. You MUST respond with valid JSON."
|
||||
userPrompt := fmt.Sprintf(`
|
||||
You are a social media expert.
|
||||
User Identity: %s
|
||||
%s
|
||||
Target Tweet: "%s"
|
||||
Strategy: %s
|
||||
|
||||
Generate a high-quality reply for X (Twitter).
|
||||
Keep it natural, engaging, and under 280 characters.
|
||||
Do not use quotes around the reply.
|
||||
`, userIdentity, tweetContent, strategy)
|
||||
Generate 3 high-quality, distinct replies for X (Twitter) using different strategic angles.
|
||||
Suggested angles depending on context: Contrarian, Analytical, Supportive, Data-driven, Founder's Experience, Quote Tweet.
|
||||
IMPORTANT: If "Available User Custom Strategies" are provided above, you MUST prioritize using those custom strategy angles for your replies.
|
||||
IMPORTANT: If a specific "IMMITATE STYLE" instruction is provided in the Identity or Context, you MUST perfectly clone that linguistic tone.
|
||||
|
||||
Keep each reply natural, engaging, and under 280 characters. No hashtags unless highly relevant.
|
||||
|
||||
Respond ONLY with a JSON array in the exact following format, without any markdown formatting wrappers (like markdown code blocks):
|
||||
[
|
||||
{"strategy": "Name of Strategy 1", "content": "Reply content 1"},
|
||||
{"strategy": "Name of Strategy 2", "content": "Reply content 2"},
|
||||
{"strategy": "Name of Strategy 3", "content": "Reply content 3"}
|
||||
]
|
||||
`, userIdentity, productContext, tweetContent)
|
||||
|
||||
resp, err := s.client.CreateChatCompletion(
|
||||
ctx,
|
||||
openai.ChatCompletionRequest{
|
||||
Model: openai.GPT4oMini,
|
||||
Messages: []openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: openai.ChatMessageRoleSystem,
|
||||
Content: "You are a professional X (Twitter) ghostwriter.",
|
||||
},
|
||||
{
|
||||
Role: openai.ChatMessageRoleUser,
|
||||
Content: prompt,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate reply: %w", err)
|
||||
targetProvider := preferredProvider
|
||||
if targetProvider == "" {
|
||||
targetProvider = s.defaultProvider
|
||||
}
|
||||
targetModel := preferredModel
|
||||
if targetModel == "" {
|
||||
targetModel = s.defaultModel
|
||||
}
|
||||
|
||||
return resp.Choices[0].Message.Content, nil
|
||||
// Fallback chain (as designed in IMPLEMENTATION_PLAN: current -> Anthropic -> OpenAI -> Gemini -> DeepSeek)
|
||||
fallbackChain := []string{targetProvider, "anthropic", "openai", "gemini", "deepseek"}
|
||||
|
||||
for _, pName := range fallbackChain {
|
||||
provider, ok := s.providers[pName]
|
||||
if !ok {
|
||||
log.Printf("Provider %s bypassed (not configured)", pName)
|
||||
continue
|
||||
}
|
||||
breaker, ok := s.breakers[pName]
|
||||
if !ok {
|
||||
continue // Should never happen
|
||||
}
|
||||
|
||||
// Use the target model only on the initially requested provider. For fallbacks, use a safe default model.
|
||||
modelToUse := targetModel
|
||||
if pName != targetProvider {
|
||||
modelToUse = getDefaultModelFor(pName)
|
||||
}
|
||||
|
||||
log.Printf("Routing request to LLM Provider: %s (Model: %s)", pName, modelToUse)
|
||||
|
||||
// Execute through circuit breaker
|
||||
reply, err := breaker.Execute(func() (string, error) {
|
||||
// Add a simple 30s timeout per call
|
||||
callCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
return provider.GenerateReply(callCtx, modelToUse, systemPrompt, userPrompt)
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
return reply, nil // Success
|
||||
}
|
||||
|
||||
log.Printf("Provider %s failed: %v. Attempting next in fallback chain...", pName, err)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all providers failed to generate reply")
|
||||
}
|
||||
|
||||
// ExtractStyle consumes a viral AI reply and uses the LLM to reverse-engineer its linguistic fingerprint
|
||||
func (s *AIService) ExtractStyle(ctx context.Context, viralReplyContent string) (string, error) {
|
||||
systemPrompt := "You are a master linguistic analyst and copywriter."
|
||||
userPrompt := fmt.Sprintf(`
|
||||
Analyze the following highly successful social media reply:
|
||||
"%s"
|
||||
|
||||
Extract the core stylistic elements that made it successful. Focus on:
|
||||
1. Tone (e.g., witty, provocative, deadpan, empathetic)
|
||||
2. Sentence structure (e.g., short punchy sentences, questions, bullet points)
|
||||
3. Key jargon or vocabulary patterns
|
||||
|
||||
Provide ONLY a concise, 2-3 sentence description of the style profile that another AI should imitate in the future.
|
||||
No conversational filler, just the exact instruction string to append to future system prompts.
|
||||
`, viralReplyContent)
|
||||
|
||||
// Route through our Multi-LLM fallback logic
|
||||
// Try OpenAI first, fallback to Anthropic
|
||||
providers := []string{"openai", "anthropic", "gemini", "deepseek"}
|
||||
|
||||
for _, pName := range providers {
|
||||
pConf, exists := s.providers[pName]
|
||||
cb, cbExists := s.breakers[pName]
|
||||
|
||||
if !exists || !cbExists {
|
||||
continue
|
||||
}
|
||||
|
||||
styleDesc, err := cb.Execute(func() (string, error) {
|
||||
// Use a default model for style extraction, as it's not user-facing and can be optimized for cost/speed
|
||||
modelToUse := getDefaultModelFor(pName)
|
||||
if modelToUse == "" { // Fallback if getDefaultModelFor doesn't have an entry
|
||||
modelToUse = "gpt-4o-mini" // A safe default
|
||||
}
|
||||
return pConf.GenerateReply(ctx, modelToUse, systemPrompt, userPrompt)
|
||||
})
|
||||
|
||||
if err == nil && styleDesc != "" {
|
||||
return styleDesc, nil
|
||||
}
|
||||
log.Printf("Provider %s failed to extract style: %v. Attempting next...", pName, err)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to extract style from any provider")
|
||||
}
|
||||
|
||||
func getDefaultModelFor(provider string) string {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return "gpt-4o-mini"
|
||||
case "anthropic":
|
||||
return "claude-3-5-haiku-latest"
|
||||
case "deepseek":
|
||||
return "deepseek-chat"
|
||||
case "gemini":
|
||||
return "gemini-2.5-flash"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
41
server/internal/service/ai_service_test.go
Normal file
41
server/internal/service/ai_service_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAIService_Initialization verifies that the AIService parses environment variables
|
||||
// correctly and initializes the required fallback strategies and default settings.
|
||||
func TestAIService_Initialization(t *testing.T) {
|
||||
// Temporarily set testing ENVs to avoid depending on local .env
|
||||
os.Setenv("LLM_PROVIDER", "anthropic")
|
||||
os.Setenv("LLM_MODEL", "claude-3-5-haiku-latest")
|
||||
os.Setenv("OPENAI_API_KEY", "test-key-openai")
|
||||
defer os.Clearenv() // Clean up after test
|
||||
|
||||
svc := NewAIService()
|
||||
if svc == nil {
|
||||
t.Fatal("Expected AIService to be initialized, got nil")
|
||||
}
|
||||
|
||||
if svc.defaultProvider != "anthropic" {
|
||||
t.Errorf("Expected default provider 'anthropic', got '%s'", svc.defaultProvider)
|
||||
}
|
||||
|
||||
if svc.defaultModel != "claude-3-5-haiku-latest" {
|
||||
t.Errorf("Expected default model 'claude-3-5-haiku-latest', got '%s'", svc.defaultModel)
|
||||
}
|
||||
|
||||
// Verify that OpenAI provider was initialized because OPENAI_API_KEY was present
|
||||
_, hasOpenAI := svc.providers["openai"]
|
||||
if !hasOpenAI {
|
||||
t.Error("Expected OpenAI provider to be initialized, but it was not found")
|
||||
}
|
||||
|
||||
// Verify that circuit breakers were initialized
|
||||
_, hasBreaker := svc.breakers["openai"]
|
||||
if !hasBreaker {
|
||||
t.Error("Expected circuit breaker for setup provider, but it was not found")
|
||||
}
|
||||
}
|
||||
26
server/internal/service/competitor_monitor_service.go
Normal file
26
server/internal/service/competitor_monitor_service.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"github.com/zs/InsightReply/internal/repository"
|
||||
)
|
||||
|
||||
type CompetitorMonitorService struct {
|
||||
repo *repository.CompetitorMonitorRepository
|
||||
}
|
||||
|
||||
func NewCompetitorMonitorService(repo *repository.CompetitorMonitorRepository) *CompetitorMonitorService {
|
||||
return &CompetitorMonitorService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *CompetitorMonitorService) ListMonitors(userID string) ([]model.CompetitorMonitor, error) {
|
||||
return s.repo.ListByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *CompetitorMonitorService) CreateMonitor(monitor *model.CompetitorMonitor) error {
|
||||
return s.repo.Create(monitor)
|
||||
}
|
||||
|
||||
func (s *CompetitorMonitorService) DeleteMonitor(id string, userID string) error {
|
||||
return s.repo.Delete(id, userID)
|
||||
}
|
||||
30
server/internal/service/custom_strategy_service.go
Normal file
30
server/internal/service/custom_strategy_service.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"github.com/zs/InsightReply/internal/repository"
|
||||
)
|
||||
|
||||
type CustomStrategyService struct {
|
||||
repo *repository.CustomStrategyRepository
|
||||
}
|
||||
|
||||
func NewCustomStrategyService(repo *repository.CustomStrategyRepository) *CustomStrategyService {
|
||||
return &CustomStrategyService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *CustomStrategyService) ListStrategies(userID string) ([]model.UserCustomStrategy, error) {
|
||||
return s.repo.ListByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *CustomStrategyService) CreateStrategy(strategy *model.UserCustomStrategy) error {
|
||||
return s.repo.Create(strategy)
|
||||
}
|
||||
|
||||
func (s *CustomStrategyService) UpdateStrategy(strategy *model.UserCustomStrategy) error {
|
||||
return s.repo.Update(strategy)
|
||||
}
|
||||
|
||||
func (s *CustomStrategyService) DeleteStrategy(id string, userID string) error {
|
||||
return s.repo.Delete(id, userID)
|
||||
}
|
||||
75
server/internal/service/llm/anthropic.go
Normal file
75
server/internal/service/llm/anthropic.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type AnthropicProvider struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewAnthropicProvider(apiKey, baseURL string) *AnthropicProvider {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.anthropic.com/v1"
|
||||
}
|
||||
return &AnthropicProvider{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) Name() string {
|
||||
return "anthropic"
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) GenerateReply(ctx context.Context, model string, systemPrompt, userPrompt string) (string, error) {
|
||||
reqBody := map[string]interface{}{
|
||||
"model": model,
|
||||
"max_tokens": 1024,
|
||||
"system": systemPrompt,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": userPrompt},
|
||||
},
|
||||
}
|
||||
bs, _ := json.Marshal(reqBody)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/messages", bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("x-api-key", p.apiKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
req.Header.Set("content-type", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("anthropic error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(result.Content) == 0 {
|
||||
return "", fmt.Errorf("anthropic returned empty content")
|
||||
}
|
||||
return result.Content[0].Text, nil
|
||||
}
|
||||
86
server/internal/service/llm/gemini.go
Normal file
86
server/internal/service/llm/gemini.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type GeminiProvider struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewGeminiProvider(apiKey, baseURL string) *GeminiProvider {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
}
|
||||
return &GeminiProvider{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) Name() string {
|
||||
return "gemini"
|
||||
}
|
||||
|
||||
func (p *GeminiProvider) GenerateReply(ctx context.Context, model string, systemPrompt, userPrompt string) (string, error) {
|
||||
url := fmt.Sprintf("%s/%s:generateContent?key=%s", p.baseURL, model, p.apiKey)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"systemInstruction": map[string]interface{}{
|
||||
"parts": []map[string]interface{}{
|
||||
{"text": systemPrompt},
|
||||
},
|
||||
},
|
||||
"contents": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"parts": []map[string]interface{}{
|
||||
{"text": userPrompt},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
bs, _ := json.Marshal(reqBody)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("gemini error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 {
|
||||
return "", fmt.Errorf("gemini returned empty content")
|
||||
}
|
||||
return result.Candidates[0].Content.Parts[0].Text, nil
|
||||
}
|
||||
50
server/internal/service/llm/openai.go
Normal file
50
server/internal/service/llm/openai.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
type OpenAIProvider struct {
|
||||
client *openai.Client
|
||||
name string
|
||||
}
|
||||
|
||||
// NewOpenAIProvider creates a new provider that uses the official or compatible OpenAI API.
|
||||
// It can also handle DeepSeek via a custom BaseURL.
|
||||
func NewOpenAIProvider(apiKey, baseURL, name string) *OpenAIProvider {
|
||||
config := openai.DefaultConfig(apiKey)
|
||||
if baseURL != "" {
|
||||
config.BaseURL = baseURL
|
||||
}
|
||||
return &OpenAIProvider{
|
||||
client: openai.NewClientWithConfig(config),
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) GenerateReply(ctx context.Context, model string, systemPrompt, userPrompt string) (string, error) {
|
||||
resp, err := p.client.CreateChatCompletion(
|
||||
ctx,
|
||||
openai.ChatCompletionRequest{
|
||||
Model: model,
|
||||
Messages: []openai.ChatCompletionMessage{
|
||||
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
|
||||
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s api error: %w", p.name, err)
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return "", fmt.Errorf("%s returned no choices", p.name)
|
||||
}
|
||||
return resp.Choices[0].Message.Content, nil
|
||||
}
|
||||
8
server/internal/service/llm/provider.go
Normal file
8
server/internal/service/llm/provider.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package llm
|
||||
|
||||
import "context"
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
GenerateReply(ctx context.Context, model string, systemPrompt, userPrompt string) (string, error)
|
||||
}
|
||||
22
server/internal/service/product_profile_service.go
Normal file
22
server/internal/service/product_profile_service.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"github.com/zs/InsightReply/internal/repository"
|
||||
)
|
||||
|
||||
type ProductProfileService struct {
|
||||
repo *repository.ProductProfileRepository
|
||||
}
|
||||
|
||||
func NewProductProfileService(repo *repository.ProductProfileRepository) *ProductProfileService {
|
||||
return &ProductProfileService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *ProductProfileService) GetProfile(userID string) (*model.UserProductProfile, error) {
|
||||
return s.repo.GetByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *ProductProfileService) SaveProfile(profile *model.UserProductProfile) error {
|
||||
return s.repo.Save(profile)
|
||||
}
|
||||
@@ -25,3 +25,24 @@ func (s *UserService) Register(email string, identity string) (*model.User, erro
|
||||
func (s *UserService) GetUser(email string) (*model.User, error) {
|
||||
return s.repo.GetByEmail(email)
|
||||
}
|
||||
|
||||
func (s *UserService) GetUserByID(id string) (*model.User, error) {
|
||||
return s.repo.GetByID(id)
|
||||
}
|
||||
|
||||
func (s *UserService) UpdatePreferences(id string, identity string, language string) (*model.User, error) {
|
||||
user, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if identity != "" {
|
||||
user.IdentityLabel = identity
|
||||
}
|
||||
if language != "" {
|
||||
user.LanguagePreference = language
|
||||
}
|
||||
|
||||
err = s.repo.Update(user)
|
||||
return user, err
|
||||
}
|
||||
|
||||
147
server/internal/worker/monitor_worker.go
Normal file
147
server/internal/worker/monitor_worker.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"github.com/zs/InsightReply/internal/repository"
|
||||
"github.com/zs/InsightReply/internal/scraper"
|
||||
)
|
||||
|
||||
type MonitorWorker struct {
|
||||
repo *repository.CompetitorMonitorRepository
|
||||
tweetRepo *repository.TweetRepository
|
||||
client *scraper.ScraperClient
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
func NewMonitorWorker(repo *repository.CompetitorMonitorRepository, tweetRepo *repository.TweetRepository) *MonitorWorker {
|
||||
return &MonitorWorker{
|
||||
repo: repo,
|
||||
tweetRepo: tweetRepo,
|
||||
client: scraper.NewScraperClient(),
|
||||
baseUrl: "https://x.beenglish.eu.org", // Self-hosted Nitter instance
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the background job loop. This should be run in a goroutine.
|
||||
func (w *MonitorWorker) Start(ctx context.Context, interval time.Duration) {
|
||||
log.Printf("[MonitorWorker] Starting background scraping loop every %v", interval)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Initial run
|
||||
w.runCycle(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("[MonitorWorker] Stopping background scraping loop")
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runCycle(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *MonitorWorker) runCycle(ctx context.Context) {
|
||||
log.Println("[MonitorWorker] Starting scrape cycle...")
|
||||
|
||||
monitors, err := w.repo.ListAllActive()
|
||||
if err != nil {
|
||||
log.Printf("[MonitorWorker] Error fetching active monitors: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(monitors) == 0 {
|
||||
log.Println("[MonitorWorker] No active monitors found. Skipping cycle.")
|
||||
return
|
||||
}
|
||||
|
||||
for _, monitor := range monitors {
|
||||
// Stop processing if context cancelled (e.g., app shutdown)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Determine Scraping Strategy
|
||||
var url string
|
||||
|
||||
// URL encode the brand name which acts as our keyword
|
||||
keyword := monitor.BrandName
|
||||
|
||||
if monitor.XHandle != "" {
|
||||
if monitor.XHandle == keyword || keyword == "" {
|
||||
// Standard profile timeline scraping
|
||||
log.Printf("[MonitorWorker] Scraping timeline for account @%s", monitor.XHandle)
|
||||
url = fmt.Sprintf("%s/%s", w.baseUrl, monitor.XHandle)
|
||||
} else {
|
||||
// Combo scraping: Keyword + Specific Account
|
||||
log.Printf("[MonitorWorker] Scraping combo: '%s' from @%s", keyword, monitor.XHandle)
|
||||
url = fmt.Sprintf("%s/search?f=tweets&q=%s+from%%3A%s", w.baseUrl, keyword, monitor.XHandle)
|
||||
}
|
||||
} else if keyword != "" {
|
||||
// Global search for Keyword across X
|
||||
log.Printf("[MonitorWorker] Scraping global search for keyword: '%s'", keyword)
|
||||
url = fmt.Sprintf("%s/search?f=tweets&q=%s", w.baseUrl, keyword)
|
||||
} else {
|
||||
continue // Invalid monitor config
|
||||
}
|
||||
|
||||
w.scrapeAndLog(url)
|
||||
|
||||
// Anti-Ban: Jitter delay between requests (3s to 8s)
|
||||
w.client.JitterDelay(3000, 8000)
|
||||
}
|
||||
|
||||
log.Println("[MonitorWorker] Scrape cycle completed.")
|
||||
}
|
||||
|
||||
func (w *MonitorWorker) scrapeAndLog(url string) {
|
||||
htmlData, err := w.client.Fetch(url)
|
||||
if err != nil {
|
||||
log.Printf("[MonitorWorker] Error scraping %s: %v", url, err)
|
||||
return
|
||||
}
|
||||
|
||||
tweets, err := scraper.ParseTimeline(htmlData)
|
||||
if err != nil {
|
||||
log.Printf("[MonitorWorker] Error parsing HTML for %s: %v", url, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[MonitorWorker] Extracted %d tweets from %s", len(tweets), url)
|
||||
|
||||
// Epic 6: Upsert into tracking database
|
||||
upsertCount := 0
|
||||
for _, rawTweet := range tweets {
|
||||
tweet := &model.Tweet{
|
||||
XTweetID: rawTweet.ID,
|
||||
AuthorHandle: rawTweet.Handle,
|
||||
Content: rawTweet.Content,
|
||||
PostedAt: rawTweet.CreatedAt,
|
||||
LikeCount: rawTweet.Likes,
|
||||
RetweetCount: rawTweet.Retweets,
|
||||
ReplyCount: rawTweet.Replies,
|
||||
CrawlQueue: "normal",
|
||||
IsProcessed: false,
|
||||
LastCrawledAt: time.Now(),
|
||||
}
|
||||
|
||||
// Save/Update in DB
|
||||
err := w.tweetRepo.Upsert(tweet)
|
||||
if err != nil {
|
||||
log.Printf("[MonitorWorker] Error UPSERTing tweet %s: %v", tweet.XTweetID, err)
|
||||
} else {
|
||||
upsertCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[MonitorWorker] Successfully Upserted %d/%d tweets to the database.", upsertCount, len(tweets))
|
||||
}
|
||||
142
server/internal/worker/performance_worker.go
Normal file
142
server/internal/worker/performance_worker.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zs/InsightReply/internal/model"
|
||||
"github.com/zs/InsightReply/internal/repository"
|
||||
"github.com/zs/InsightReply/internal/scraper"
|
||||
"github.com/zs/InsightReply/internal/service"
|
||||
)
|
||||
|
||||
type PerformanceWorker struct {
|
||||
repo *repository.ReplyRepository
|
||||
client *scraper.ScraperClient
|
||||
aiSvc *service.AIService
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
func NewPerformanceWorker(repo *repository.ReplyRepository, aiSvc *service.AIService) *PerformanceWorker {
|
||||
return &PerformanceWorker{
|
||||
repo: repo,
|
||||
client: scraper.NewScraperClient(),
|
||||
aiSvc: aiSvc,
|
||||
baseUrl: "https://x.beenglish.eu.org",
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the 24h retroactive performance checking loop
|
||||
func (w *PerformanceWorker) Start(ctx context.Context, interval time.Duration) {
|
||||
log.Printf("[PerformanceWorker] Starting retroactive engagement tracking every %v", interval)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("[PerformanceWorker] Stopping background performance loop")
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runCycle(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *PerformanceWorker) runCycle(ctx context.Context) {
|
||||
pending, err := w.repo.GetPendingPerformanceChecks()
|
||||
if err != nil {
|
||||
log.Printf("[PerformanceWorker] Error fetching pending checks: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, reply := range pending {
|
||||
// Stop processing if context cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
xTweetID, err := w.repo.GetTweetXTweetID(reply.TweetID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Scrape the specific thread
|
||||
// Nitter handles /i/status/12345 generic routes
|
||||
url := fmt.Sprintf("%s/i/status/%s", w.baseUrl, xTweetID)
|
||||
log.Printf("[PerformanceWorker] Checking thread %s for user's AI reply performance", url)
|
||||
|
||||
htmlData, err := w.client.Fetch(url)
|
||||
if err != nil {
|
||||
w.client.JitterDelay(2000, 5000)
|
||||
continue
|
||||
}
|
||||
|
||||
threadReplies, err := scraper.ParseTimeline(htmlData)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Search for the user's generated text within the thread replies
|
||||
found := false
|
||||
for _, threadReply := range threadReplies {
|
||||
// Basic similarity check: if 50% of the AI sentence is present
|
||||
// Real implementation might use Levenshtein distance, but strings.Contains on chunks works for MVP
|
||||
snippet := reply.Content
|
||||
if len(snippet) > 20 {
|
||||
snippet = snippet[:20]
|
||||
}
|
||||
|
||||
if strings.Contains(threadReply.Content, snippet) {
|
||||
found = true
|
||||
|
||||
// WE FOUND OUR REPLY! Record its metrics
|
||||
perf := &model.ReplyPerformance{
|
||||
ReplyID: reply.ID,
|
||||
UserID: reply.UserID,
|
||||
LikeCountIncrease: threadReply.Likes,
|
||||
ReplyCountIncrease: threadReply.Replies,
|
||||
CheckTime: time.Now(),
|
||||
}
|
||||
|
||||
w.repo.SaveReplyPerformance(perf)
|
||||
log.Printf("[PerformanceWorker] 🎯 Verified AI reply in wild! Likes: %d, Replies: %d", perf.LikeCountIncrease, perf.ReplyCountIncrease)
|
||||
|
||||
// Epic 13 AI Tone Engine: Autonomous Style Cloning for proven viral comments
|
||||
if perf.LikeCountIncrease >= 10 {
|
||||
log.Printf("[PerformanceWorker] Reply went viral! Asking AI to reverse-engineer linguistic styling.")
|
||||
|
||||
styleProfile, err := w.aiSvc.ExtractStyle(ctx, reply.Content)
|
||||
if err == nil && styleProfile != "" {
|
||||
err = w.repo.SaveStyleExtraction(reply.UserID, styleProfile)
|
||||
if err != nil {
|
||||
log.Printf("[PerformanceWorker] Error saving style database mapping: %v", err)
|
||||
} else {
|
||||
log.Printf("[PerformanceWorker] Successfully built user style clone: %s", styleProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Even if not found (maybe they edited heavily or didn't actually post it), we mark it as checked to prevent infinite re-checking
|
||||
if !found {
|
||||
perf := &model.ReplyPerformance{
|
||||
ReplyID: reply.ID,
|
||||
UserID: reply.UserID,
|
||||
CheckTime: time.Now(),
|
||||
}
|
||||
w.repo.SaveReplyPerformance(perf)
|
||||
}
|
||||
|
||||
w.client.JitterDelay(3000, 8000)
|
||||
}
|
||||
}
|
||||
16
server/migrations/000001_init_schema.down.sql
Normal file
16
server/migrations/000001_init_schema.down.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
DROP TRIGGER IF EXISTS update_users_modtime ON users;
|
||||
DROP TRIGGER IF EXISTS update_user_style_profiles_modtime ON user_style_profiles;
|
||||
DROP FUNCTION IF EXISTS update_modified_column;
|
||||
|
||||
DROP TABLE IF EXISTS competitor_monitors CASCADE;
|
||||
DROP TABLE IF EXISTS user_custom_strategies CASCADE;
|
||||
DROP TABLE IF EXISTS user_product_profiles CASCADE;
|
||||
DROP TABLE IF EXISTS crawl_snapshots CASCADE;
|
||||
DROP TABLE IF EXISTS subscriptions CASCADE;
|
||||
DROP TABLE IF EXISTS api_usage_logs CASCADE;
|
||||
DROP TABLE IF EXISTS reply_performance CASCADE;
|
||||
DROP TABLE IF EXISTS generated_replies CASCADE;
|
||||
DROP TABLE IF EXISTS tweets CASCADE;
|
||||
DROP TABLE IF EXISTS monitored_keywords CASCADE;
|
||||
DROP TABLE IF EXISTS monitored_accounts CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
230
server/migrations/000001_init_schema.up.sql
Normal file
230
server/migrations/000001_init_schema.up.sql
Normal file
@@ -0,0 +1,230 @@
|
||||
-- ====================================================
|
||||
-- InsightReply 数据库 Schema (PostgreSQL)
|
||||
-- 版本: v1.1
|
||||
-- 更新: 新增 api_usage_logs, subscriptions, user_style_profiles 表
|
||||
-- ====================================================
|
||||
|
||||
-- users 表:存储业务用户
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255),
|
||||
subscription_tier VARCHAR(50) DEFAULT 'Free', -- Free, Pro, Premium
|
||||
identity_label VARCHAR(100), -- AI 创始人, SaaS Builder 等
|
||||
language_preference VARCHAR(10) DEFAULT 'auto', -- en, zh, auto
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- monitored_accounts 表:存储用户重点监控的 X 账号
|
||||
CREATE TABLE IF NOT EXISTS monitored_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
x_account_id VARCHAR(255),
|
||||
x_handle VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, x_handle)
|
||||
);
|
||||
|
||||
-- monitored_keywords 表:存储用户重点监控的关键词
|
||||
CREATE TABLE IF NOT EXISTS monitored_keywords (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
keyword VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, keyword)
|
||||
);
|
||||
|
||||
-- tweets 表:共享的推文数据池,AI 评论生成的上下文
|
||||
CREATE TABLE IF NOT EXISTS tweets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
x_tweet_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
author_id VARCHAR(255),
|
||||
author_handle VARCHAR(255),
|
||||
content TEXT NOT NULL,
|
||||
posted_at TIMESTAMP WITH TIME ZONE,
|
||||
like_count INTEGER DEFAULT 0,
|
||||
retweet_count INTEGER DEFAULT 0,
|
||||
reply_count INTEGER DEFAULT 0,
|
||||
heat_score FLOAT DEFAULT 0.0,
|
||||
crawl_queue VARCHAR(20) DEFAULT 'normal', -- high, normal, low (智能抓取频率)
|
||||
is_processed BOOLEAN DEFAULT FALSE,
|
||||
last_crawled_at TIMESTAMP WITH TIME ZONE, -- 上次抓取时间
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tweets_x_tweet_id ON tweets(x_tweet_id);
|
||||
CREATE INDEX idx_tweets_heat_score ON tweets(heat_score DESC);
|
||||
CREATE INDEX idx_tweets_crawl_queue ON tweets(crawl_queue, last_crawled_at);
|
||||
|
||||
-- generated_replies 表:生成的 AI 评论记录
|
||||
CREATE TABLE IF NOT EXISTS generated_replies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tweet_id UUID NOT NULL REFERENCES tweets(id) ON DELETE CASCADE,
|
||||
strategy_type VARCHAR(100) NOT NULL, -- cognitive_upgrade, contrarian, data_supplement, empathy, founder_exp
|
||||
content TEXT NOT NULL,
|
||||
status VARCHAR(50) DEFAULT 'draft', -- draft, copied, posted
|
||||
language VARCHAR(10) DEFAULT 'en', -- 生成语言
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_generated_replies_user_id ON generated_replies(user_id);
|
||||
CREATE INDEX idx_generated_replies_tweet_id ON generated_replies(tweet_id);
|
||||
|
||||
-- reply_performance 表:针对已发布评论的效果数据回拨
|
||||
CREATE TABLE IF NOT EXISTS reply_performance (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reply_id UUID NOT NULL REFERENCES generated_replies(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- 冗余字段,便于按用户维度查询
|
||||
like_count_increase INTEGER DEFAULT 0,
|
||||
reply_count_increase INTEGER DEFAULT 0,
|
||||
interaction_rate FLOAT DEFAULT 0.0,
|
||||
check_time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reply_performance_reply_id ON reply_performance(reply_id);
|
||||
CREATE INDEX idx_reply_performance_user_id ON reply_performance(user_id);
|
||||
|
||||
-- ====================================================
|
||||
-- 新增表 (v1.1)
|
||||
-- ====================================================
|
||||
|
||||
-- api_usage_logs 表:记录 LLM API 调用量和成本
|
||||
CREATE TABLE IF NOT EXISTS api_usage_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider VARCHAR(50) NOT NULL, -- openai, anthropic, deepseek
|
||||
model VARCHAR(100) NOT NULL, -- gpt-4o-mini, claude-3.5-haiku 等
|
||||
prompt_tokens INTEGER DEFAULT 0,
|
||||
completion_tokens INTEGER DEFAULT 0,
|
||||
total_tokens INTEGER GENERATED ALWAYS AS (prompt_tokens + completion_tokens) STORED,
|
||||
cost_usd NUMERIC(10, 6) DEFAULT 0.0, -- 精确到 $0.000001
|
||||
endpoint VARCHAR(100), -- /ai/generate
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_api_usage_logs_user_id ON api_usage_logs(user_id);
|
||||
CREATE INDEX idx_api_usage_logs_created_at ON api_usage_logs(created_at DESC);
|
||||
|
||||
-- subscriptions 表:用户订阅记录(支付历史)
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tier VARCHAR(50) NOT NULL, -- Pro, Premium
|
||||
stripe_subscription_id VARCHAR(255), -- Stripe 订阅 ID
|
||||
status VARCHAR(50) DEFAULT 'active', -- active, cancelled, past_due, expired
|
||||
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
cancelled_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
|
||||
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
|
||||
|
||||
-- user_style_profiles 表:用户风格画像(用于个性化 Prompt)
|
||||
CREATE TABLE IF NOT EXISTS user_style_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
top_strategies JSONB DEFAULT '[]', -- 最常选择的策略排序
|
||||
avg_reply_length INTEGER DEFAULT 200, -- 平均偏好回复长度
|
||||
high_engagement_keywords JSONB DEFAULT '[]', -- 高互动关键词
|
||||
tone_preference VARCHAR(100) DEFAULT 'professional', -- casual, professional, witty, provocative
|
||||
custom_prompt_suffix TEXT, -- 用户自定义的额外 Prompt 指令
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- crawl_snapshots 表:异常抓取时的 HTML 快照(排错用)
|
||||
CREATE TABLE IF NOT EXISTS crawl_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
url TEXT NOT NULL,
|
||||
http_status INTEGER,
|
||||
html_content TEXT,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_crawl_snapshots_created_at ON crawl_snapshots(created_at DESC);
|
||||
|
||||
-- ====================================================
|
||||
-- 新增表 (v1.2) — 用户可配置系统
|
||||
-- ====================================================
|
||||
|
||||
-- user_product_profiles 表:用户的产品档案(用于生成与产品相关联的评论)
|
||||
-- 设计原则:所有字段用户自定义,系统不做任何硬编码
|
||||
CREATE TABLE IF NOT EXISTS user_product_profiles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
product_name VARCHAR(255), -- 产品名称 (如 "SwiftBiu")
|
||||
tagline TEXT, -- 一句话介绍
|
||||
domain VARCHAR(255), -- 所属领域 (如 "AI Video Creation")
|
||||
key_features JSONB DEFAULT '[]', -- 核心功能列表 ["视频生成", "多语言配音"]
|
||||
target_users TEXT, -- 目标用户描述
|
||||
product_url VARCHAR(500), -- 官网或商店链接
|
||||
competitors JSONB DEFAULT '[]', -- 竞品名称列表 ["CapCut", "Descript"]
|
||||
relevance_keywords JSONB DEFAULT '[]', -- 相关领域关键词 ["short video", "content creation", "AI dubbing"]
|
||||
custom_context TEXT, -- 用户自定义的额外上下文(自由文本,注入 Prompt)
|
||||
default_llm_provider VARCHAR(50), -- 用户专属模型偏好: openai, anthropic, deepseek, gemini (覆盖系统默认)
|
||||
default_llm_model VARCHAR(100), -- 例如: claude-3-5-haiku-latest
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- user_custom_strategies 表:用户自定义评论策略
|
||||
-- 除系统内置的 5 种策略外,用户可以创建自己的策略模板
|
||||
CREATE TABLE IF NOT EXISTS user_custom_strategies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
strategy_key VARCHAR(100) NOT NULL, -- 策略标识 (如 "builder_story")
|
||||
label VARCHAR(255) NOT NULL, -- 显示名称 (如 "创始人实战型")
|
||||
icon VARCHAR(10), -- Emoji 图标
|
||||
description TEXT, -- 策略描述(告诉 LLM 这种策略的写法)
|
||||
prompt_template TEXT, -- 自定义 Prompt 模板(可包含 {tweet_content} {product_name} 等变量)
|
||||
few_shot_examples JSONB DEFAULT '[]', -- 自定义 Few-shot 示例
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0, -- 排序权重
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, strategy_key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_custom_strategies_user_id ON user_custom_strategies(user_id);
|
||||
|
||||
-- competitor_monitors 表:竞品品牌监控(复用后端雷达,按品牌词自动抓取)
|
||||
CREATE TABLE IF NOT EXISTS competitor_monitors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
brand_name VARCHAR(255) NOT NULL, -- 竞品品牌名
|
||||
x_handle VARCHAR(255), -- 竞品官方 X 账号 (可选)
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, brand_name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_competitor_monitors_user_id ON competitor_monitors(user_id);
|
||||
|
||||
-- ====================================================
|
||||
-- 触发器:自动更新 updated_at
|
||||
-- ====================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_modified_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- 为所有需要追踪更新时间的表添加触发器
|
||||
CREATE TRIGGER update_users_modtime
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_column();
|
||||
|
||||
CREATE TRIGGER update_user_style_profiles_modtime
|
||||
BEFORE UPDATE ON user_style_profiles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_column();
|
||||
Binary file not shown.
Reference in New Issue
Block a user