feat(captain): align custom tool limits
This commit is contained in:
@@ -5,7 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -163,6 +165,87 @@ func (s *CaptainCustomToolCRUDTestSuite) TestCreate_默认GET方法() {
|
||||
assert.Equal(s.T(), "GET", resp["http_method"]) // default HTTP method
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolCRUDTestSuite) TestCreate_省略Slug时使用ChatwootCustom前缀和下划线() {
|
||||
body := map[string]interface{}{
|
||||
"custom_tool": map[string]interface{}{
|
||||
"title": "Lookup Order",
|
||||
"endpoint_url": "https://example.com/orders",
|
||||
},
|
||||
}
|
||||
|
||||
w := s.makeRequest("POST", s.accountPath()+"/captain/custom_tools/", body)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), "custom_lookup_order", resp["slug"])
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolCRUDTestSuite) TestCreate_重复标题Slug追加随机后缀() {
|
||||
body := map[string]interface{}{
|
||||
"custom_tool": map[string]interface{}{
|
||||
"title": "Lookup Order",
|
||||
"endpoint_url": "https://example.com/orders",
|
||||
},
|
||||
}
|
||||
|
||||
w1 := s.makeRequest("POST", s.accountPath()+"/captain/custom_tools/", body)
|
||||
s.Require().Equal(http.StatusOK, w1.Code)
|
||||
w2 := s.makeRequest("POST", s.accountPath()+"/captain/custom_tools/", body)
|
||||
s.Require().Equal(http.StatusOK, w2.Code)
|
||||
|
||||
var first, second map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w1.Body.Bytes(), &first))
|
||||
s.Require().NoError(json.Unmarshal(w2.Body.Bytes(), &second))
|
||||
assert.Equal(s.T(), "custom_lookup_order", first["slug"])
|
||||
assert.NotEqual(s.T(), first["slug"], second["slug"])
|
||||
assert.Regexp(s.T(), regexp.MustCompile(`^custom_lookup_order_[a-z0-9]{6}$`), second["slug"])
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolCRUDTestSuite) TestCreate_生成Slug最长64字符() {
|
||||
body := map[string]interface{}{
|
||||
"custom_tool": map[string]interface{}{
|
||||
"title": strings.Repeat("Very Long Title ", 10),
|
||||
"endpoint_url": "https://example.com/long",
|
||||
},
|
||||
}
|
||||
|
||||
w := s.makeRequest("POST", s.accountPath()+"/captain/custom_tools/", body)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
slug := resp["slug"].(string)
|
||||
assert.LessOrEqual(s.T(), len(slug), 64)
|
||||
assert.True(s.T(), strings.HasPrefix(slug, "custom_"))
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolCRUDTestSuite) TestCreate_超过每账户15个工具返回422() {
|
||||
for i := 0; i < 15; i++ {
|
||||
body := map[string]interface{}{
|
||||
"custom_tool": map[string]interface{}{
|
||||
"title": "Limit Tool " + strconv.Itoa(i),
|
||||
"slug": "limit-tool-" + strconv.Itoa(i),
|
||||
"endpoint_url": "https://example.com/limit" + strconv.Itoa(i),
|
||||
},
|
||||
}
|
||||
w := s.makeRequest("POST", s.accountPath()+"/captain/custom_tools/", body)
|
||||
s.Require().Equal(http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
w := s.makeRequest("POST", s.accountPath()+"/captain/custom_tools/", map[string]interface{}{
|
||||
"custom_tool": map[string]interface{}{
|
||||
"title": "One Too Many",
|
||||
"endpoint_url": "https://example.com/too-many",
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(s.T(), "You can create a maximum of 15 custom tools per account", resp["error"])
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolCRUDTestSuite) TestCreate_无效JSON返回400() {
|
||||
// ShouldBindJSON 在 JSON 解析失败时返回 400
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -42,6 +43,10 @@ func (h *CaptainCustomToolHandler) Create(c *gin.Context) {
|
||||
tool, err := h.svc.Create(c.Request.Context(), accountID, &req)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Create captain custom tool: %v", err)
|
||||
if errors.Is(err, service.ErrCaptainCustomToolLimitExceeded) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": service.ErrCaptainCustomToolLimitExceeded.Error()})
|
||||
return
|
||||
}
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create custom tool")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ func TestCaptainCustomToolHandler_ChatwootToolPayloadsAndScope(t *testing.T) {
|
||||
var created map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created))
|
||||
assert.NotContains(t, created, "success")
|
||||
assert.Equal(t, "lookup-order", created["slug"])
|
||||
assert.Equal(t, "custom_lookup_order", created["slug"])
|
||||
assert.Equal(t, "POST", created["http_method"])
|
||||
toolID := uint(created["id"].(float64))
|
||||
|
||||
|
||||
@@ -251,9 +251,9 @@ func (CaptainScenario) TableName() string { return "captain_scenarios" }
|
||||
|
||||
type CaptainCustomTool struct {
|
||||
Base
|
||||
AccountID uint `gorm:"index;not null" json:"account_id"`
|
||||
AccountID uint `gorm:"index;uniqueIndex:idx_captain_custom_tools_account_slug,priority:1;not null" json:"account_id"`
|
||||
Title string `gorm:"size:255;not null" json:"title"`
|
||||
Slug string `gorm:"size:64;not null;uniqueIndex:idx_account_slug" json:"slug"`
|
||||
Slug string `gorm:"size:64;not null;uniqueIndex:idx_captain_custom_tools_account_slug,priority:2" json:"slug"`
|
||||
Description string `gorm:"type:text" json:"description,omitempty"`
|
||||
EndpointURL string `gorm:"type:text;not null" json:"endpoint_url"`
|
||||
HTTPMethod string `gorm:"size:10;default:GET;not null" json:"http_method"`
|
||||
|
||||
@@ -3,9 +3,12 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -25,6 +28,14 @@ type CaptainCustomToolService struct {
|
||||
httpClient HTTPDoer
|
||||
}
|
||||
|
||||
const (
|
||||
maxCaptainCustomToolsPerAccount = 15
|
||||
maxCaptainCustomToolSlugLength = 64
|
||||
customToolSlugCollisionSuffix = 7
|
||||
)
|
||||
|
||||
var ErrCaptainCustomToolLimitExceeded = errors.New("You can create a maximum of 15 custom tools per account")
|
||||
|
||||
type HTTPDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
@@ -89,6 +100,14 @@ type UpdateCustomToolRequest struct {
|
||||
|
||||
// Create creates a new CaptainCustomTool.
|
||||
func (s *CaptainCustomToolService) Create(ctx context.Context, accountID uint, req *CreateCustomToolRequest) (*model.CaptainCustomTool, error) {
|
||||
count, err := s.toolRepo.CountByAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count custom tools: %w", err)
|
||||
}
|
||||
if count >= maxCaptainCustomToolsPerAccount {
|
||||
return nil, ErrCaptainCustomToolLimitExceeded
|
||||
}
|
||||
|
||||
// Default values
|
||||
httpMethod := req.HTTPMethod
|
||||
if httpMethod == "" {
|
||||
@@ -100,7 +119,10 @@ func (s *CaptainCustomToolService) Create(ctx context.Context, accountID uint, r
|
||||
}
|
||||
slug := req.Slug
|
||||
if slug == "" {
|
||||
slug = customToolSlug(req.Title)
|
||||
slug, err = s.uniqueCustomToolSlug(ctx, accountID, req.Title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tool := &model.CaptainCustomTool{
|
||||
@@ -265,14 +287,54 @@ func applyCustomToolUpdate(tool *model.CaptainCustomTool, req *UpdateCustomToolR
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolService) uniqueCustomToolSlug(ctx context.Context, accountID uint, title string) (string, error) {
|
||||
baseSlug := customToolSlug(title)
|
||||
if !s.customToolSlugExists(ctx, accountID, baseSlug) {
|
||||
return baseSlug, nil
|
||||
}
|
||||
|
||||
truncated := truncateString(baseSlug, maxCaptainCustomToolSlugLength-customToolSlugCollisionSuffix)
|
||||
for i := 0; i < 5; i++ {
|
||||
candidate := truncated + "_" + randomLowerAlphanumeric(6)
|
||||
if !s.customToolSlugExists(ctx, accountID, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("slug generation failed")
|
||||
}
|
||||
|
||||
func (s *CaptainCustomToolService) customToolSlugExists(ctx context.Context, accountID uint, slug string) bool {
|
||||
_, err := s.toolRepo.GetBySlug(ctx, accountID, slug)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func customToolSlug(title string) string {
|
||||
slug := strings.ToLower(strings.TrimSpace(title))
|
||||
slug = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
if slug == "" {
|
||||
return "custom-tool"
|
||||
slug = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(slug, "_")
|
||||
slug = strings.Trim(slug, "_")
|
||||
return truncateString("custom_"+slug, maxCaptainCustomToolSlugLength)
|
||||
}
|
||||
|
||||
func truncateString(value string, maxLen int) string {
|
||||
if len(value) <= maxLen {
|
||||
return value
|
||||
}
|
||||
return slug
|
||||
return value[:maxLen]
|
||||
}
|
||||
|
||||
func randomLowerAlphanumeric(length int) string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
var b strings.Builder
|
||||
b.Grow(length)
|
||||
for i := 0; i < length; i++ {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
if err != nil {
|
||||
b.WriteByte(alphabet[time.Now().UnixNano()%int64(len(alphabet))])
|
||||
continue
|
||||
}
|
||||
b.WriteByte(alphabet[n.Int64()])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- Tool Execution ---
|
||||
|
||||
Reference in New Issue
Block a user