package portal import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "time" "aigateway.local/core/internal/gateway" "aigateway.local/core/internal/identity" platformid "aigateway.local/core/internal/platform/id" "github.com/jackc/pgx/v5" ) type ConversationMessage struct { Sequence int `json:"sequence"` Role string `json:"role"` Content string `json:"content"` CreatedAt time.Time `json:"created_at"` } type Conversation struct { ID string `json:"id"` ApplicationCode string `json:"application_code"` Title string `json:"title"` Status string `json:"status"` Messages []ConversationMessage `json:"messages"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func (s *Service) CreateConversation(ctx context.Context, account identity.Account, code string) (Conversation, error) { app, err := s.assets.GetPublishedApplicationByCode(ctx, strings.ToLower(strings.TrimSpace(code))) if err != nil || !visible(app.DepartmentIDs, account.DepartmentID) { return Conversation{}, ErrNotFound } if s.credentials == nil || s.runtime == nil { return Conversation{}, errors.New("应用运行服务未配置") } if _, _, err = s.credentials.Ensure(ctx, app.ID, account.DepartmentID); err != nil { return Conversation{}, err } id, err := platformid.NewUUID() if err != nil { return Conversation{}, err } var item Conversation err = s.pool.QueryRow(ctx, `INSERT INTO gateway.portal_conversations(id,portal_user_id,application_id) VALUES($1,$2,$3) RETURNING id::text,'',title,status,created_at,updated_at`, id, account.ID, app.ID).Scan(&item.ID, &item.ApplicationCode, &item.Title, &item.Status, &item.CreatedAt, &item.UpdatedAt) item.ApplicationCode = app.Code item.Messages = []ConversationMessage{} return item, err } func messageDigest(previous string, sequence int, role, content string) string { digest := sha256.Sum256([]byte(fmt.Sprintf("%s\n%d\n%s\n%s", previous, sequence, role, content))) return hex.EncodeToString(digest[:]) } func (s *Service) conversation(ctx context.Context, account identity.Account, code, id string) (Conversation, error) { var item Conversation err := s.pool.QueryRow(ctx, `SELECT c.id::text,a.code,c.title,c.status,c.created_at,c.updated_at FROM gateway.portal_conversations c JOIN gateway.applications a ON a.id=c.application_id WHERE c.id=$1 AND c.portal_user_id=$2 AND a.code=$3`, id, account.ID, code).Scan(&item.ID, &item.ApplicationCode, &item.Title, &item.Status, &item.CreatedAt, &item.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return Conversation{}, ErrNotFound } if err != nil { return Conversation{}, err } rows, err := s.pool.Query(ctx, `SELECT sequence,role,content,previous_hash,message_hash,created_at FROM gateway.portal_conversation_messages WHERE conversation_id=$1 ORDER BY sequence`, id) if err != nil { return Conversation{}, err } defer rows.Close() previous := strings.Repeat("0", 64) item.Messages = []ConversationMessage{} for rows.Next() { var message ConversationMessage var storedPrevious, storedHash string if err = rows.Scan(&message.Sequence, &message.Role, &message.Content, &storedPrevious, &storedHash, &message.CreatedAt); err != nil { return Conversation{}, err } if storedPrevious != previous || storedHash != messageDigest(previous, message.Sequence, message.Role, message.Content) { return Conversation{}, errors.New("会话历史完整性校验失败") } previous = storedHash item.Messages = append(item.Messages, message) } return item, rows.Err() } func (s *Service) Conversation(ctx context.Context, account identity.Account, code, id string) (Conversation, error) { return s.conversation(ctx, account, strings.ToLower(strings.TrimSpace(code)), id) } func (s *Service) appendMessage(ctx context.Context, conversationID, role, content string) (ConversationMessage, error) { tx, err := s.pool.Begin(ctx) if err != nil { return ConversationMessage{}, err } defer func() { _ = tx.Rollback(ctx) }() var sequence int if err = tx.QueryRow(ctx, `SELECT next_sequence FROM gateway.portal_conversations WHERE id=$1 FOR UPDATE`, conversationID).Scan(&sequence); err != nil { return ConversationMessage{}, err } if sequence > 200 { return ConversationMessage{}, errors.New("本会话已达到 200 条消息上限") } previous := strings.Repeat("0", 64) if sequence > 1 { if err = tx.QueryRow(ctx, `SELECT message_hash FROM gateway.portal_conversation_messages WHERE conversation_id=$1 AND sequence=$2`, conversationID, sequence-1).Scan(&previous); err != nil { return ConversationMessage{}, err } } id, err := platformid.NewUUID() if err != nil { return ConversationMessage{}, err } hash := messageDigest(previous, sequence, role, content) var created time.Time if err = tx.QueryRow(ctx, `INSERT INTO gateway.portal_conversation_messages(id,conversation_id,sequence,role,content,previous_hash,message_hash) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING created_at`, id, conversationID, sequence, role, content, previous, hash).Scan(&created); err != nil { return ConversationMessage{}, err } _, err = tx.Exec(ctx, `UPDATE gateway.portal_conversations SET next_sequence=next_sequence+1,title=CASE WHEN next_sequence=1 THEN left($2,160) ELSE title END,updated_at=clock_timestamp() WHERE id=$1`, conversationID, content) if err != nil { return ConversationMessage{}, err } if err = tx.Commit(ctx); err != nil { return ConversationMessage{}, err } return ConversationMessage{Sequence: sequence, Role: role, Content: content, CreatedAt: created}, nil } func (s *Service) callApplication(ctx context.Context, appCode, secret string, messages []ConversationMessage, variables map[string]any, conversationID string) (map[string]any, string, error) { payloadMessages := make([]map[string]any, 0, len(messages)) for _, m := range messages { payloadMessages = append(payloadMessages, map[string]any{"role": m.Role, "content": m.Content}) } payload, _ := json.Marshal(map[string]any{"messages": payloadMessages, "variables": variables}) request := httptest.NewRequest(http.MethodPost, "/v1/applications/"+appCode+"/chat/completions", bytes.NewReader(payload)).WithContext(gateway.WithRequestID(ctx, "portal-"+time.Now().UTC().Format("20060102150405.000000000"))) request.Header.Set("Authorization", "Bearer "+secret) request.Header.Set("Content-Type", "application/json") if strings.TrimSpace(conversationID) != "" { request.Header.Set("X-Gateway-Conversation-ID", conversationID) } recorder := httptest.NewRecorder() s.runtime.ServeHTTP(recorder, request) var response map[string]any if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { return nil, "", errors.New("应用响应无法解析") } if recorder.Code < 200 || recorder.Code >= 300 { return response, "", fmt.Errorf("应用调用失败(HTTP %d)", recorder.Code) } choices, _ := response["choices"].([]any) if len(choices) == 0 { return response, "", errors.New("应用未返回回答") } choice, _ := choices[0].(map[string]any) message, _ := choice["message"].(map[string]any) answer, _ := message["content"].(string) if strings.TrimSpace(answer) == "" { return response, "", errors.New("应用未返回文本回答") } return response, answer, nil } func (s *Service) Chat(ctx context.Context, account identity.Account, code, message string, variables map[string]any) (map[string]any, error) { message = strings.TrimSpace(message) if message == "" || len(message) > 100000 { return nil, errors.New("消息为空或过长") } app, err := s.assets.GetPublishedApplicationByCode(ctx, strings.ToLower(strings.TrimSpace(code))) if err != nil || !visible(app.DepartmentIDs, account.DepartmentID) { return nil, ErrNotFound } secret, _, err := s.credentials.Ensure(ctx, app.ID, account.DepartmentID) if err != nil { return nil, err } response, _, err := s.callApplication(ctx, app.Code, secret, []ConversationMessage{{Role: "user", Content: message}}, variables, "") return response, err } func (s *Service) AppendConversationMessage(ctx context.Context, account identity.Account, code, id, message string, variables map[string]any) (map[string]any, error) { message = strings.TrimSpace(message) if message == "" || len(message) > 100000 { return nil, errors.New("消息为空或过长") } lease, _ := platformid.NewUUID() var appID string tag, err := s.pool.Exec(ctx, `UPDATE gateway.portal_conversations c SET busy=true,busy_token=$4,busy_since=clock_timestamp() FROM gateway.applications a WHERE c.application_id=a.id AND c.id=$1 AND c.portal_user_id=$2 AND a.code=$3 AND c.status='active' AND (NOT c.busy OR c.busy_since