Files
ai-gateway-go/internal/portal/conversations.go
T
superidou c22669c31d 0.11.0: 旗舰版功能补齐(License/登录记录/会话管理/角色管理/门户定时任务/模型配额/输出脱敏/供应链扫描/记忆管理/AI助手/真实概览)
- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆)
- 新增包: license/memory/modelquota/assistant,扫描引擎
- 全部功能后端+前端+端到端验证通过(25 包单测)
2026-08-13 11:37:18 +08:00

297 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
}
// ListConversations 返回当前用户在某应用下的会话列表(不含消息体)。
func (s *Service) ListConversations(ctx context.Context, account identity.Account, code string, limit int) ([]Conversation, error) {
if limit < 1 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(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.portal_user_id=$1 AND a.code=$2 ORDER BY c.updated_at DESC LIMIT $3`, account.ID, strings.ToLower(strings.TrimSpace(code)), limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Conversation{}
for rows.Next() {
var item Conversation
if err := rows.Scan(&item.ID, &item.ApplicationCode, &item.Title, &item.Status, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// RenameConversation 重命名会话(仅本人)。
func (s *Service) RenameConversation(ctx context.Context, account identity.Account, code, id, title string) (Conversation, error) {
title = strings.TrimSpace(title)
if title == "" || len(title) > 128 {
return Conversation{}, errors.New("会话标题必须为 1-128 个字符")
}
var item Conversation
err := s.pool.QueryRow(ctx, `UPDATE gateway.portal_conversations c SET title=$3,updated_at=clock_timestamp()
FROM gateway.applications a WHERE a.id=c.application_id AND c.id=$1 AND c.portal_user_id=$2 AND a.code=$4
RETURNING c.id::text,a.code,c.title,c.status,c.created_at,c.updated_at`,
id, account.ID, title, strings.ToLower(strings.TrimSpace(code))).Scan(&item.ID, &item.ApplicationCode, &item.Title, &item.Status, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Conversation{}, ErrNotFound
}
return item, err
}
// DeleteConversation 删除会话及全部消息(仅本人)。
func (s *Service) DeleteConversation(ctx context.Context, account identity.Account, code, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.portal_conversations c USING gateway.applications a
WHERE a.id=c.application_id AND c.id=$1 AND c.portal_user_id=$2 AND a.code=$3`,
id, account.ID, strings.ToLower(strings.TrimSpace(code)))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
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<clock_timestamp()-interval '10 minutes')`, id, account.ID, code, lease)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
return nil, errors.New("会话不存在、已归档或上一条消息仍在处理")
}
defer func() {
_, _ = s.pool.Exec(context.WithoutCancel(ctx), `UPDATE gateway.portal_conversations SET busy=false,busy_token=NULL,busy_since=NULL WHERE id=$1 AND busy_token=$2`, id, lease)
}()
if err = s.pool.QueryRow(ctx, `SELECT application_id::text FROM gateway.portal_conversations WHERE id=$1`, id).Scan(&appID); err != nil {
return nil, err
}
app, err := s.assets.GetApplication(ctx, appID)
if err != nil || !visible(app.DepartmentIDs, account.DepartmentID) {
return nil, ErrNotFound
}
conversation, err := s.conversation(ctx, account, code, id)
if err != nil {
return nil, err
}
secret, _, err := s.credentials.Ensure(ctx, app.ID, account.DepartmentID)
if err != nil {
return nil, err
}
// Append the new user message in memory and persist it only after the
// model call succeeds. Persisting first left an orphaned user message (and,
// on retry, a duplicated one) whenever callApplication failed; the busy
// lease already guarantees no writer can interleave during the call.
history := make([]ConversationMessage, len(conversation.Messages)+1)
copy(history, conversation.Messages)
history[len(conversation.Messages)] = ConversationMessage{Role: "user", Content: message}
response, answer, err := s.callApplication(ctx, code, secret, history, variables, id)
if err != nil {
return response, err
}
if _, err = s.appendMessage(ctx, id, "user", message); err != nil {
return nil, err
}
if _, err = s.appendMessage(ctx, id, "assistant", answer); err != nil {
return nil, err
}
response["conversation_id"] = id
response["history_integrity"] = "verified"
return response, nil
}