Initial backend repository commit.

Set up project files and add .gitignore to exclude local build/runtime artifacts.

Made-with: Cursor
This commit is contained in:
2026-03-09 21:28:58 +08:00
commit 4d8f2ec997
102 changed files with 25022 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
package gorse
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
gorseio "github.com/gorse-io/gorse-go"
)
// FeedbackType 反馈类型
type FeedbackType string
const (
FeedbackTypeLike FeedbackType = "like" // 点赞
FeedbackTypeStar FeedbackType = "star" // 收藏
FeedbackTypeComment FeedbackType = "comment" // 评论
FeedbackTypeRead FeedbackType = "read" // 浏览
)
// Score 非个性化推荐返回的评分项
type Score struct {
Id string `json:"Id"`
Score float64 `json:"Score"`
}
// Client Gorse客户端接口
type Client interface {
// InsertFeedback 插入用户反馈
InsertFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error
// DeleteFeedback 删除用户反馈
DeleteFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error
// GetRecommend 获取个性化推荐列表
GetRecommend(ctx context.Context, userID string, n int, offset int) ([]string, error)
// GetNonPersonalized 获取非个性化推荐(通过名称)
GetNonPersonalized(ctx context.Context, name string, n int, offset int, userID string) ([]string, error)
// UpsertItem 插入或更新物品无embedding
UpsertItem(ctx context.Context, itemID string, categories []string, comment string) error
// UpsertItemWithEmbedding 插入或更新物品带embedding
UpsertItemWithEmbedding(ctx context.Context, itemID string, categories []string, comment string, textToEmbed string) error
// DeleteItem 删除物品
DeleteItem(ctx context.Context, itemID string) error
// UpsertUser 插入或更新用户
UpsertUser(ctx context.Context, userID string, labels map[string]any) error
// IsEnabled 检查是否启用
IsEnabled() bool
}
// client Gorse客户端实现
type client struct {
config Config
gorse *gorseio.GorseClient
httpClient *http.Client
}
// NewClient 创建新的Gorse客户端
func NewClient(cfg Config) Client {
if !cfg.Enabled {
return &noopClient{}
}
gorse := gorseio.NewGorseClient(cfg.Address, cfg.APIKey)
return &client{
config: cfg,
gorse: gorse,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// IsEnabled 检查是否启用
func (c *client) IsEnabled() bool {
return c.config.Enabled
}
// InsertFeedback 插入用户反馈
func (c *client) InsertFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error {
if !c.config.Enabled {
return nil
}
_, err := c.gorse.InsertFeedback(ctx, []gorseio.Feedback{
{
FeedbackType: string(feedbackType),
UserId: userID,
ItemId: itemID,
Timestamp: time.Now().UTC().Truncate(time.Second),
},
})
return err
}
// DeleteFeedback 删除用户反馈
func (c *client) DeleteFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error {
if !c.config.Enabled {
return nil
}
_, err := c.gorse.DeleteFeedback(ctx, string(feedbackType), userID, itemID)
return err
}
// GetRecommend 获取个性化推荐列表
func (c *client) GetRecommend(ctx context.Context, userID string, n int, offset int) ([]string, error) {
if !c.config.Enabled {
return nil, nil
}
result, err := c.gorse.GetRecommend(ctx, userID, "", n, offset)
if err != nil {
return nil, err
}
return result, nil
}
// GetNonPersonalized 获取非个性化推荐
// name: 推荐器名称,如 "most_liked_weekly"
// n: 返回数量
// offset: 偏移量
// userID: 可选,用于排除用户已读物品
func (c *client) GetNonPersonalized(ctx context.Context, name string, n int, offset int, userID string) ([]string, error) {
if !c.config.Enabled {
return nil, nil
}
// 构建URL
url := fmt.Sprintf("%s/api/non-personalized/%s?n=%d&offset=%d", c.config.Address, name, n, offset)
if userID != "" {
url += fmt.Sprintf("&user-id=%s", userID)
}
// 创建请求
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// 设置API Key
if c.config.APIKey != "" {
req.Header.Set("X-API-Key", c.config.APIKey)
}
// 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("gorse api error: status=%d, body=%s", resp.StatusCode, string(body))
}
// 解析响应
var scores []Score
if err := json.Unmarshal(body, &scores); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
// 提取ID
ids := make([]string, len(scores))
for i, score := range scores {
ids[i] = score.Id
}
return ids, nil
}
// UpsertItem 插入或更新物品
func (c *client) UpsertItem(ctx context.Context, itemID string, categories []string, comment string) error {
if !c.config.Enabled {
return nil
}
_, err := c.gorse.InsertItem(ctx, gorseio.Item{
ItemId: itemID,
IsHidden: false,
Categories: categories,
Comment: comment,
Timestamp: time.Now().UTC().Truncate(time.Second),
})
return err
}
// UpsertItemWithEmbedding 插入或更新物品带embedding
func (c *client) UpsertItemWithEmbedding(ctx context.Context, itemID string, categories []string, comment string, textToEmbed string) error {
if !c.config.Enabled {
return nil
}
// 生成embedding
var embedding []float64
if textToEmbed != "" {
var err error
embedding, err = GetEmbedding(textToEmbed)
if err != nil {
log.Printf("[WARN] Failed to get embedding for item %s: %v, using zero vector", itemID, err)
embedding = make([]float64, 1024)
}
} else {
embedding = make([]float64, 1024)
}
_, err := c.gorse.InsertItem(ctx, gorseio.Item{
ItemId: itemID,
IsHidden: false,
Categories: categories,
Comment: comment,
Timestamp: time.Now().UTC().Truncate(time.Second),
Labels: map[string]any{
"embedding": embedding,
},
})
return err
}
// DeleteItem 删除物品
func (c *client) DeleteItem(ctx context.Context, itemID string) error {
if !c.config.Enabled {
return nil
}
_, err := c.gorse.DeleteItem(ctx, itemID)
return err
}
// UpsertUser 插入或更新用户
func (c *client) UpsertUser(ctx context.Context, userID string, labels map[string]any) error {
if !c.config.Enabled {
return nil
}
_, err := c.gorse.InsertUser(ctx, gorseio.User{
UserId: userID,
Labels: labels,
})
return err
}
// noopClient 空操作客户端(用于未启用推荐功能时)
type noopClient struct{}
func (c *noopClient) IsEnabled() bool { return false }
func (c *noopClient) InsertFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error {
return nil
}
func (c *noopClient) DeleteFeedback(ctx context.Context, feedbackType FeedbackType, userID, itemID string) error {
return nil
}
func (c *noopClient) GetRecommend(ctx context.Context, userID string, n int, offset int) ([]string, error) {
return nil, nil
}
func (c *noopClient) GetNonPersonalized(ctx context.Context, name string, n int, offset int, userID string) ([]string, error) {
return nil, nil
}
func (c *noopClient) UpsertItem(ctx context.Context, itemID string, categories []string, comment string) error {
return nil
}
func (c *noopClient) UpsertItemWithEmbedding(ctx context.Context, itemID string, categories []string, comment string, textToEmbed string) error {
return nil
}
func (c *noopClient) DeleteItem(ctx context.Context, itemID string) error { return nil }
func (c *noopClient) UpsertUser(ctx context.Context, userID string, labels map[string]any) error {
return nil
}
// 确保实现了接口
var _ Client = (*client)(nil)
var _ Client = (*noopClient)(nil)
// log 用于内部日志
func init() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}

View File

@@ -0,0 +1,23 @@
package gorse
import (
"carrot_bbs/internal/config"
)
// Config Gorse客户端配置从config.GorseConfig转换
type Config struct {
Address string
APIKey string
Enabled bool
Dashboard string
}
// ConfigFromAppConfig 从应用配置创建Gorse配置
func ConfigFromAppConfig(cfg *config.GorseConfig) Config {
return Config{
Address: cfg.Address,
APIKey: cfg.APIKey,
Enabled: cfg.Enabled,
Dashboard: cfg.Dashboard,
}
}

View File

@@ -0,0 +1,106 @@
package gorse
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
)
// EmbeddingConfig embedding服务配置
type EmbeddingConfig struct {
APIKey string
URL string
Model string
}
var defaultEmbeddingConfig = EmbeddingConfig{
APIKey: "sk-ZPN5NMPSqEaOGCPfD2LqndZ5Wwmw3DC4CQgzgKhM35fI3RpD",
URL: "https://api.littlelan.cn/v1/embeddings",
Model: "BAAI/bge-m3",
}
// SetEmbeddingConfig 设置embedding配置
func SetEmbeddingConfig(apiKey, url, model string) {
if apiKey != "" {
defaultEmbeddingConfig.APIKey = apiKey
}
if url != "" {
defaultEmbeddingConfig.URL = url
}
if model != "" {
defaultEmbeddingConfig.Model = model
}
}
// GetEmbedding 获取文本的embedding
func GetEmbedding(text string) ([]float64, error) {
type embeddingRequest struct {
Input string `json:"input"`
Model string `json:"model"`
}
type embeddingResponse struct {
Data []struct {
Embedding []float64 `json:"embedding"`
} `json:"data"`
}
reqBody := embeddingRequest{
Input: text,
Model: defaultEmbeddingConfig.Model,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", defaultEmbeddingConfig.URL, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+defaultEmbeddingConfig.APIKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("embedding API error: status=%d, body=%s", resp.StatusCode, string(body))
}
var result embeddingResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if len(result.Data) == 0 {
return nil, fmt.Errorf("no embedding returned")
}
return result.Data[0].Embedding, nil
}
// InitEmbeddingWithConfig 从应用配置初始化embedding
func InitEmbeddingWithConfig(apiKey, url, model string) {
if apiKey == "" {
log.Println("[WARN] Gorse embedding API key not set, using default")
}
defaultEmbeddingConfig.APIKey = apiKey
if url != "" {
defaultEmbeddingConfig.URL = url
}
if model != "" {
defaultEmbeddingConfig.Model = model
}
}