merge: 合并 feat/tokentime 到 develop,解决 limiter/scenarioRepo 参数冲突
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/hhs/camtalk/internal/api"
|
||||
@@ -55,6 +56,7 @@ func main() {
|
||||
var userRepo store.UserRepository
|
||||
var msgRepo store.MessageRepository
|
||||
var sessRepo store.SessionRepository
|
||||
var pool *pgxpool.Pool // 数据库连接池
|
||||
|
||||
// L3: PostgreSQL(冷数据持久化层)
|
||||
dsn := cfg.Storage.Persistence.DSN
|
||||
@@ -66,7 +68,8 @@ func main() {
|
||||
logger.Log.Fatalw("storage.persistence.dsn is required when persistence is enabled",
|
||||
"hint", "set CAMTALK_STORAGE_DSN environment variable")
|
||||
}
|
||||
pool, err := store.NewPostgresPool(ctx, dsn)
|
||||
var err error
|
||||
pool, err = store.NewPostgresPool(ctx, dsn)
|
||||
if err != nil {
|
||||
logger.Log.Fatalw("failed to connect to postgres", "error", err)
|
||||
}
|
||||
@@ -182,7 +185,11 @@ func main() {
|
||||
}
|
||||
|
||||
// 初始化 Eino Graph + Orchestrator
|
||||
pipelineGraph, err := eino.NewPipelineGraph(ctx, cfg, sttService, ttsService, sessionMgr)
|
||||
var userScenarioRepo store.UserScenarioRepository
|
||||
if pool != nil {
|
||||
userScenarioRepo = store.NewPostgresUserScenarioRepo(pool)
|
||||
}
|
||||
pipelineGraph, err := eino.NewPipelineGraph(ctx, cfg, sttService, ttsService, sessionMgr, userScenarioRepo)
|
||||
if err != nil {
|
||||
logger.Log.Fatalw("failed to create eino pipeline graph", "error", err)
|
||||
}
|
||||
@@ -239,8 +246,23 @@ func main() {
|
||||
convHandler := api.NewConversationHandler(sessionMgr, tokenMgr, msgRepo)
|
||||
convHandler.RegisterRoutes(apiGroup)
|
||||
|
||||
// UserScenario REST 端点
|
||||
if pool != nil {
|
||||
userScenarioRepo := store.NewPostgresUserScenarioRepo(pool)
|
||||
userScenarioHandler := api.NewUserScenarioHandler(userScenarioRepo)
|
||||
scenarioGroup := apiGroup.Group("/scenarios")
|
||||
scenarioGroup.Use(auth.AuthMiddleware(tokenMgr))
|
||||
{
|
||||
scenarioGroup.GET("", userScenarioHandler.List)
|
||||
scenarioGroup.POST("", userScenarioHandler.Create)
|
||||
scenarioGroup.GET("/:id", userScenarioHandler.Get)
|
||||
scenarioGroup.PATCH("/:id", userScenarioHandler.Update)
|
||||
scenarioGroup.DELETE("/:id", userScenarioHandler.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket
|
||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter))
|
||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter, userScenarioRepo))
|
||||
|
||||
// HTTP Server
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -3,6 +3,7 @@ module github.com/hhs/camtalk
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/cloudwego/eino v0.9.9
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
@@ -19,7 +20,6 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
|
||||
@@ -84,41 +84,65 @@ var scenarioPrompts = map[string]scenarioPrompt{
|
||||
}
|
||||
|
||||
// GetScenarioPrompt 根据情景 ID 和语言获取对应的 system prompt。
|
||||
// 支持系统预置情景和用户自建情景。
|
||||
// customScenarios: 用户自建情景映射表(scenarioID → prompt),可为 nil
|
||||
// 返回空字符串表示无此情景(使用默认 prompt)。
|
||||
func GetScenarioPrompt(scenarioID, language string) string {
|
||||
func GetScenarioPrompt(scenarioID, language string, customScenarios map[string]string) string {
|
||||
if scenarioID == "" || scenarioID == "free_chat" {
|
||||
return ""
|
||||
}
|
||||
p, ok := scenarioPrompts[scenarioID]
|
||||
if !ok {
|
||||
return ""
|
||||
|
||||
// 1. 优先查找系统预置情景
|
||||
if p, ok := scenarioPrompts[scenarioID]; ok {
|
||||
switch {
|
||||
case strings.HasPrefix(language, "zh"):
|
||||
return p.ZH
|
||||
case strings.HasPrefix(language, "ja"):
|
||||
return p.JA
|
||||
default:
|
||||
return p.EN
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(language, "zh"):
|
||||
return p.ZH
|
||||
case strings.HasPrefix(language, "ja"):
|
||||
return p.JA
|
||||
default:
|
||||
return p.EN
|
||||
|
||||
// 2. 查找用户自建情景
|
||||
if customScenarios != nil {
|
||||
if customPrompt, ok := customScenarios[scenarioID]; ok {
|
||||
return customPrompt
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 默认空字符串
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetScenarioGreeting 根据情景 ID 和语言获取对应的首句引导。
|
||||
// 支持系统预置情景和用户自建情景。
|
||||
// customGreetings: 用户自建情景的首句引导映射表(scenarioID → greeting),可为 nil
|
||||
// 返回空字符串表示无此情景或不需要引导(自由对话)。
|
||||
func GetScenarioGreeting(scenarioID, language string) string {
|
||||
func GetScenarioGreeting(scenarioID, language string, customGreetings map[string]string) string {
|
||||
if scenarioID == "" || scenarioID == "free_chat" {
|
||||
return ""
|
||||
}
|
||||
p, ok := scenarioPrompts[scenarioID]
|
||||
if !ok {
|
||||
return ""
|
||||
|
||||
// 1. 优先查找系统预置情景
|
||||
if p, ok := scenarioPrompts[scenarioID]; ok {
|
||||
switch {
|
||||
case strings.HasPrefix(language, "zh"):
|
||||
return p.GreetingZH
|
||||
case strings.HasPrefix(language, "ja"):
|
||||
return p.GreetingJA
|
||||
default:
|
||||
return p.GreetingEN
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(language, "zh"):
|
||||
return p.GreetingZH
|
||||
case strings.HasPrefix(language, "ja"):
|
||||
return p.GreetingJA
|
||||
default:
|
||||
return p.GreetingEN
|
||||
|
||||
// 2. 查找用户自建情景
|
||||
if customGreetings != nil {
|
||||
if customGreeting, ok := customGreetings[scenarioID]; ok {
|
||||
return customGreeting
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 默认空字符串
|
||||
return ""
|
||||
}
|
||||
|
||||
208
backend/internal/api/user_scenario_handler.go
Normal file
208
backend/internal/api/user_scenario_handler.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxScenariosPerUser = 20 // 每个用户最多 20 个自建情景
|
||||
MaxPromptLength = 2000 // Prompt 最大长度
|
||||
)
|
||||
|
||||
// UserScenarioHandler 用户情景 API Handler。
|
||||
type UserScenarioHandler struct {
|
||||
repo store.UserScenarioRepository
|
||||
}
|
||||
|
||||
// NewUserScenarioHandler 创建用户情景 Handler。
|
||||
func NewUserScenarioHandler(repo store.UserScenarioRepository) *UserScenarioHandler {
|
||||
return &UserScenarioHandler{repo: repo}
|
||||
}
|
||||
|
||||
// List 获取用户的所有自建情景。
|
||||
// GET /api/scenarios
|
||||
func (h *UserScenarioHandler) List(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
scenarios, err := h.repo.FindByUserID(c.Request.Context(), userID.(string))
|
||||
if err != nil {
|
||||
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if scenarios == nil {
|
||||
scenarios = []*models.UserScenario{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.UserScenarioListResponse{
|
||||
Scenarios: scenarios,
|
||||
Total: len(scenarios),
|
||||
})
|
||||
}
|
||||
|
||||
// Create 创建用户情景。
|
||||
// POST /api/scenarios
|
||||
func (h *UserScenarioHandler) Create(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateUserScenarioRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户是否已达上限
|
||||
count, err := h.repo.CountByUserID(c.Request.Context(), userID.(string))
|
||||
if err != nil {
|
||||
logger.Log.Errorw("统计用户情景数量失败", "user_id", userID, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
if count >= MaxScenariosPerUser {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "已达创建上限(最多 20 个)"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建情景
|
||||
scenario := &models.UserScenario{
|
||||
UserID: userID.(string),
|
||||
Name: req.Name,
|
||||
Icon: req.Icon,
|
||||
Description: req.Description,
|
||||
Prompt: req.Prompt,
|
||||
Greeting: req.Greeting,
|
||||
Language: req.Language,
|
||||
}
|
||||
|
||||
if err := h.repo.Create(c.Request.Context(), scenario); err != nil {
|
||||
logger.Log.Errorw("创建用户情景失败", "user_id", userID, "error", err)
|
||||
if err.Error() == "duplicate key value violates unique constraint" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "情景名称已存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log.Infow("创建用户情景成功", "user_id", userID, "scenario_id", scenario.ID)
|
||||
c.JSON(http.StatusCreated, scenario)
|
||||
}
|
||||
|
||||
// Get 获取单个情景详情。
|
||||
// GET /api/scenarios/:id
|
||||
func (h *UserScenarioHandler) Get(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
scenarioID := c.Param("id")
|
||||
scenario, err := h.repo.FindByIDAndUserID(c.Request.Context(), scenarioID, userID.(string))
|
||||
if err != nil {
|
||||
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "情景不存在或无权限"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, scenario)
|
||||
}
|
||||
|
||||
// Update 更新用户情景。
|
||||
// PATCH /api/scenarios/:id
|
||||
func (h *UserScenarioHandler) Update(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
scenarioID := c.Param("id")
|
||||
|
||||
// 查询并校验所有权
|
||||
scenario, err := h.repo.FindByIDAndUserID(c.Request.Context(), scenarioID, userID.(string))
|
||||
if err != nil {
|
||||
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "情景不存在或无权限"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateUserScenarioRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.Name != nil {
|
||||
scenario.Name = *req.Name
|
||||
}
|
||||
if req.Icon != nil {
|
||||
scenario.Icon = *req.Icon
|
||||
}
|
||||
if req.Description != nil {
|
||||
scenario.Description = *req.Description
|
||||
}
|
||||
if req.Prompt != nil {
|
||||
scenario.Prompt = *req.Prompt
|
||||
}
|
||||
if req.Greeting != nil {
|
||||
scenario.Greeting = *req.Greeting
|
||||
}
|
||||
if req.Language != nil {
|
||||
scenario.Language = *req.Language
|
||||
}
|
||||
|
||||
if err := h.repo.Update(c.Request.Context(), scenario); err != nil {
|
||||
logger.Log.Errorw("更新用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log.Infow("更新用户情景成功", "user_id", userID, "scenario_id", scenarioID)
|
||||
c.JSON(http.StatusOK, scenario)
|
||||
}
|
||||
|
||||
// Delete 删除用户情景。
|
||||
// DELETE /api/scenarios/:id
|
||||
func (h *UserScenarioHandler) Delete(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
scenarioID := c.Param("id")
|
||||
|
||||
// 查询并校验所有权
|
||||
_, err := h.repo.FindByIDAndUserID(c.Request.Context(), scenarioID, userID.(string))
|
||||
if err != nil {
|
||||
logger.Log.Errorw("查询用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "情景不存在或无权限"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.Delete(c.Request.Context(), scenarioID); err != nil {
|
||||
logger.Log.Errorw("删除用户情景失败", "user_id", userID, "scenario_id", scenarioID, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Log.Infow("删除用户情景成功", "user_id", userID, "scenario_id", scenarioID)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -119,6 +119,7 @@ func (e *EinoOrchestrator) ProcessQuery(
|
||||
state.Language = input.Language
|
||||
state.DetailLevel = sess.Config.DetailLevel
|
||||
state.TTSEnabled = input.TTSEnabled
|
||||
state.UserID = input.UserID
|
||||
ctx = WithPipelineState(ctx, state)
|
||||
|
||||
// 6. 调用 Graph(Stream 模式 + 运行时 Callback)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -42,6 +43,7 @@ func NewPipelineGraph(
|
||||
sttService stt.Service,
|
||||
ttsService tts.Service,
|
||||
sessionMgr session.Manager,
|
||||
scenarioRepo store.UserScenarioRepository,
|
||||
) (*PipelineGraph, error) {
|
||||
log := logger.Log
|
||||
|
||||
@@ -68,7 +70,7 @@ func NewPipelineGraph(
|
||||
maxHistory := cfg.Session.MaxHistory
|
||||
|
||||
_ = g.AddLambdaNode(nodeSTT, NewSTTLambda(sttService))
|
||||
_ = g.AddLambdaNode(nodeHistory, NewHistoryLambda(sessionMgr.GetHistory, maxHistory))
|
||||
_ = g.AddLambdaNode(nodeHistory, NewHistoryLambda(sessionMgr.GetHistory, scenarioRepo, maxHistory))
|
||||
_ = g.AddChatModelNode(nodeLLM, chatModel)
|
||||
_ = g.AddLambdaNode(nodeMessageToString, NewMessageToStringLambda())
|
||||
_ = g.AddLambdaNode(nodeSplitter, NewSplitterLambda())
|
||||
@@ -112,5 +114,6 @@ func buildPipelineInput(req models.WsQuery, sessionID string, sess *models.Sessi
|
||||
Language: sess.Config.Language,
|
||||
Scenario: sess.Config.Scenario,
|
||||
TTSEnabled: sess.Config.TTSEnabled,
|
||||
UserID: sess.UserID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ func TestNewHistoryLambda_ReturnsNonNil(t *testing.T) {
|
||||
fetcher := func(ctx context.Context, sessionID string, limit int) ([]models.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
lambda := NewHistoryLambda(fetcher, 10)
|
||||
lambda := NewHistoryLambda(fetcher, nil, 10)
|
||||
require.NotNil(t, lambda)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/hhs/camtalk/internal/ai/llm"
|
||||
"github.com/hhs/camtalk/internal/logger"
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
)
|
||||
|
||||
// NewHistoryLambda 创建历史组装 Lambda 节点。
|
||||
@@ -17,7 +18,11 @@ import (
|
||||
//
|
||||
// 从 PipelineState 读取请求元数据(SessionID、Scenario、ImageData 等),
|
||||
// 构建系统提示词,组装历史消息和当前用户输入(含多模态图片)。
|
||||
func NewHistoryLambda(historyFetcher func(ctx context.Context, sessionID string, limit int) ([]models.Message, error), maxHistory int) *compose.Lambda {
|
||||
func NewHistoryLambda(
|
||||
historyFetcher func(ctx context.Context, sessionID string, limit int) ([]models.Message, error),
|
||||
scenarioRepo store.UserScenarioRepository,
|
||||
maxHistory int,
|
||||
) *compose.Lambda {
|
||||
return compose.InvokableLambda(func(ctx context.Context, sttOut STTOutput) ([]*schema.Message, error) {
|
||||
log := logger.Log
|
||||
|
||||
@@ -34,10 +39,31 @@ func NewHistoryLambda(historyFetcher func(ctx context.Context, sessionID string,
|
||||
scenario := state.Scenario
|
||||
detailLevel := state.DetailLevel
|
||||
language := sttOut.Language
|
||||
userID := state.UserID
|
||||
state.mu.Unlock()
|
||||
|
||||
// 构建系统提示词
|
||||
scenarioPrompt := llm.GetScenarioPrompt(scenario, language)
|
||||
// 加载用户自建情景(如果有 userID 和 scenarioRepo)
|
||||
var customScenarios map[string]string
|
||||
var customGreetings map[string]string
|
||||
if userID != "" && scenarioRepo != nil {
|
||||
scenarios, err := scenarioRepo.FindByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
log.Warnw("加载用户自建情景失败", "user_id", userID, "error", err)
|
||||
} else if len(scenarios) > 0 {
|
||||
customScenarios = make(map[string]string, len(scenarios))
|
||||
customGreetings = make(map[string]string, len(scenarios))
|
||||
for _, s := range scenarios {
|
||||
customScenarios[s.ID] = s.Prompt
|
||||
if s.Greeting != "" {
|
||||
customGreetings[s.ID] = s.Greeting
|
||||
}
|
||||
}
|
||||
log.Debugw("加载用户自建情景", "user_id", userID, "count", len(scenarios))
|
||||
}
|
||||
}
|
||||
|
||||
// 构建系统提示词(支持用户自建情景)
|
||||
scenarioPrompt := llm.GetScenarioPrompt(scenario, language, customScenarios)
|
||||
systemPrompt := llm.BuildSystemPrompt(language, detailLevel, scenarioPrompt)
|
||||
|
||||
// 构建 system message(仅文本,多模态内容只能放在 user 角色)
|
||||
|
||||
@@ -23,6 +23,7 @@ type PipelineState struct {
|
||||
DetailLevel string
|
||||
Language string
|
||||
TTSEnabled bool
|
||||
UserID string // 新增:用户 ID,用于加载自建情景
|
||||
}
|
||||
|
||||
// genLocalState 创建每请求的 PipelineState 实例。
|
||||
|
||||
@@ -12,6 +12,7 @@ type PipelineInput struct {
|
||||
Language string // zh / en
|
||||
Scenario string // free_chat, interviewer, etc.
|
||||
TTSEnabled bool
|
||||
UserID string // 用户 ID,用于加载自建情景
|
||||
}
|
||||
|
||||
// PipelineOutput Graph 统一输出。
|
||||
|
||||
43
backend/internal/models/user_scenario.go
Normal file
43
backend/internal/models/user_scenario.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// UserScenario 用户自建情景。
|
||||
type UserScenario struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
Prompt string `json:"prompt"`
|
||||
Greeting string `json:"greeting,omitempty"`
|
||||
Language string `json:"language"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateUserScenarioRequest 创建用户情景请求。
|
||||
type CreateUserScenarioRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=50"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Description string `json:"description,omitempty" binding:"omitempty,max=100"`
|
||||
Prompt string `json:"prompt" binding:"required,min=10,max=2000"`
|
||||
Greeting string `json:"greeting,omitempty" binding:"omitempty,max=500"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateUserScenarioRequest 更新用户情景请求。
|
||||
type UpdateUserScenarioRequest struct {
|
||||
Name *string `json:"name,omitempty" binding:"omitempty,min=2,max=50"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Description *string `json:"description,omitempty" binding:"omitempty,max=100"`
|
||||
Prompt *string `json:"prompt,omitempty" binding:"omitempty,min=10,max=2000"`
|
||||
Greeting *string `json:"greeting,omitempty" binding:"omitempty,max=500"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// UserScenarioListResponse 用户情景列表响应。
|
||||
type UserScenarioListResponse struct {
|
||||
Scenarios []*UserScenario `json:"scenarios"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
238
backend/internal/store/user_scenario_repository.go
Normal file
238
backend/internal/store/user_scenario_repository.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/hhs/camtalk/internal/models"
|
||||
)
|
||||
|
||||
// UserScenarioRepository 用户自建情景仓储接口。
|
||||
type UserScenarioRepository interface {
|
||||
Create(ctx context.Context, scenario *models.UserScenario) error
|
||||
FindByID(ctx context.Context, id string) (*models.UserScenario, error)
|
||||
FindByIDAndUserID(ctx context.Context, id, userID string) (*models.UserScenario, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]*models.UserScenario, error)
|
||||
Update(ctx context.Context, scenario *models.UserScenario) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
CountByUserID(ctx context.Context, userID string) (int, error)
|
||||
}
|
||||
|
||||
// PostgresUserScenarioRepo PostgreSQL 实现。
|
||||
type PostgresUserScenarioRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgresUserScenarioRepo 创建 PostgreSQL 用户情景仓储。
|
||||
func NewPostgresUserScenarioRepo(pool *pgxpool.Pool) UserScenarioRepository {
|
||||
return &PostgresUserScenarioRepo{pool: pool}
|
||||
}
|
||||
|
||||
// Create 创建用户情景。
|
||||
func (r *PostgresUserScenarioRepo) Create(ctx context.Context, scenario *models.UserScenario) error {
|
||||
query := `
|
||||
INSERT INTO user_scenarios (id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NULLIF($5, ''), $6, NULLIF($7, ''), $8, $9, $10)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
now := time.Now()
|
||||
scenario.CreatedAt = now
|
||||
scenario.UpdatedAt = now
|
||||
|
||||
if scenario.ID == "" {
|
||||
scenario.ID = uuid.New().String()
|
||||
}
|
||||
if scenario.Icon == "" {
|
||||
scenario.Icon = "✨"
|
||||
}
|
||||
if scenario.Language == "" {
|
||||
scenario.Language = "zh-CN"
|
||||
}
|
||||
|
||||
err := r.pool.QueryRow(ctx, query,
|
||||
scenario.ID,
|
||||
scenario.UserID,
|
||||
scenario.Name,
|
||||
scenario.Icon,
|
||||
scenario.Description,
|
||||
scenario.Prompt,
|
||||
scenario.Greeting,
|
||||
scenario.Language,
|
||||
scenario.CreatedAt,
|
||||
scenario.UpdatedAt,
|
||||
).Scan(&scenario.ID, &scenario.CreatedAt, &scenario.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user scenario: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindByID 根据 ID 查找情景。
|
||||
func (r *PostgresUserScenarioRepo) FindByID(ctx context.Context, id string) (*models.UserScenario, error) {
|
||||
query := `
|
||||
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||
FROM user_scenarios
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var scenario models.UserScenario
|
||||
err := r.pool.QueryRow(ctx, query, id).Scan(
|
||||
&scenario.ID,
|
||||
&scenario.UserID,
|
||||
&scenario.Name,
|
||||
&scenario.Icon,
|
||||
&scenario.Description,
|
||||
&scenario.Prompt,
|
||||
&scenario.Greeting,
|
||||
&scenario.Language,
|
||||
&scenario.CreatedAt,
|
||||
&scenario.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("user scenario not found: %s", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find user scenario: %w", err)
|
||||
}
|
||||
return &scenario, nil
|
||||
}
|
||||
|
||||
// FindByIDAndUserID 根据 ID 和用户 ID 查找情景(权限校验)。
|
||||
func (r *PostgresUserScenarioRepo) FindByIDAndUserID(ctx context.Context, id, userID string) (*models.UserScenario, error) {
|
||||
query := `
|
||||
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||
FROM user_scenarios
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
var scenario models.UserScenario
|
||||
err := r.pool.QueryRow(ctx, query, id, userID).Scan(
|
||||
&scenario.ID,
|
||||
&scenario.UserID,
|
||||
&scenario.Name,
|
||||
&scenario.Icon,
|
||||
&scenario.Description,
|
||||
&scenario.Prompt,
|
||||
&scenario.Greeting,
|
||||
&scenario.Language,
|
||||
&scenario.CreatedAt,
|
||||
&scenario.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, fmt.Errorf("user scenario not found or no permission")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find user scenario: %w", err)
|
||||
}
|
||||
return &scenario, nil
|
||||
}
|
||||
|
||||
// FindByUserID 查找用户的所有情景。
|
||||
func (r *PostgresUserScenarioRepo) FindByUserID(ctx context.Context, userID string) ([]*models.UserScenario, error) {
|
||||
query := `
|
||||
SELECT id, user_id, name, icon, description, prompt, greeting, language, created_at, updated_at
|
||||
FROM user_scenarios
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find user scenarios: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var scenarios []*models.UserScenario
|
||||
for rows.Next() {
|
||||
var s models.UserScenario
|
||||
err := rows.Scan(
|
||||
&s.ID,
|
||||
&s.UserID,
|
||||
&s.Name,
|
||||
&s.Icon,
|
||||
&s.Description,
|
||||
&s.Prompt,
|
||||
&s.Greeting,
|
||||
&s.Language,
|
||||
&s.CreatedAt,
|
||||
&s.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan user scenario: %w", err)
|
||||
}
|
||||
scenarios = append(scenarios, &s)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate user scenarios: %w", err)
|
||||
}
|
||||
return scenarios, nil
|
||||
}
|
||||
|
||||
// Update 更新用户情景。
|
||||
func (r *PostgresUserScenarioRepo) Update(ctx context.Context, scenario *models.UserScenario) error {
|
||||
query := `
|
||||
UPDATE user_scenarios
|
||||
SET name = $1, icon = $2, description = $3, prompt = $4, greeting = $5, language = $6, updated_at = $7
|
||||
WHERE id = $8 AND user_id = $9
|
||||
RETURNING updated_at
|
||||
`
|
||||
|
||||
scenario.UpdatedAt = time.Now()
|
||||
|
||||
err := r.pool.QueryRow(ctx, query,
|
||||
scenario.Name,
|
||||
scenario.Icon,
|
||||
scenario.Description,
|
||||
scenario.Prompt,
|
||||
scenario.Greeting,
|
||||
scenario.Language,
|
||||
scenario.UpdatedAt,
|
||||
scenario.ID,
|
||||
scenario.UserID,
|
||||
).Scan(&scenario.UpdatedAt)
|
||||
|
||||
if err == pgx.ErrNoRows {
|
||||
return fmt.Errorf("user scenario not found or no permission")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user scenario: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除用户情景。
|
||||
func (r *PostgresUserScenarioRepo) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM user_scenarios WHERE id = $1`
|
||||
|
||||
result, err := r.pool.Exec(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user scenario: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("user scenario not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByUserID 统计用户的情景数量。
|
||||
func (r *PostgresUserScenarioRepo) CountByUserID(ctx context.Context, userID string) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM user_scenarios WHERE user_id = $1`
|
||||
|
||||
var count int
|
||||
err := r.pool.QueryRow(ctx, query, userID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count user scenarios: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/hhs/camtalk/internal/orchestrator"
|
||||
"github.com/hhs/camtalk/internal/ratelimit"
|
||||
"github.com/hhs/camtalk/internal/session"
|
||||
"github.com/hhs/camtalk/internal/store"
|
||||
)
|
||||
|
||||
// newUpgrader 根据配置创建 WebSocket upgrader。
|
||||
@@ -43,12 +44,12 @@ func newUpgrader(cfg *config.Config) websocket.Upgrader {
|
||||
|
||||
// Client 代表一个 WebSocket 客户端连接。
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
sessionID string
|
||||
sessionMgr session.Manager
|
||||
orchestrator orchestrator.Orchestrator
|
||||
cancelFuncs map[string]context.CancelFunc // requestID → cancel func
|
||||
mu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
sessionID string
|
||||
sessionMgr session.Manager
|
||||
orchestrator orchestrator.Orchestrator
|
||||
cancelFuncs map[string]context.CancelFunc // requestID → cancel func
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// SendJSON 向客户端发送 JSON 消息(公开以便 errors 包调用)。
|
||||
@@ -95,19 +96,19 @@ func (w *WSClient) SendError(err models.WsError) error {
|
||||
}
|
||||
|
||||
// ServeWS 处理 WebSocket 升级请求。
|
||||
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config, tokenMgr *auth.TokenManager, limiter ratelimit.Limiter) gin.HandlerFunc {
|
||||
func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config, tokenMgr *auth.TokenManager, limiter ratelimit.Limiter, scenarioRepo store.UserScenarioRepository) gin.HandlerFunc {
|
||||
upgrader := newUpgrader(cfg)
|
||||
heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second
|
||||
heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second
|
||||
version := cfg.App.Version
|
||||
|
||||
return func(c *gin.Context) {
|
||||
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, tokenMgr, limiter)
|
||||
serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, tokenMgr, limiter, scenarioRepo)
|
||||
}
|
||||
}
|
||||
|
||||
func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator,
|
||||
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, tokenMgr *auth.TokenManager, limiter ratelimit.Limiter) {
|
||||
upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, tokenMgr *auth.TokenManager, limiter ratelimit.Limiter, scenarioRepo store.UserScenarioRepository) {
|
||||
|
||||
// --- JWT 认证(upgrade 前完成,失败直接返回 HTTP 错误) ---
|
||||
token := c.Query("token")
|
||||
@@ -303,7 +304,21 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche
|
||||
if scenarioID != "" && scenarioID != "free_chat" {
|
||||
sess, err := client.sessionMgr.Get(context.Background(), sessionID)
|
||||
if err == nil && sess != nil {
|
||||
greeting := llm.GetScenarioGreeting(scenarioID, sess.Config.Language)
|
||||
// 加载用户自建情景
|
||||
var customGreetings map[string]string
|
||||
if sess.UserID != "" && scenarioRepo != nil {
|
||||
scenarios, err := scenarioRepo.FindByUserID(context.Background(), sess.UserID)
|
||||
if err == nil && len(scenarios) > 0 {
|
||||
customGreetings = make(map[string]string, len(scenarios))
|
||||
for _, s := range scenarios {
|
||||
if s.Greeting != "" {
|
||||
customGreetings[s.ID] = s.Greeting
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
greeting := llm.GetScenarioGreeting(scenarioID, sess.Config.Language, customGreetings)
|
||||
if greeting != "" {
|
||||
// 发送首句作为 AI 消息
|
||||
_ = client.SendJSON(models.WsLLMChunk{
|
||||
|
||||
6
backend/migrations/004_user_scenarios.down.sql
Normal file
6
backend/migrations/004_user_scenarios.down.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- 004_user_scenarios.down.sql
|
||||
-- 回滚用户自建情景表
|
||||
|
||||
DROP INDEX IF EXISTS idx_user_scenarios_created_at;
|
||||
DROP INDEX IF EXISTS idx_user_scenarios_user_id;
|
||||
DROP TABLE IF EXISTS user_scenarios;
|
||||
36
backend/migrations/004_user_scenarios.up.sql
Normal file
36
backend/migrations/004_user_scenarios.up.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- 004_user_scenarios.up.sql
|
||||
-- 用户自建情景表
|
||||
|
||||
CREATE TABLE user_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
icon VARCHAR(10) DEFAULT '✨',
|
||||
description VARCHAR(100) NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
greeting VARCHAR(200),
|
||||
language VARCHAR(10) DEFAULT 'zh-CN',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT unique_user_scenario UNIQUE(user_id, name),
|
||||
CONSTRAINT check_name_length CHECK (char_length(name) >= 2 AND char_length(name) <= 50),
|
||||
CONSTRAINT check_description_length CHECK (char_length(description) >= 5 AND char_length(description) <= 100),
|
||||
CONSTRAINT check_prompt_length CHECK (char_length(prompt) >= 50 AND char_length(prompt) <= 2000)
|
||||
);
|
||||
|
||||
-- 为用户 ID 创建索引,加速查询
|
||||
CREATE INDEX idx_user_scenarios_user_id ON user_scenarios(user_id);
|
||||
|
||||
-- 为创建时间创建索引,用于排序
|
||||
CREATE INDEX idx_user_scenarios_created_at ON user_scenarios(created_at DESC);
|
||||
|
||||
COMMENT ON TABLE user_scenarios IS '用户自建情景表';
|
||||
COMMENT ON COLUMN user_scenarios.id IS '情景唯一标识';
|
||||
COMMENT ON COLUMN user_scenarios.user_id IS '所属用户 ID,外键关联 users 表';
|
||||
COMMENT ON COLUMN user_scenarios.name IS '情景名称,如"创意写作导师"';
|
||||
COMMENT ON COLUMN user_scenarios.icon IS 'Emoji 图标,如"🎨"';
|
||||
COMMENT ON COLUMN user_scenarios.description IS '简短描述,显示在情景卡片上';
|
||||
COMMENT ON COLUMN user_scenarios.prompt IS '角色 System Prompt,定义 AI 行为';
|
||||
COMMENT ON COLUMN user_scenarios.greeting IS '首句引导,可选';
|
||||
COMMENT ON COLUMN user_scenarios.language IS '默认语言,如 zh-CN、en-US';
|
||||
Reference in New Issue
Block a user