feat(media-supply): integrate object storage for media supply service and enhance image upload handling
Backend CI / Backend (push) Successful in 16m49s

This commit is contained in:
2026-06-03 22:11:22 +08:00
parent d098633a67
commit ae455ea21c
10 changed files with 765 additions and 52 deletions
@@ -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 := `<p class="article-editor-image article-editor-image--center" align="center"><img src="/api/public/assets/` + token + `" alt="" data-asset-id="5" /></p>`
got, err := svc.prepareMeijiequanArticleContent(context.Background(), 4, content)
if err != nil {
t.Fatalf("prepare content: %v", err)
}
if storage.gotKey != objectKey {
t.Fatalf("expected object key %s, got %s", objectKey, storage.gotKey)
}
if uploadedFilename != "body.png" {
t.Fatalf("expected webp to upload as png filename, got %s", uploadedFilename)
}
if len(uploadedBytes) == 0 {
t.Fatal("expected uploaded bytes")
}
if strings.Contains(got, "/api/public/assets/") || strings.Contains(got, "data-asset-id") {
t.Fatalf("expected SaaS image attrs to be removed, got %s", got)
}
if !strings.Contains(got, `src="http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/202606/03/body.png"`) ||
!strings.Contains(got, `_src="http://nxobject.oss-cn-shanghai.aliyuncs.com/yhdoc/202606/03/body.png"`) {
t.Fatalf("expected meijiequan image url in src and _src, got %s", got)
}
}
func TestMediaSupplySubmitOrderExtraExtractsNestedPayload(t *testing.T) {
extra := mediaSupplySubmitOrderExtra([]byte(`{
"title": "ignored",