c22669c31d
- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆) - 新增包: license/memory/modelquota/assistant,扫描引擎 - 全部功能后端+前端+端到端验证通过(25 包单测)
297 lines
13 KiB
Go
297 lines
13 KiB
Go
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
|
||
}
|