feat(macros): align attachment uploads

This commit is contained in:
2026-06-07 10:22:36 +08:00
parent 023ab10b31
commit 168f12070e
11 changed files with 504 additions and 53 deletions
+35 -9
View File
@@ -105,6 +105,10 @@ func (h *MacroHandler) Create(c *gin.Context) {
}
if svcErr := h.svc.Create(c.Request.Context(), macro); svcErr != nil {
if strings.Contains(svcErr.Error(), "invalid attachment") {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
return
}
handleServiceError(c, svcErr)
return
}
@@ -154,6 +158,10 @@ func (h *MacroHandler) Update(c *gin.Context) {
updated, svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, macro)
if svcErr != nil {
if strings.Contains(svcErr.Error(), "invalid attachment") {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
return
}
handleServiceError(c, svcErr)
return
}
@@ -312,9 +320,9 @@ type macroActionRequest struct {
}
type macroRequest struct {
Name string `json:"name"`
Visibility interface{} `json:"visibility"`
Actions []macroActionRequest `json:"actions"`
Name string `json:"name"`
Visibility interface{} `json:"visibility"`
Actions *[]macroActionRequest `json:"actions"`
}
func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
@@ -322,13 +330,16 @@ func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
if err := c.ShouldBindJSON(&req); err != nil {
return nil, err
}
actions := make(automation.Actions, 0, len(req.Actions))
for _, actionReq := range req.Actions {
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
if err != nil {
return nil, err
var actions automation.Actions
if req.Actions != nil {
actions = make(automation.Actions, 0, len(*req.Actions))
for _, actionReq := range *req.Actions {
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
if err != nil {
return nil, err
}
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
}
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
}
return &automation.Macro{
Name: strings.TrimSpace(req.Name),
@@ -392,6 +403,21 @@ func serializeMacro(macro *automation.Macro) gin.H {
if macro.UpdatedBy != nil && macro.UpdatedBy.ID != 0 {
item["updated_by"] = serializeMacroAgent(macro.UpdatedBy)
}
if len(macro.Files) > 0 {
files := make([]gin.H, 0, len(macro.Files))
for _, file := range macro.Files {
files = append(files, gin.H{
"id": file.ID,
"macro_id": file.MacroID,
"file_type": file.FileType,
"account_id": file.AccountID,
"file_url": file.FileURL,
"blob_id": file.BlobID,
"filename": file.Filename,
})
}
item["files"] = files
}
return item
}
@@ -41,6 +41,7 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(
&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{},
&model.Attachment{}, &model.DirectUpload{},
&automation.Macro{}, &automation.MacroExecution{}, &automation.ConversationLabel{}, &automation.ConversationMute{},
))
s.db = db
@@ -54,6 +55,62 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
s.db.Create(s.user)
}
func (s *MacroHandlerTestSuite) TestCreate_AttachmentActionAcceptsSignedUploadAndSerializesFiles() {
upload := &model.DirectUpload{
UploadUUID: "macro-upload-signed-id",
AccountID: s.account.ID,
Status: model.DirectUploadStatusPending,
Source: model.DirectUploadSourceAccount,
OriginalName: "avatar.png",
FileType: "image",
MimeType: "image/png",
FileSize: 123,
FileURL: "/uploads/account/1/avatar.png",
ThumbURL: "/uploads/account/1/avatar.png",
}
s.Require().NoError(s.db.Create(upload).Error)
r := gin.New()
r.POST("/api/v1/accounts/:account_id/macros", func(c *gin.Context) {
c.Set("user_id", float64(s.user.ID))
c.Next()
}, s.handler.Create)
body := []byte(`{"name":"send file","visibility":"global","actions":[{"action_name":"send_attachment","action_params":["macro-upload-signed-id"]}]}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].(map[string]interface{})
actions := payload["actions"].([]interface{})
action := actions[0].(map[string]interface{})
assert.Equal(s.T(), []interface{}{float64(upload.ID)}, action["action_params"])
files := payload["files"].([]interface{})
s.Require().Len(files, 1)
file := files[0].(map[string]interface{})
assert.Equal(s.T(), float64(upload.ID), file["blob_id"])
assert.Equal(s.T(), "avatar.png", file["filename"])
}
func (s *MacroHandlerTestSuite) TestCreate_AttachmentActionRejectsInvalidBlob() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/macros", func(c *gin.Context) {
c.Set("user_id", float64(s.user.ID))
c.Next()
}, s.handler.Create)
body := []byte(`{"name":"bad file","visibility":"global","actions":[{"action_name":"send_attachment","action_params":["missing-upload"]}]}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
}
func (s *MacroHandlerTestSuite) TearDownSuite() {
if s.db != nil {
sqlDB, _ := s.db.DB()
+35 -10
View File
@@ -1,10 +1,12 @@
package v1
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
@@ -29,21 +31,44 @@ func (h *UploadHandler) Upload(c *gin.Context) {
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "file is required")
return
var result *service.UploadResponse
var svcErr error
if strings.Contains(c.GetHeader("Content-Type"), "application/json") {
var req struct {
ExternalURL string `json:"external_url"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.ExternalURL) == "" {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "missing input"})
return
}
result, svcErr = h.svc.AccountUploadFromURL(c.Request.Context(), accountID, strings.TrimSpace(req.ExternalURL))
} else {
fileHeader, err := c.FormFile("attachment")
if err != nil {
fileHeader, err = c.FormFile("file")
}
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "missing input"})
return
}
result, svcErr = h.svc.AccountUpload(c.Request.Context(), accountID, service.AccountUploadRequest{
FileHeader: fileHeader,
})
}
result, svcErr := h.svc.AccountUpload(c.Request.Context(), accountID, service.AccountUploadRequest{
FileHeader: fileHeader,
})
if svcErr != nil {
handleServiceError(c, svcErr)
status := http.StatusUnprocessableEntity
if errors.Is(svcErr, gorm.ErrRecordNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": svcErr.Error()})
return
}
response.OK(c, result)
c.JSON(http.StatusOK, gin.H{
"file_url": result.FileURL,
"blob_id": result.UploadUUID,
"blob_key": result.UploadUUID,
})
}
// DirectUpload handles POST /api/v1/widget/direct_uploads — widget direct file upload.
+60 -2
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -55,9 +56,13 @@ func setupUploadHandlerRouter(h *UploadHandler) *gin.Engine {
// makeMultipartUploadBody creates a multipart form body with a file field.
func makeMultipartUploadBody(filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
return makeMultipartUploadBodyWithField("file", filename, content)
}
func makeMultipartUploadBodyWithField(fieldName, filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
body = &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filename)
part, err := writer.CreateFormFile(fieldName, filename)
if err != nil {
return nil, "", err
}
@@ -79,7 +84,60 @@ func TestUploadHandler_Upload_NoFile(t *testing.T) {
// Set account_id in context (simulating middleware)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
}
func TestUploadHandler_Upload_ChatwootAttachmentFieldRawPayload(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
body, contentType, err := makeMultipartUploadBodyWithField("attachment", "macro.png", []byte("fake png"))
require.NoError(t, err)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(t, payload, "success")
assert.NotEmpty(t, payload["file_url"])
assert.NotEmpty(t, payload["blob_id"])
assert.Equal(t, payload["blob_id"], payload["blob_key"])
}
func TestUploadHandler_Upload_ChatwootExternalURLRawPayload(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("external image"))
}))
defer server.Close()
body := strings.NewReader(`{"external_url":"` + server.URL + `/image.png"}`)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(t, payload, "success")
assert.NotEmpty(t, payload["file_url"])
assert.NotEmpty(t, payload["blob_id"])
}
func TestUploadHandler_DirectUpload_NoFile(t *testing.T) {