diff --git a/server/cmd/tenant-api/main.go b/server/cmd/tenant-api/main.go
index 94a8929..f9039c6 100644
--- a/server/cmd/tenant-api/main.go
+++ b/server/cmd/tenant-api/main.go
@@ -44,7 +44,7 @@ func main() {
tenantapp.NewImageAssetWorker(imageService, app.RabbitMQ, app.Logger).Start(workerCtx)
mediaSupplyService := app.MediaSupplyService
if mediaSupplyService == nil {
- mediaSupplyService = tenantapp.NewMediaSupplyService(app.DB, app.Redis, app.Logger, app.ConfigStore)
+ mediaSupplyService = tenantapp.NewMediaSupplyService(app.DB, app.Redis, app.Logger, app.ConfigStore).WithObjectStorage(app.ObjectStorage)
}
tenantapp.NewMediaSupplyWorker(mediaSupplyService, app.DB, app.Logger).Start(workerCtx)
knowledgeCleanupService := tenantapp.NewKnowledgeService(
diff --git a/server/internal/bootstrap/bootstrap.go b/server/internal/bootstrap/bootstrap.go
index 1cbfd22..def4825 100644
--- a/server/internal/bootstrap/bootstrap.go
+++ b/server/internal/bootstrap/bootstrap.go
@@ -169,7 +169,7 @@ func New(configPath string) (*App, error) {
WithCache(appCache).
WithIPRegionResolver(ipRegions)
monitoringService := tenantapp.NewMonitoringService(pool, monitoringPool, mqClient, cfg.MonitoringDispatch, cfg.BrandLibrary, logger).WithRedis(rdb)
- mediaSupplyService := tenantapp.NewMediaSupplyService(pool, rdb, logger, configStore)
+ mediaSupplyService := tenantapp.NewMediaSupplyService(pool, rdb, logger, configStore).WithObjectStorage(objectStorageClient)
kolProfiles := repository.NewKolProfileRepository(pool)
kolPackages := repository.NewKolPackageRepository(pool)
kolMarketplace := repository.NewKolMarketplaceRepository(pool)
diff --git a/server/internal/shared/publicasset/token.go b/server/internal/shared/publicasset/token.go
new file mode 100644
index 0000000..86a9fa5
--- /dev/null
+++ b/server/internal/shared/publicasset/token.go
@@ -0,0 +1,45 @@
+package publicasset
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "strings"
+)
+
+func SignObjectKey(objectKey string, secret string) string {
+ encodedKey := base64.RawURLEncoding.EncodeToString([]byte(objectKey))
+ mac := hmac.New(sha256.New, []byte(strings.TrimSpace(secret)))
+ _, _ = mac.Write([]byte(objectKey))
+ signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
+ return fmt.Sprintf("%s.%s", encodedKey, signature)
+}
+
+func ParseObjectKeyToken(token string, secret string) (string, bool) {
+ objectKey, ok := ObjectKeyFromToken(token)
+ if !ok {
+ return "", false
+ }
+ expected := SignObjectKey(objectKey, secret)
+ if !hmac.Equal([]byte(expected), []byte(strings.TrimSpace(token))) {
+ return "", false
+ }
+ return objectKey, true
+}
+
+func ObjectKeyFromToken(token string) (string, bool) {
+ parts := strings.Split(strings.TrimSpace(token), ".")
+ if len(parts) != 2 {
+ return "", false
+ }
+ objectKeyBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
+ if err != nil {
+ return "", false
+ }
+ objectKey := strings.TrimSpace(string(objectKeyBytes))
+ if objectKey == "" {
+ return "", false
+ }
+ return objectKey, true
+}
diff --git a/server/internal/tenant/app/media_supply_images.go b/server/internal/tenant/app/media_supply_images.go
new file mode 100644
index 0000000..fda88bf
--- /dev/null
+++ b/server/internal/tenant/app/media_supply_images.go
@@ -0,0 +1,379 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ "image/png"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+ _ "golang.org/x/image/webp"
+ nethtml "golang.org/x/net/html"
+ "golang.org/x/net/html/atom"
+
+ "github.com/geo-platform/tenant-api/internal/shared/publicasset"
+)
+
+var errMediaSupplyArticleImageUpload = errors.New("media supply article image upload failed")
+
+type mediaSupplyArticleImageUpload struct {
+ URL string
+ Filename string
+ ContentType string
+ Content []byte
+}
+
+func (s *MediaSupplyService) prepareMeijiequanArticleContent(ctx context.Context, tenantID int64, content string) (string, error) {
+ html := meijiequanArticleHTML(content)
+ if strings.TrimSpace(html) == "" {
+ return "", nil
+ }
+ return s.uploadMeijiequanArticleImages(ctx, tenantID, html)
+}
+
+func (s *MediaSupplyService) uploadMeijiequanArticleImages(ctx context.Context, tenantID int64, fragment string) (string, error) {
+ if !strings.Contains(strings.ToLower(fragment), " 0 {
+ cacheKey = "asset:" + strconv.FormatInt(assetID, 10)
+ }
+ meijiequanURL := cache[cacheKey]
+ if meijiequanURL == "" {
+ if s == nil || s.client == nil {
+ return errMediaSupplyArticleImageUpload
+ }
+ resp, err := s.client.UploadEditorImage(ctx, MeijiequanUploadImageRequest{
+ Filename: upload.Filename,
+ ContentType: upload.ContentType,
+ Content: upload.Content,
+ })
+ if err != nil {
+ return fmt.Errorf("%w: %v", errMediaSupplyArticleImageUpload, err)
+ }
+ meijiequanURL = strings.TrimSpace(resp.URL)
+ if meijiequanURL == "" {
+ return errMediaSupplyArticleImageUpload
+ }
+ cache[cacheKey] = meijiequanURL
+ }
+
+ attrs := make([]nethtml.Attribute, 0, len(node.Attr)+2)
+ hasSrc := false
+ hasOriginalSrc := false
+ for _, attr := range node.Attr {
+ key := strings.ToLower(strings.TrimSpace(attr.Key))
+ switch key {
+ case "src":
+ attr.Val = meijiequanURL
+ hasSrc = true
+ case "_src":
+ attr.Val = meijiequanURL
+ hasOriginalSrc = true
+ case "data-src":
+ attr.Val = meijiequanURL
+ case "data-asset-id":
+ continue
+ }
+ attrs = append(attrs, attr)
+ }
+ if !hasSrc {
+ attrs = append(attrs, nethtml.Attribute{Key: "src", Val: meijiequanURL})
+ }
+ if !hasOriginalSrc {
+ attrs = append(attrs, nethtml.Attribute{Key: "_src", Val: meijiequanURL})
+ }
+ node.Attr = attrs
+ return nil
+}
+
+func (s *MediaSupplyService) resolveMeijiequanArticleImageUpload(ctx context.Context, tenantID int64, imageURL string, assetID int64) (mediaSupplyArticleImageUpload, bool, error) {
+ objectKey, internalAssetURL, ok := s.mediaSupplyObjectKeyFromPublicAssetURL(imageURL, tenantID)
+ if internalAssetURL {
+ if !ok {
+ if assetID <= 0 {
+ return mediaSupplyArticleImageUpload{}, false, errMediaSupplyArticleImageUpload
+ }
+ } else {
+ upload, err := s.meijiequanUploadFromObjectKey(ctx, objectKey, imageURL)
+ return upload, err == nil, err
+ }
+ }
+ if assetID > 0 {
+ objectKey, err := s.loadMediaSupplyImageAssetObjectKey(ctx, tenantID, assetID)
+ if err != nil {
+ return mediaSupplyArticleImageUpload{}, false, err
+ }
+ upload, err := s.meijiequanUploadFromObjectKey(ctx, objectKey, imageURL)
+ return upload, err == nil, err
+ }
+ return mediaSupplyArticleImageUpload{}, false, nil
+}
+
+func (s *MediaSupplyService) meijiequanUploadFromObjectKey(ctx context.Context, objectKey, sourceURL string) (mediaSupplyArticleImageUpload, error) {
+ if s == nil || s.objectStorage == nil {
+ return mediaSupplyArticleImageUpload{}, errMediaSupplyArticleImageUpload
+ }
+ content, err := s.objectStorage.GetBytes(ctx, objectKey)
+ if err != nil || len(content) == 0 {
+ if err == nil {
+ err = errMediaSupplyArticleImageUpload
+ }
+ return mediaSupplyArticleImageUpload{}, fmt.Errorf("%w: %v", errMediaSupplyArticleImageUpload, err)
+ }
+ uploadContent, contentType, ext, err := normalizeMeijiequanUploadImage(content, objectKey, sourceURL)
+ if err != nil {
+ return mediaSupplyArticleImageUpload{}, err
+ }
+ filename := mediaSupplyUploadFilename(objectKey, sourceURL, ext)
+ return mediaSupplyArticleImageUpload{
+ URL: sourceURL,
+ Filename: filename,
+ ContentType: contentType,
+ Content: uploadContent,
+ }, nil
+}
+
+func normalizeMeijiequanUploadImage(content []byte, objectKey, sourceURL string) ([]byte, string, string, error) {
+ contentType := strings.ToLower(strings.TrimSpace(http.DetectContentType(content)))
+ ext := strings.ToLower(filepath.Ext(firstNonEmptyText(objectKey, sourceURL)))
+ if mediaSupplyMeijiequanImageExtAllowed(ext) && mediaSupplyMeijiequanImageContentTypeAllowed(contentType) {
+ return content, contentType, ext, nil
+ }
+
+ decoded, _, err := image.Decode(bytes.NewReader(content))
+ if err != nil {
+ return nil, "", "", fmt.Errorf("%w: unsupported image", errMediaSupplyArticleImageUpload)
+ }
+ var output bytes.Buffer
+ if err := png.Encode(&output, decoded); err != nil {
+ return nil, "", "", fmt.Errorf("%w: encode png: %v", errMediaSupplyArticleImageUpload, err)
+ }
+ return output.Bytes(), "image/png", ".png", nil
+}
+
+func mediaSupplyMeijiequanImageExtAllowed(ext string) bool {
+ switch strings.ToLower(strings.TrimSpace(ext)) {
+ case ".png", ".jpg", ".jpeg", ".gif", ".bmp":
+ return true
+ default:
+ return false
+ }
+}
+
+func mediaSupplyMeijiequanImageContentTypeAllowed(contentType string) bool {
+ switch strings.ToLower(strings.TrimSpace(contentType)) {
+ case "image/png", "image/jpeg", "image/gif", "image/bmp", "image/x-ms-bmp":
+ return true
+ default:
+ return false
+ }
+}
+
+func mediaSupplyUploadFilename(objectKey, sourceURL, ext string) string {
+ name := strings.TrimSpace(filepath.Base(strings.TrimRight(firstNonEmptyText(objectKey, sourceURL), "/")))
+ if name == "" || name == "." || strings.Contains(name, "?") || strings.Contains(name, "#") {
+ name = strings.TrimSpace(filepath.Base(strings.TrimRight(objectKey, "/")))
+ }
+ if name == "" || name == "." {
+ name = "image"
+ }
+ currentExt := filepath.Ext(name)
+ if strings.TrimSpace(ext) == "" {
+ ext = ".png"
+ }
+ if currentExt == "" || !strings.EqualFold(currentExt, ext) {
+ name = strings.TrimSuffix(name, currentExt) + ext
+ }
+ return name
+}
+
+func (s *MediaSupplyService) mediaSupplyObjectKeyFromPublicAssetURL(raw string, tenantID int64) (string, bool, bool) {
+ token, ok := mediaSupplyPublicAssetTokenFromURL(raw)
+ if !ok {
+ return "", false, false
+ }
+ objectKey, valid := publicasset.ParseObjectKeyToken(token, s.mediaSupplyAssetTokenSecret())
+ if !valid || !mediaSupplyObjectKeyAllowedForTenant(objectKey, tenantID) {
+ return "", true, false
+ }
+ return objectKey, true, true
+}
+
+func (s *MediaSupplyService) mediaSupplyAssetTokenSecret() string {
+ if s == nil || s.cfg == nil || s.cfg.Current() == nil {
+ return ""
+ }
+ return strings.TrimSpace(s.cfg.Current().JWT.Secret)
+}
+
+func mediaSupplyPublicAssetTokenFromURL(raw string) (string, bool) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", false
+ }
+ if strings.HasPrefix(raw, "/api/public/assets/") {
+ return mediaSupplyAssetTokenFromPath(raw), true
+ }
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ return "", false
+ }
+ if strings.HasPrefix(parsed.Path, "/api/public/assets/") {
+ return mediaSupplyAssetTokenFromPath(parsed.Path), true
+ }
+ return "", false
+}
+
+func mediaSupplyAssetTokenFromPath(pathValue string) string {
+ token := strings.TrimPrefix(strings.TrimSpace(pathValue), "/api/public/assets/")
+ if slash := strings.Index(token, "/"); slash >= 0 {
+ token = token[:slash]
+ }
+ if idx := strings.IndexAny(token, "?#"); idx >= 0 {
+ token = token[:idx]
+ }
+ token, _ = url.PathUnescape(token)
+ return strings.TrimSpace(token)
+}
+
+func mediaSupplyObjectKeyAllowedForTenant(objectKey string, tenantID int64) bool {
+ if tenantID <= 0 {
+ return false
+ }
+ tenantPrefix := "tenants/" + strconv.FormatInt(tenantID, 10) + "/"
+ return strings.HasPrefix(objectKey, tenantPrefix+"images/") ||
+ strings.HasPrefix(objectKey, tenantPrefix+"articles/")
+}
+
+func (s *MediaSupplyService) loadMediaSupplyImageAssetObjectKey(ctx context.Context, tenantID, assetID int64) (string, error) {
+ if s == nil || s.pool == nil {
+ return "", errMediaSupplyArticleImageUpload
+ }
+ var objectKey string
+ if err := s.pool.QueryRow(ctx, `
+ SELECT object_key
+ FROM image_assets
+ WHERE id = $1 AND tenant_id = $2 AND status = 'active' AND deleted_at IS NULL
+ `, assetID, tenantID).Scan(&objectKey); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return "", errMediaSupplyArticleImageUpload
+ }
+ return "", fmt.Errorf("%w: %v", errMediaSupplyArticleImageUpload, err)
+ }
+ if !mediaSupplyObjectKeyAllowedForTenant(objectKey, tenantID) {
+ return "", errMediaSupplyArticleImageUpload
+ }
+ return objectKey, nil
+}
+
+func mediaSupplyImageURLRequiresUpload(raw string) bool {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || strings.HasPrefix(strings.ToLower(raw), "data:") {
+ return false
+ }
+ if _, ok := mediaSupplyPublicAssetTokenFromURL(raw); ok {
+ return true
+ }
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ return true
+ }
+ if parsed.Scheme == "" && parsed.Host == "" {
+ return strings.HasPrefix(raw, "/") || !strings.Contains(raw, "://")
+ }
+ host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
+ return host == "localhost" || host == "127.0.0.1" || host == "::1"
+}
+
+func mediaSupplyHTMLAttrValue(attrs []nethtml.Attribute, key string) string {
+ for _, attr := range attrs {
+ if strings.EqualFold(strings.TrimSpace(attr.Key), key) {
+ return strings.TrimSpace(attr.Val)
+ }
+ }
+ return ""
+}
+
+func mediaSupplyHTMLInt64AttrValue(attrs []nethtml.Attribute, key string) int64 {
+ value := mediaSupplyHTMLAttrValue(attrs, key)
+ if value == "" {
+ return 0
+ }
+ parsed, err := strconv.ParseInt(value, 10, 64)
+ if err != nil || parsed <= 0 {
+ return 0
+ }
+ return parsed
+}
diff --git a/server/internal/tenant/app/media_supply_service.go b/server/internal/tenant/app/media_supply_service.go
index 2f33c10..0e241c9 100644
--- a/server/internal/tenant/app/media_supply_service.go
+++ b/server/internal/tenant/app/media_supply_service.go
@@ -17,15 +17,17 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/config"
+ "github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type MediaSupplyService struct {
- pool *pgxpool.Pool
- redis *goredis.Client
- logger *zap.Logger
- cfg config.Provider
- client *MeijiequanClient
+ pool *pgxpool.Pool
+ redis *goredis.Client
+ logger *zap.Logger
+ cfg config.Provider
+ client *MeijiequanClient
+ objectStorage objectstorage.Client
}
func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *zap.Logger, cfg config.Provider) *MediaSupplyService {
@@ -43,6 +45,13 @@ func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *za
}
}
+func (s *MediaSupplyService) WithObjectStorage(storage objectstorage.Client) *MediaSupplyService {
+ if s != nil {
+ s.objectStorage = storage
+ }
+ return s
+}
+
func (s *MediaSupplyService) mediaSupplyConfig() config.MediaSupplyConfig {
if s != nil && s.cfg != nil && s.cfg.Current() != nil {
cfg := s.cfg.Current().MediaSupply
diff --git a/server/internal/tenant/app/media_supply_test.go b/server/internal/tenant/app/media_supply_test.go
index df1a0bd..853c312 100644
--- a/server/internal/tenant/app/media_supply_test.go
+++ b/server/internal/tenant/app/media_supply_test.go
@@ -1,8 +1,16 @@
package app
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
+ "image"
+ "image/color"
+ "image/png"
+ "io"
+ "net/http"
+ "net/http/httptest"
"strings"
"testing"
"time"
@@ -11,12 +19,39 @@ import (
goredis "github.com/redis/go-redis/v9"
"github.com/geo-platform/tenant-api/internal/shared/config"
+ "github.com/geo-platform/tenant-api/internal/shared/publicasset"
)
type mediaSupplyOrderScanStub struct {
values []any
}
+type mediaSupplyImageObjectStorage struct {
+ content []byte
+ gotKey string
+}
+
+func (s *mediaSupplyImageObjectStorage) Validate() error { return nil }
+
+func (s *mediaSupplyImageObjectStorage) PutBytes(context.Context, string, []byte, string) error {
+ return nil
+}
+
+func (s *mediaSupplyImageObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
+ s.gotKey = objectKey
+ return s.content, nil
+}
+
+func (s *mediaSupplyImageObjectStorage) Exists(context.Context, string) (bool, error) {
+ return true, nil
+}
+
+func (s *mediaSupplyImageObjectStorage) Delete(context.Context, string) error { return nil }
+
+func (s *mediaSupplyImageObjectStorage) PublicURL(string) (string, error) { return "", nil }
+
+func (s *mediaSupplyImageObjectStorage) DownloadURL(string) (string, error) { return "", nil }
+
func (s mediaSupplyOrderScanStub) Scan(dest ...any) error {
for i, value := range s.values {
switch target := dest[i].(type) {
@@ -41,6 +76,66 @@ func (s mediaSupplyOrderScanStub) Scan(dest ...any) error {
return nil
}
+func testMediaSupplyPNG(t *testing.T) []byte {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
+ img.Set(0, 0, color.RGBA{R: 255, A: 255})
+ img.Set(1, 0, color.RGBA{G: 255, A: 255})
+ img.Set(0, 1, color.RGBA{B: 255, A: 255})
+ img.Set(1, 1, color.RGBA{R: 255, G: 255, A: 255})
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ t.Fatalf("encode png: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func testMeijiequanClientWithUploadServer(t *testing.T, handler http.HandlerFunc) (*MeijiequanClient, func()) {
+ t.Helper()
+ server := httptest.NewServer(handler)
+ redisServer, err := miniredis.Run()
+ if err != nil {
+ server.Close()
+ t.Fatalf("miniredis: %v", err)
+ }
+ rdb := goredis.NewClient(&goredis.Options{Addr: redisServer.Addr()})
+ now := time.Now().UTC()
+ expiresAt := now.Add(time.Hour)
+ session := meijiequanStoredSession{
+ Cookies: []meijiequanStoredCookie{{
+ Name: "laravel_session",
+ Value: "test-session",
+ Domain: strings.TrimPrefix(server.URL, "http://"),
+ Path: "/",
+ }},
+ CSRFToken: "csrf-token",
+ ExpiresAt: &expiresAt,
+ UpdatedAt: now,
+ }
+ raw, err := json.Marshal(session)
+ if err != nil {
+ server.Close()
+ redisServer.Close()
+ t.Fatalf("marshal session: %v", err)
+ }
+ if err := rdb.Set(context.Background(), meijiequanSessionKey, raw, time.Hour).Err(); err != nil {
+ server.Close()
+ redisServer.Close()
+ t.Fatalf("set session: %v", err)
+ }
+ client := NewMeijiequanClient(rdb, config.MeijiequanConfig{
+ BaseURL: server.URL,
+ RequestTimeout: 3 * time.Second,
+ UpstreamMinInterval: time.Millisecond,
+ })
+ cleanup := func() {
+ _ = rdb.Close()
+ redisServer.Close()
+ server.Close()
+ }
+ return client, cleanup
+}
+
func TestMediaSupplySellPriceNeverBelowCost(t *testing.T) {
got := mediaSupplySellPrice(1000, 900, true, 0, 0)
if got != 1000 {
@@ -340,6 +435,127 @@ func TestMeijiequanArticleHTMLBackfillsImageSrcFromOriginalSrc(t *testing.T) {
}
}
+func TestMeijiequanUploadEditorImageUsesUEditorEndpoint(t *testing.T) {
+ var gotPath string
+ var gotAction string
+ var gotCategory string
+ var gotFieldContent []byte
+ var gotFilename string
+ var gotContentType string
+ client, cleanup := testMeijiequanClientWithUploadServer(t, func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ gotAction = r.URL.Query().Get("action")
+ gotCategory = r.URL.Query().Get("category")
+ reader, err := r.MultipartReader()
+ if err != nil {
+ t.Fatalf("multipart reader: %v", err)
+ }
+ for {
+ part, err := reader.NextPart()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ t.Fatalf("multipart part: %v", err)
+ }
+ if part.FormName() != "upfile" {
+ continue
+ }
+ gotFilename = part.FileName()
+ gotContentType = part.Header.Get("Content-Type")
+ gotFieldContent, err = io.ReadAll(part)
+ if err != nil {
+ t.Fatalf("read part: %v", err)
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"state":"SUCCESS","url":"http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/test.png","type":".png","size":12}`))
+ })
+ defer cleanup()
+
+ resp, err := client.UploadEditorImage(context.Background(), MeijiequanUploadImageRequest{
+ Filename: "body.png",
+ ContentType: "image/png",
+ Content: []byte("image-bytes"),
+ })
+ if err != nil {
+ t.Fatalf("upload image: %v", err)
+ }
+ if resp.URL != "http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/test.png" {
+ t.Fatalf("unexpected uploaded url: %#v", resp)
+ }
+ if gotPath != "/vendor/ueditor/php/controller.php" || gotAction != "uploadimage" || gotCategory != "yhdoc" {
+ t.Fatalf("unexpected endpoint path=%s action=%s category=%s", gotPath, gotAction, gotCategory)
+ }
+ if gotFilename != "body.png" || gotContentType != "image/png" || string(gotFieldContent) != "image-bytes" {
+ t.Fatalf("unexpected multipart field filename=%s contentType=%s content=%q", gotFilename, gotContentType, gotFieldContent)
+ }
+}
+
+func TestPrepareMeijiequanArticleContentUploadsSaaSImages(t *testing.T) {
+ objectKey := "tenants/4/images/body.webp"
+ token := publicasset.SignObjectKey(objectKey, "test-secret")
+ storage := &mediaSupplyImageObjectStorage{content: testMediaSupplyPNG(t)}
+ var uploadedBytes []byte
+ var uploadedFilename string
+ client, cleanup := testMeijiequanClientWithUploadServer(t, func(w http.ResponseWriter, r *http.Request) {
+ reader, err := r.MultipartReader()
+ if err != nil {
+ t.Fatalf("multipart reader: %v", err)
+ }
+ for {
+ part, err := reader.NextPart()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ t.Fatalf("multipart part: %v", err)
+ }
+ if part.FormName() != "upfile" {
+ continue
+ }
+ uploadedFilename = part.FileName()
+ uploadedBytes, err = io.ReadAll(part)
+ if err != nil {
+ t.Fatalf("read part: %v", err)
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"state":"SUCCESS","url":"http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/202606/03/body.png","type":".png","size":99}`))
+ })
+ defer cleanup()
+
+ svc := (&MediaSupplyService{
+ client: client,
+ objectStorage: storage,
+ cfg: config.NewStaticProvider(&config.Config{
+ JWT: config.JWTConfig{Secret: "test-secret"},
+ }),
+ }).WithObjectStorage(storage)
+ content := `