diff --git a/internal/dao/api_for_conversation.go b/internal/dao/api_for_conversation.go new file mode 100644 index 0000000000..90184bd0bc --- /dev/null +++ b/internal/dao/api_for_conversation.go @@ -0,0 +1,148 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package dao + +import ( + "context" + "errors" + "ragflow/internal/entity" + + "gorm.io/gorm" +) + +// API4ConversationDAO API for conversation data access object +type API4ConversationDAO struct{} + +// NewAPI4ConversationDAO create API4Conversation DAO +func NewAPI4ConversationDAO() *API4ConversationDAO { + return &API4ConversationDAO{} +} + +// ConversationStatsRow is one daily aggregate row for api_4_conversation. +type ConversationStatsRow struct { + Dt string `gorm:"column:dt"` + PV int64 `gorm:"column:pv"` + UV int64 `gorm:"column:uv"` + Tokens float64 `gorm:"column:tokens"` + Duration float64 `gorm:"column:duration"` + Round float64 `gorm:"column:round"` + ThumbUp int64 `gorm:"column:thumb_up"` +} + +// Create inserts a new api_4_conversation row. The caller is responsible +// for setting ID, DialogID, UserID and the BaseModel time fields; the +// DAO does not assign defaults because session creation paths in the +// Python agent API generate an uuid + tenant timestamp and rely on the +// round-trip shape being byte-identical. +func (dao *API4ConversationDAO) Create(ctx context.Context, db *gorm.DB, conv *entity.API4Conversation) error { + if conv == nil { + return errors.New("api4 conversation: nil row") + } + return db.WithContext(ctx).Create(conv).Error +} + +// Update writes back an existing api_4_conversation row. The bot +// completion path calls this with the updated Message JSON after each +// turn so multi-turn chatbot sessions carry prior history into the next +// LLM call. Matches the Python conversation_service.update pattern at +// api/db/services/conversation_service.py:236 (async_iframe_completion). +func (dao *API4ConversationDAO) Update(ctx context.Context, db *gorm.DB, conv *entity.API4Conversation) error { + if conv == nil { + return errors.New("api4 conversation: nil row") + } + if conv.ID == "" { + return errors.New("api4 conversation: empty id") + } + return db.WithContext(ctx).Save(conv).Error +} + +// Stats returns daily conversation aggregates for a tenant. +func (dao *API4ConversationDAO) Stats(ctx context.Context, db *gorm.DB, tenantID, fromDate, toDate string, source *string) ([]ConversationStatsRow, error) { + var rows []ConversationStatsRow + dateExpr := "DATE_FORMAT(a.create_date, '%Y-%m-%d 00:00:00')" + query := db.WithContext(ctx).Table("api_4_conversation AS a"). + Select(` + DATE_FORMAT(a.create_date, '%Y-%m-%d 00:00:00') AS dt, + COUNT(a.id) AS pv, + COUNT(DISTINCT a.user_id) AS uv, + COALESCE(SUM(a.tokens), 0) AS tokens, + COALESCE(SUM(a.duration), 0) AS duration, + COALESCE(AVG(a.round), 0) AS round, + COALESCE(SUM(a.thumb_up), 0) AS thumb_up + `). + Joins("JOIN dialog AS d ON a.dialog_id = d.id AND d.tenant_id = ?", tenantID). + Where("a.create_date >= ? AND a.create_date <= ?", fromDate, toDate) + + if source == nil { + query = query.Where("a.source IS NULL") + } else { + query = query.Where("a.source = ?", *source) + } + + err := query.Group(dateExpr). + Order(dateExpr). + Scan(&rows).Error + return rows, err +} + +func (dao *API4ConversationDAO) GetBySessionID(ctx context.Context, db *gorm.DB, sessionID, agentID string) (*entity.API4Conversation, error) { + var result entity.API4Conversation + tx := db.WithContext(ctx).Where("id = ? AND dialog_id = ?", sessionID, agentID).Find(&result) + if tx.Error != nil { + return nil, tx.Error + } + if tx.RowsAffected == 0 { + return nil, nil + } + return &result, nil +} + +// GetByID returns a conversation without requiring the caller to know its +// agent. It is used when the session itself is the authorization resource. +func (dao *API4ConversationDAO) GetByID(ctx context.Context, db *gorm.DB, id string) (*entity.API4Conversation, error) { + var result entity.API4Conversation + tx := db.WithContext(ctx).Where("id = ?", id).Find(&result) + if tx.Error != nil { + return nil, tx.Error + } + if tx.RowsAffected == 0 { + return nil, nil + } + return &result, nil +} + +// ListIDsByAgentID lists conversation IDs for one agent. +func (dao *API4ConversationDAO) ListIDsByAgentID(ctx context.Context, db *gorm.DB, agentID string) ([]string, error) { + var ids []string + err := db.WithContext(ctx).Model(&entity.API4Conversation{}).Where("dialog_id = ?", agentID).Pluck("id", &ids).Error + return ids, err +} + +// DeleteBySessionIDAndAgentID deletes API4Conversations by sessionID and agentID +func (dao *API4ConversationDAO) DeleteBySessionIDAndAgentID(ctx context.Context, db *gorm.DB, sessionID, agentID string) (int64, error) { + result := db.WithContext(ctx).Where("id = ? AND dialog_id = ?", sessionID, agentID).Delete(&entity.API4Conversation{}) + return result.RowsAffected, result.Error +} + +// DeleteByDialogIDs deletes API4Conversations by dialog IDs (hard delete) +func (dao *API4ConversationDAO) DeleteByDialogIDs(ctx context.Context, db *gorm.DB, dialogIDs []string) (int64, error) { + if len(dialogIDs) == 0 { + return 0, nil + } + result := db.WithContext(ctx).Where("dialog_id IN ?", dialogIDs).Delete(&entity.API4Conversation{}) + return result.RowsAffected, result.Error +} diff --git a/internal/dao/api_token.go b/internal/dao/api_token.go index eab665a643..bed736b61b 100644 --- a/internal/dao/api_token.go +++ b/internal/dao/api_token.go @@ -18,8 +18,6 @@ package dao import ( "context" - "errors" - "ragflow/internal/entity" "gorm.io/gorm" @@ -47,16 +45,19 @@ func (dao *APITokenDAO) GetByTenantID(ctx context.Context, db *gorm.DB, tenantID // DeleteByTenantID deletes all API tokens by tenant ID (hard delete) func (dao *APITokenDAO) DeleteByTenantID(ctx context.Context, db *gorm.DB, tenantID string) (int64, error) { - result := db.WithContext(ctx).Unscoped().Where("tenant_id = ?", tenantID).Delete(&entity.APIToken{}) + result := db.WithContext(ctx).Where("tenant_id = ?", tenantID).Delete(&entity.APIToken{}) return result.RowsAffected, result.Error } -// GetUserByAPIToken gets user by API token -func (dao *APITokenDAO) GetUserByAPIToken(ctx context.Context, db *gorm.DB, token string) (*entity.APIToken, error) { +// GetByAPIToken gets user by API token +func (dao *APITokenDAO) GetByAPIToken(ctx context.Context, db *gorm.DB, token string) (*entity.APIToken, error) { var apiToken entity.APIToken - err := db.WithContext(ctx).Where("token = ?", token).First(&apiToken).Error - if err != nil { - return nil, err + tx := db.WithContext(ctx).Where("token = ?", token).Find(&apiToken) + if tx.Error != nil { + return nil, tx.Error + } + if tx.RowsAffected == 0 { + return nil, nil } return &apiToken, nil } @@ -74,135 +75,12 @@ func (dao *APITokenDAO) DeleteByDialogIDs(ctx context.Context, db *gorm.DB, dial if len(dialogIDs) == 0 { return 0, nil } - result := db.WithContext(ctx).Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&entity.APIToken{}) + result := db.WithContext(ctx).Where("dialog_id IN ?", dialogIDs).Delete(&entity.APIToken{}) return result.RowsAffected, result.Error } // DeleteByTenantIDAndToken deletes a specific API token by tenant ID and token value func (dao *APITokenDAO) DeleteByTenantIDAndToken(ctx context.Context, db *gorm.DB, tenantID, token string) (int64, error) { - result := db.WithContext(ctx).Unscoped().Where("tenant_id = ? AND token = ?", tenantID, token).Delete(&entity.APIToken{}) - return result.RowsAffected, result.Error -} - -// API4ConversationDAO API for conversation data access object -type API4ConversationDAO struct{} - -// NewAPI4ConversationDAO create API4Conversation DAO -func NewAPI4ConversationDAO() *API4ConversationDAO { - return &API4ConversationDAO{} -} - -// ConversationStatsRow is one daily aggregate row for api_4_conversation. -type ConversationStatsRow struct { - Dt string `gorm:"column:dt"` - PV int64 `gorm:"column:pv"` - UV int64 `gorm:"column:uv"` - Tokens float64 `gorm:"column:tokens"` - Duration float64 `gorm:"column:duration"` - Round float64 `gorm:"column:round"` - ThumbUp int64 `gorm:"column:thumb_up"` -} - -// Create inserts a new api_4_conversation row. The caller is responsible -// for setting ID, DialogID, UserID and the BaseModel time fields; the -// DAO does not assign defaults because session creation paths in the -// Python agent API generate an uuid + tenant timestamp and rely on the -// round-trip shape being byte-identical. -func (dao *API4ConversationDAO) Create(ctx context.Context, db *gorm.DB, conv *entity.API4Conversation) error { - if conv == nil { - return errors.New("api4 conversation: nil row") - } - return db.WithContext(ctx).Create(conv).Error -} - -// Update writes back an existing api_4_conversation row. The bot -// completion path calls this with the updated Message JSON after each -// turn so multi-turn chatbot sessions carry prior history into the next -// LLM call. Matches the Python conversation_service.update pattern at -// api/db/services/conversation_service.py:236 (async_iframe_completion). -func (dao *API4ConversationDAO) Update(ctx context.Context, db *gorm.DB, conv *entity.API4Conversation) error { - if conv == nil { - return errors.New("api4 conversation: nil row") - } - if conv.ID == "" { - return errors.New("api4 conversation: empty id") - } - return db.WithContext(ctx).Save(conv).Error -} - -// Stats returns daily conversation aggregates for a tenant. -func (dao *API4ConversationDAO) Stats(ctx context.Context, db *gorm.DB, tenantID, fromDate, toDate string, source *string) ([]ConversationStatsRow, error) { - var rows []ConversationStatsRow - dateExpr := "DATE_FORMAT(a.create_date, '%Y-%m-%d 00:00:00')" - query := db.WithContext(ctx).Table("api_4_conversation AS a"). - Select(` - DATE_FORMAT(a.create_date, '%Y-%m-%d 00:00:00') AS dt, - COUNT(a.id) AS pv, - COUNT(DISTINCT a.user_id) AS uv, - COALESCE(SUM(a.tokens), 0) AS tokens, - COALESCE(SUM(a.duration), 0) AS duration, - COALESCE(AVG(a.round), 0) AS round, - COALESCE(SUM(a.thumb_up), 0) AS thumb_up - `). - Joins("JOIN dialog AS d ON a.dialog_id = d.id AND d.tenant_id = ?", tenantID). - Where("a.create_date >= ? AND a.create_date <= ?", fromDate, toDate) - - if source == nil { - query = query.Where("a.source IS NULL") - } else { - query = query.Where("a.source = ?", *source) - } - - err := query.Group(dateExpr). - Order(dateExpr). - Scan(&rows).Error - return rows, err -} - -func (dao *API4ConversationDAO) GetBySessionID(ctx context.Context, db *gorm.DB, sessionID, agentID string) (*entity.API4Conversation, error) { - var result entity.API4Conversation - tx := db.WithContext(ctx).Where("id = ? AND dialog_id = ?", sessionID, agentID).Find(&result) - if tx.Error != nil { - return nil, tx.Error - } - if tx.RowsAffected == 0 { - return nil, nil - } - return &result, nil -} - -// GetByID returns a conversation without requiring the caller to know its -// agent. It is used when the session itself is the authorization resource. -func (dao *API4ConversationDAO) GetByID(ctx context.Context, db *gorm.DB, id string) (*entity.API4Conversation, error) { - var result entity.API4Conversation - tx := db.WithContext(ctx).Where("id = ?", id).Find(&result) - if tx.Error != nil { - return nil, tx.Error - } - if tx.RowsAffected == 0 { - return nil, nil - } - return &result, nil -} - -// ListIDsByAgentID lists conversation IDs for one agent. -func (dao *API4ConversationDAO) ListIDsByAgentID(ctx context.Context, db *gorm.DB, agentID string) ([]string, error) { - var ids []string - err := db.WithContext(ctx).Model(&entity.API4Conversation{}).Where("dialog_id = ?", agentID).Pluck("id", &ids).Error - return ids, err -} - -// DeleteBySessionIDAndAgentID deletes API4Conversations by sessionID and agentID -func (dao *API4ConversationDAO) DeleteBySessionIDAndAgentID(ctx context.Context, db *gorm.DB, sessionID, agentID string) (int64, error) { - result := db.WithContext(ctx).Where("id = ? AND dialog_id = ?", sessionID, agentID).Delete(&entity.API4Conversation{}) - return result.RowsAffected, result.Error -} - -// DeleteByDialogIDs deletes API4Conversations by dialog IDs (hard delete) -func (dao *API4ConversationDAO) DeleteByDialogIDs(ctx context.Context, db *gorm.DB, dialogIDs []string) (int64, error) { - if len(dialogIDs) == 0 { - return 0, nil - } - result := db.WithContext(ctx).Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&entity.API4Conversation{}) + result := db.WithContext(ctx).Where("tenant_id = ? AND token = ?", tenantID, token).Delete(&entity.APIToken{}) return result.RowsAffected, result.Error } diff --git a/internal/entity/api_for_conversation.go b/internal/entity/api_for_conversation.go new file mode 100644 index 0000000000..4ebefdac70 --- /dev/null +++ b/internal/entity/api_for_conversation.go @@ -0,0 +1,44 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package entity + +import "encoding/json" + +// API4Conversation API for conversation model +type API4Conversation struct { + ID string `gorm:"column:id;primaryKey;size:32" json:"id"` + Name *string `gorm:"column:name;size:255" json:"name,omitempty"` + DialogID string `gorm:"column:dialog_id;size:32;not null;index" json:"dialog_id"` + UserID string `gorm:"column:user_id;size:255;not null;index" json:"user_id"` + ExpUserID *string `gorm:"column:exp_user_id;size:255;index" json:"exp_user_id,omitempty"` + Message json.RawMessage `gorm:"column:message;type:longtext" json:"message,omitempty"` + Reference json.RawMessage `gorm:"column:reference;type:longtext" json:"reference,omitempty"` + Tokens int `gorm:"column:tokens" json:"tokens"` + Source *string `gorm:"column:source;size:16" json:"source,omitempty"` + DSL JSONMap `gorm:"column:dsl;type:longtext" json:"dsl,omitempty"` + Duration float64 `gorm:"column:duration" json:"duration"` + Round int `gorm:"column:round" json:"round"` + ThumbUp int `gorm:"column:thumb_up" json:"thumb_up"` + Errors *string `gorm:"column:errors;type:text" json:"errors,omitempty"` + VersionTitle *string `gorm:"column:version_title;size:255" json:"version_title,omitempty"` + BaseModel +} + +// TableName specify table name +func (API4Conversation) TableName() string { + return "api_4_conversation" +} diff --git a/internal/entity/api_token.go b/internal/entity/api_token.go index edac31a4cd..37464ee9b5 100644 --- a/internal/entity/api_token.go +++ b/internal/entity/api_token.go @@ -16,8 +16,6 @@ package entity -import "encoding/json" - // APIToken API token model type APIToken struct { TenantID string `gorm:"column:tenant_id;size:32;not null;primaryKey" json:"tenant_id"` @@ -32,28 +30,3 @@ type APIToken struct { func (APIToken) TableName() string { return "api_token" } - -// API4Conversation API for conversation model -type API4Conversation struct { - ID string `gorm:"column:id;primaryKey;size:32" json:"id"` - Name *string `gorm:"column:name;size:255" json:"name,omitempty"` - DialogID string `gorm:"column:dialog_id;size:32;not null;index" json:"dialog_id"` - UserID string `gorm:"column:user_id;size:255;not null;index" json:"user_id"` - ExpUserID *string `gorm:"column:exp_user_id;size:255;index" json:"exp_user_id,omitempty"` - Message json.RawMessage `gorm:"column:message;type:longtext" json:"message,omitempty"` - Reference json.RawMessage `gorm:"column:reference;type:longtext" json:"reference,omitempty"` - Tokens int `gorm:"column:tokens" json:"tokens"` - Source *string `gorm:"column:source;size:16" json:"source,omitempty"` - DSL JSONMap `gorm:"column:dsl;type:longtext" json:"dsl,omitempty"` - Duration float64 `gorm:"column:duration" json:"duration"` - Round int `gorm:"column:round" json:"round"` - ThumbUp int `gorm:"column:thumb_up" json:"thumb_up"` - Errors *string `gorm:"column:errors;type:text" json:"errors,omitempty"` - VersionTitle *string `gorm:"column:version_title;size:255" json:"version_title,omitempty"` - BaseModel -} - -// TableName specify table name -func (API4Conversation) TableName() string { - return "api_4_conversation" -} diff --git a/internal/service/user.go b/internal/service/user.go index 2a8fd8b4ec..b23139547c 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -958,8 +958,8 @@ func (s *UserService) GetUserByAPIToken(ctx context.Context, authorization strin // Query API token from database apiTokenDAO := dao.NewAPITokenDAO() - userToken, err := apiTokenDAO.GetUserByAPIToken(ctx, dao.DB, token) - if err != nil { + userToken, err := apiTokenDAO.GetByAPIToken(ctx, dao.DB, token) + if err != nil || userToken == nil { return nil, common.CodeUnauthorized, fmt.Errorf("invalid access token") }