Files
geo/server/internal/tenant/app/media_supply_test.go
T
root ced0c4ec0f
Frontend CI / Frontend (push) Successful in 2m59s
Backend CI / Backend (push) Successful in 14m58s
Scope knowledge and image libraries by brand
2026-06-30 12:30:10 +08:00

1018 lines
33 KiB
Go

package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"image"
"image/color"
"image/png"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
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) {
case *int:
*target = value.(int)
case *int64:
*target = value.(int64)
case *string:
*target = value.(string)
case **string:
*target = value.(*string)
case **int64:
*target = value.(*int64)
case *time.Time:
*target = value.(time.Time)
case **time.Time:
*target = value.(*time.Time)
default:
panic("unsupported scan target")
}
}
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 {
t.Fatalf("expected cost floor 1000, got %d", got)
}
got = mediaSupplySellPrice(1000, 1100, true, 20, 0)
if got != 1100 {
t.Fatalf("expected explicit override above cost 1100, got %d", got)
}
got = mediaSupplySellPrice(1000, 1400, true, 20, 0)
if got != 1400 {
t.Fatalf("expected override above floor 1400, got %d", got)
}
}
func TestMediaSupplySellPricePrefersManualOverride(t *testing.T) {
got := mediaSupplySellPrice(1000, 1100, true, 50, 0)
if got != 1100 {
t.Fatalf("expected manual override 1100 to win over 50%% markup floor, got %d", got)
}
got = mediaSupplySellPrice(1000, 1101, true, 50, 0)
if got != 1200 {
t.Fatalf("expected manual override 1101 to round up to 1200, got %d", got)
}
}
func TestMediaSupplySellPriceRoundsUpToWholeYuan(t *testing.T) {
got := mediaSupplySellPrice(8040, 0, false, 0, 0)
if got != 8100 {
t.Fatalf("expected 8040 cents to round up to 8100, got %d", got)
}
got = mediaSupplySellPrice(7001, 0, false, 0, 0)
if got != 7100 {
t.Fatalf("expected 7001 cents to round up to 7100, got %d", got)
}
got = mediaSupplySellPrice(7000, 0, false, 0, 0)
if got != 7000 {
t.Fatalf("expected whole yuan price to stay 7000, got %d", got)
}
}
func TestMediaSupplySellPriceAppliesMarkupBeforeRoundUp(t *testing.T) {
got := mediaSupplySellPrice(300, 0, false, 50, 0)
if got != 500 {
t.Fatalf("expected 300 cents with 50%% markup to round up to 500, got %d", got)
}
got = mediaSupplySellPrice(800, 0, false, 50, 0)
if got != 1200 {
t.Fatalf("expected 800 cents with 50%% markup to be 1200, got %d", got)
}
got = mediaSupplySellPrice(8040, 0, false, 50, 0)
if got != 12100 {
t.Fatalf("expected 8040 cents with 50%% markup to round up to 12100, got %d", got)
}
}
func TestMediaSupplyOverrideBelowCostUsesSyncedCostPrices(t *testing.T) {
resource := SupplierMediaResource{
CostPriceCents: 1000,
CostPrices: map[string]int64{
"price": 1500,
"priceyc": 2200,
},
}
if !mediaSupplyOverrideBelowCost(resource, "price", 1400, true) {
t.Fatal("expected default price override below synced price cost to be cleared")
}
if mediaSupplyOverrideBelowCost(resource, "price", 1500, true) {
t.Fatal("expected override equal to synced price cost to stay")
}
if !mediaSupplyOverrideBelowCost(resource, "priceyc", 2100, true) {
t.Fatal("expected priceyc override below synced priceyc cost to be cleared")
}
if mediaSupplyOverrideBelowCost(resource, "priceyc", 2100, false) {
t.Fatal("disabled override should not be treated as active loss risk")
}
}
func TestMediaSupplyResourceOrderSQL(t *testing.T) {
priceSQL := "sell_price_expr"
if got := mediaSupplyResourceOrderSQL("price", priceSQL); got != "(sell_price_expr) ASC, r.id DESC" {
t.Fatalf("unexpected price order SQL: %s", got)
}
if got := mediaSupplyResourceOrderSQL("weight", priceSQL); got != "COALESCE(r.baidu_weight, 0) DESC, r.id DESC" {
t.Fatalf("unexpected weight order SQL: %s", got)
}
if got := mediaSupplyResourceOrderSQL("unknown", priceSQL); got != "r.updated_at DESC, r.id DESC" {
t.Fatalf("unexpected default order SQL: %s", got)
}
}
func TestFormatMeijiequanResourceID(t *testing.T) {
item := MediaSupplyOrderItem{SupplierResourceID: "36112", PriceType: "price"}
if got := formatMeijiequanResourceID(item); got != "36112_price_0" {
t.Fatalf("unexpected default price resource id: %s", got)
}
item.PriceType = "priceyc"
if got := formatMeijiequanResourceID(item); got != "36112_priceyc_0" {
t.Fatalf("unexpected variant resource id: %s", got)
}
}
func TestRetryableMediaSupplyErrorTreatsBusinessFailuresAsTerminal(t *testing.T) {
terminalErrors := []error{
errMeijiequanSessionMissing,
errMeijiequanSessionExpired,
errMeijiequanAuthRequired,
errMeijiequanOrderRejected,
errMediaSupplySelectedResourcesMissing,
errMediaSupplyLockedSellBelowCost,
}
for _, err := range terminalErrors {
if retryableMediaSupplyError(err) {
t.Fatalf("expected terminal media supply error to be non-retryable: %v", err)
}
}
if !retryableMediaSupplyError(errors.New("temporary network failure")) {
t.Fatal("expected generic temporary error to remain retryable")
}
}
func TestMediaSupplyRefreshFailureMessageIsActionable(t *testing.T) {
got := mediaSupplyPublicErrorMessage(errMediaSupplySelectedResourcesMissing)
if got != "媒体资源暂不可用或已下架,请重新选择媒体" {
t.Fatalf("unexpected refresh failure message: %s", got)
}
got = mediaSupplyPublicGenericErrorMessage("failed to refresh supplier price for 1 selected resources")
if got != "媒体资源暂不可用或已下架,请重新选择媒体" {
t.Fatalf("unexpected generic refresh failure message: %s", got)
}
}
func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) {
now := time.Now().UTC()
errorMessage := "failed to refresh supplier price for 1 selected resources"
externalOrderID := "U6210"
externalOrderCode := "U6210"
item, err := scanMediaSupplyOrder(mediaSupplyOrderScanStub{values: []any{
int64(1),
int64(2),
int64(3),
int64(4),
int64(5),
(*int64)(nil),
mediaSupplySupplierMeijiequan,
1,
mediaSupplyOrderStatusFailed,
"测试标题",
(*string)(nil),
(*string)(nil),
&externalOrderID,
&externalOrderCode,
(*string)(nil),
int64(100),
int64(120),
int64(120),
(*time.Time)(nil),
(*time.Time)(nil),
&errorMessage,
1,
now,
(*time.Time)(nil),
(*time.Time)(nil),
now,
now,
}})
if err != nil {
t.Fatalf("scan failed: %v", err)
}
if strings.Contains(strings.ToLower(item.Supplier), "meijiequan") {
t.Fatalf("supplier was not redacted: %s", item.Supplier)
}
if item.ErrorMessage == nil || *item.ErrorMessage != "媒体资源暂不可用或已下架,请重新选择媒体" {
t.Fatalf("unexpected public error message: %#v", item.ErrorMessage)
}
if item.ExternalOrderID != nil {
t.Fatalf("external order id should not be exposed: %#v", item.ExternalOrderID)
}
if item.ExternalOrderCode != nil {
t.Fatalf("invalid external order code should not be exposed: %#v", item.ExternalOrderCode)
}
}
func TestMeijiequanCreateOrderFormMatchesUpstreamShape(t *testing.T) {
form := meijiequanCreateOrderForm(MeijiequanCreateOrderRequest{
UID: "962",
ModelID: 1,
Title: "2026合肥全屋定制推荐 6家选型参考",
Content: "# 如何选择\n\n![封面](http://example.test/a.png \"178.png\")\n\n- 正文内容\n- **重点**",
ResourceIDArr: []string{"36122_price_0"},
PointTime: "2026-06-06 11:00:00",
PointPriceRange: "1",
Extra: map[string]any{
"source": "平台稿件",
"description": "稿件描述",
},
})
if got := form.Get("uid"); got != "962" {
t.Fatalf("unexpected uid: %s", got)
}
if got := form.Get("prop[biaoti]"); got != "2026合肥全屋定制推荐 6家选型参考" {
t.Fatalf("unexpected title: %s", got)
}
if got := form.Get("prop[laiyuan]"); got != "平台稿件" {
t.Fatalf("unexpected source: %s", got)
}
if got := form.Get("prop[miaoshu]"); got != "稿件描述" {
t.Fatalf("unexpected description: %s", got)
}
content := form.Get("prop[neirong]")
if !strings.Contains(content, "<h1>如何选择</h1>") ||
!strings.Contains(content, `src="http://example.test/a.png"`) ||
!strings.Contains(content, `title="178.png"`) ||
!strings.Contains(content, `_src="http://example.test/a.png"`) ||
!strings.Contains(content, "<li>正文内容</li>") ||
!strings.Contains(content, "<strong>重点</strong>") {
t.Fatalf("unexpected html content: %s", content)
}
if got := form.Get("model_id"); got != "1" {
t.Fatalf("unexpected model_id: %s", got)
}
if got := form.Get("voucher_id"); got != "0" {
t.Fatalf("unexpected voucher_id: %s", got)
}
if got := form.Get("resource_id_arr"); got != `["36122_price_0"]` {
t.Fatalf("unexpected resource_id_arr: %s", got)
}
if got := form.Get("resource_spare_arr"); got != `[]` {
t.Fatalf("unexpected resource_spare_arr: %s", got)
}
if got := form.Get("point_time"); got != "2026-06-06 11:00:00" {
t.Fatalf("unexpected point_time: %s", got)
}
if got := form.Get("point_price_range"); got != "1" {
t.Fatalf("unexpected point_price_range: %s", got)
}
}
func TestMeijiequanArticleHTMLKeepsExistingHTML(t *testing.T) {
input := `<p><img src="http://example.test/a.png" title="x" _src="http://example.test/a.png" alt="a"></p><p>正文</p>`
got := meijiequanArticleHTML(input)
if !strings.Contains(got, `<p><img src="http://example.test/a.png"`) ||
!strings.Contains(got, `title="x"`) ||
!strings.Contains(got, `_src="http://example.test/a.png"`) ||
!strings.Contains(got, `alt="a"`) ||
!strings.Contains(got, `<p>正文</p>`) {
t.Fatalf("expected html image and paragraphs to be preserved, got %s", got)
}
}
func TestMeijiequanArticleHTMLConvertsMilkdownMixedMarkdown(t *testing.T) {
input := strings.Join([]string{
"# 标题",
"",
`<p class="article-editor-image article-editor-image--center" align="center"><img src="http://example.test/a.webp" alt="图注" title="图注" data-asset-id="88" /></p>`,
"",
"## 小标题",
"",
"- 第一条",
"- **第二条**",
"",
"| 维度 | 说明 |",
"| --- | --- |",
"| 环保 | ENF |",
}, "\n")
got := meijiequanArticleHTML(input)
if !strings.Contains(got, "<h1>标题</h1>") ||
!strings.Contains(got, `<img src="http://example.test/a.webp"`) ||
!strings.Contains(got, `_src="http://example.test/a.webp"`) ||
strings.Contains(got, "data-asset-id") ||
!strings.Contains(got, "<h2>小标题</h2>") ||
!strings.Contains(got, "<li>第一条</li>") ||
!strings.Contains(got, "<strong>第二条</strong>") ||
!strings.Contains(got, "<table>") ||
!strings.Contains(got, "<td>ENF</td>") {
t.Fatalf("unexpected mixed markdown html: %s", got)
}
}
func TestMeijiequanArticleHTMLBackfillsImageSrcFromOriginalSrc(t *testing.T) {
got := meijiequanArticleHTML(`<p><img _src="http://example.test/original.png" alt="a"></p>`)
if !strings.Contains(got, `src="http://example.test/original.png"`) ||
!strings.Contains(got, `_src="http://example.test/original.png"`) {
t.Fatalf("expected src and _src to be normalized, got %s", got)
}
}
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, 11, 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",
"extra": {
"source": "来源",
"point_time": "2026-06-06 11:00:00"
},
"point_price_range": "1"
}`))
if got := meijiequanAnyString(extra["source"]); got != "来源" {
t.Fatalf("unexpected source: %s", got)
}
if got := meijiequanAnyString(extra["point_time"]); got != "2026-06-06 11:00:00" {
t.Fatalf("unexpected point_time: %s", got)
}
if got := meijiequanAnyString(extra["point_price_range"]); got != "1" {
t.Fatalf("unexpected point_price_range: %s", got)
}
}
func TestManualMeijiequanChallengeResolverRequiresManualHandling(t *testing.T) {
_, err := ManualMeijiequanChallengeCodeResolver{}.ResolveCode(context.Background(), MeijiequanChallenge{})
if !errors.Is(err, errMeijiequanChallengeRequired) {
t.Fatalf("expected challenge required error, got %v", err)
}
}
func TestMeijiequanHiddenInputs(t *testing.T) {
fields := parseMeijiequanHiddenInputs(strings.NewReader(`
<form>
<input type="hidden" name="_token" value="token-value">
<input type="hidden" name="sk" id="sk" value="sk-value">
<input type="text" name="username" value="ignore">
</form>`))
if fields["_token"] != "token-value" || fields["sk"] != "sk-value" {
t.Fatalf("unexpected hidden fields: %#v", fields)
}
}
func TestDecodeMeijiequanMediaListAcceptsStringNumbers(t *testing.T) {
parsed, err := decodeMeijiequanMediaList([]byte(`{
"status": "1",
"info": "ok",
"page": "2",
"all_page": "7",
"all_num": "121",
"user_id": "8899",
"data": [{"id":"1","name":"测试媒体","sale_price":"12.5"}]
}`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if parsed.Status != 1 || parsed.Page != 2 || parsed.AllPage != 7 || parsed.AllNum != 121 || parsed.UserID != 8899 {
t.Fatalf("unexpected parsed page metadata: %#v", parsed)
}
if len(parsed.Data) != 1 {
t.Fatalf("unexpected item count: %d", len(parsed.Data))
}
}
func TestMeijiequanListMediaKeepsRawItemCountForPaging(t *testing.T) {
client, cleanup := testMeijiequanClientWithUploadServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/advSupply/media/getMediaInfo" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"status": "1",
"page": "1",
"all_page": "0",
"all_num": "0",
"data": [
{"id":"1","name":"有效媒体","sale_price":"12.5"},
{"id":"2","sale_price":"18.0"}
]
}`))
})
defer cleanup()
page, err := client.ListMedia(context.Background(), 1, 1, 2)
if err != nil {
t.Fatalf("list media: %v", err)
}
if page.RawItemCount != 2 {
t.Fatalf("expected raw count to include invalid upstream rows, got %d", page.RawItemCount)
}
if len(page.Items) != 1 {
t.Fatalf("expected only valid rows to be upserted, got %d", len(page.Items))
}
}
func TestDecodeMeijiequanSearchOptions(t *testing.T) {
groups, err := decodeMeijiequanSearchOptions([]byte(`{
"pindaoleixing": {"name": "频道类型", "list": ["IT科技", "财经金融"]},
"quyu": {"name": "所在地区", "list": ["全国", "北京"]}
}`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(groups) != 2 {
t.Fatalf("unexpected group count: %d", len(groups))
}
if groups[0].Key != "pindaoleixing" || groups[0].Name != "频道类型" || groups[0].List[1] != "财经金融" {
t.Fatalf("unexpected first group: %#v", groups[0])
}
}
func TestDecodeMeijiequanPublishedArticleJSON(t *testing.T) {
page, err := decodeMeijiequanPublishedArticlePage([]byte(`{
"status": "1",
"page": "1",
"all_page": "2",
"all_num": "21",
"data": [{
"id": "8891",
"order_code": "A20260529001",
"biaoti": "2026合肥品牌推荐",
"resource_id": "36122_price_0",
"media_name": "测试新闻网",
"article_url": "https://news.example.test/a.html",
"status_text": "已发表",
"publish_time": "2026-05-29 16:00:00"
}]
}`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if page.Page != 1 || page.AllPage != 2 || page.AllNum != 21 || len(page.Items) != 1 {
t.Fatalf("unexpected page: %#v", page)
}
item := page.Items[0]
if item.ResourceID != "36122" ||
item.ExternalArticleURL != "https://news.example.test/a.html" ||
item.Title != "2026合肥品牌推荐" ||
item.Status != "已发表" ||
item.PublishedAt == nil {
t.Fatalf("unexpected article item: %#v", item)
}
}
func TestDecodeMeijiequanPublishedArticleHTML(t *testing.T) {
page, err := decodeMeijiequanPublishedArticlePage([]byte(`
<table>
<tbody>
<tr data-id="8891" data-resource-id="36122_price_0">
<td>2026合肥品牌推荐</td>
<td>测试新闻网</td>
<td>WM118996</td>
<td><a href="https://news.example.test/a.html">查看链接</a></td>
<td>已发表</td>
</tr>
</tbody>
</table>`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("unexpected article count: %d", len(page.Items))
}
item := page.Items[0]
if item.OrderID != "8891" ||
item.OrderCode != "WM118996" ||
item.ResourceID != "36122" ||
item.ExternalArticleURL != "https://news.example.test/a.html" ||
item.Title != "2026合肥品牌推荐" {
t.Fatalf("unexpected html article item: %#v", item)
}
}
func TestDecodeMeijiequanPublishedArticleHTMLUsesCopiedBacklinkNotShowURL(t *testing.T) {
page, err := decodeMeijiequanPublishedArticlePage([]byte(`
<table>
<tbody>
<tr data-id="8891" data-resource-id="36122_price_0">
<td>WM118996</td>
<td>2026合肥品牌推荐</td>
<td>金融树洞网</td>
<td>
<a href="http://show.meitidaren.top/review/abc">查看回链</a>
<button data-clipboard-text="http://jrsd.huaihuarexian.cn/caijing/11221014.html">一键复制</button>
</td>
<td>已发布</td>
</tr>
</tbody>
</table>`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("unexpected article count: %d", len(page.Items))
}
item := page.Items[0]
if item.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" {
t.Fatalf("expected copied real backlink, got %#v", item)
}
}
func TestDecodeMeijiequanAllArticleHTMLKeepsStatusAndRejectReasonColumns(t *testing.T) {
page, err := decodeMeijiequanPublishedArticlePage([]byte(`
<table>
<thead>
<tr>
<th>订单号</th><th>标题</th><th>资源名称</th><th>价格</th><th>状态</th>
<th>回链</th><th>标签</th><th>创建时间</th><th>发稿时间</th><th>退稿原因</th><th>退稿时间</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>WM119065</td>
<td>合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议</td>
<td>博客园(GEO排名可发)</td>
<td>3.00</td>
<td>退稿申请中</td>
<td></td>
<td></td>
<td>05-29 20:45</td>
<td></td>
<td>不想发布了</td>
<td>05-29 20:54</td>
<td>标签 | 再次发稿</td>
</tr>
<tr>
<td>WM118996</td>
<td>全屋定制品牌哪家强?2026 年合肥品牌推荐与排名</td>
<td>金融树洞网</td>
<td>3.00</td>
<td>已发布</td>
<td><button data-clipboard-text="http://jrsd.huaihuarexian.cn/caijing/11221014.html">一键复制</button></td>
<td></td>
<td>05-29 17:16</td>
<td>05-29 17:20</td>
<td></td>
<td></td>
<td>标签 | 再次发稿 | 申请退稿</td>
</tr>
</tbody>
</table>`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(page.Items) != 2 {
t.Fatalf("unexpected article count: %d", len(page.Items))
}
problem := page.Items[0]
if problem.OrderCode != "WM119065" ||
problem.Title != "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议" ||
problem.ResourceName != "博客园(GEO排名可发)" ||
problem.PriceCents != 300 ||
problem.Status != "退稿申请中" ||
problem.RejectReason != "不想发布了" {
t.Fatalf("unexpected problem article: %#v", problem)
}
published := page.Items[1]
if published.OrderCode != "WM118996" ||
published.ResourceName != "金融树洞网" ||
published.PriceCents != 300 ||
published.Status != "已发布" ||
published.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" {
t.Fatalf("unexpected published article: %#v", published)
}
}
func TestDecodeMeijiequanPublishedArticleJSONRejectsShowURL(t *testing.T) {
page, err := decodeMeijiequanPublishedArticlePage([]byte(`{
"status": "1",
"data": [{
"order_code": "WM118996",
"biaoti": "2026合肥品牌推荐",
"media_name": "金融树洞网",
"article_url": "http://show.meitidaren.top/review/abc"
}]
}`))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("unexpected article count: %d", len(page.Items))
}
if page.Items[0].ExternalArticleURL != "" {
t.Fatalf("show url should not be treated as backlink: %#v", page.Items[0])
}
}
func TestMatchMeijiequanPublishedArticlesPrefersResourceID(t *testing.T) {
items := []MediaSupplyOrderItem{
{ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "测试新闻网", LockedCostPriceCents: 300, LockedSellPriceCents: 500},
{ID: 12, SupplierResourceID: "36123", ResourceNameSnapshot: "另一个媒体", LockedCostPriceCents: 300, LockedSellPriceCents: 500},
}
matches := matchMeijiequanPublishedArticles(mediaSupplySubmitOrder{Title: "2026合肥品牌推荐"}, items, []MeijiequanPublishedArticle{
{
Title: "2026合肥品牌推荐",
ResourceID: "36123_price_0",
ResourceName: "另一个媒体",
PriceCents: 300,
ExternalArticleURL: "https://news.example.test/b.html",
},
})
if len(matches) != 1 {
t.Fatalf("unexpected matches: %#v", matches)
}
if matches[12].ExternalArticleURL != "https://news.example.test/b.html" {
t.Fatalf("unexpected matched item: %#v", matches)
}
}
func TestMatchMeijiequanOrderCodeArticleUsesTitleResourceAndPriceFallback(t *testing.T) {
items := []MediaSupplyOrderItem{{
ID: 11,
SupplierResourceID: "36122",
ResourceNameSnapshot: "博客园(GEO排名可发)",
LockedCostPriceCents: 300,
LockedSellPriceCents: 900,
}}
article := matchMeijiequanOrderCodeArticle(
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选"},
items,
[]MeijiequanPublishedArticle{
{OrderCode: "WM120001", Title: "合肥全屋定制怎么选", ResourceName: "博客园(GEO排名可发)", PriceCents: 900},
{OrderCode: "WM120002", Title: "合肥全屋定制怎么选", ResourceName: "博客园(GEO排名可发)", PriceCents: 300},
},
)
if article == nil || article.OrderCode != "WM120002" {
t.Fatalf("expected title/resource/price fallback to match WM120002, got %#v", article)
}
}
func TestMatchMeijiequanOrderCodeArticleDoesNotFallbackWhenOrderCodeDiffers(t *testing.T) {
orderCode := "WM120090"
items := []MediaSupplyOrderItem{{
ID: 11,
SupplierResourceID: "36122",
ResourceNameSnapshot: "博客园(GEO排名可发)",
LockedCostPriceCents: 300,
LockedSellPriceCents: 900,
}}
article := matchMeijiequanOrderCodeArticle(
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选", ExternalOrderCode: &orderCode},
items,
[]MeijiequanPublishedArticle{{
OrderCode: "WM120091",
Title: "合肥全屋定制怎么选",
ResourceName: "博客园(GEO排名可发)",
PriceCents: 300,
}},
)
if article != nil {
t.Fatalf("article with different order code should not match: %#v", article)
}
}
func TestMatchMeijiequanPublishedArticlesDoesNotFallbackWhenOrderCodeDiffers(t *testing.T) {
orderCode := "WM120090"
items := []MediaSupplyOrderItem{{
ID: 11,
SupplierResourceID: "36122",
ResourceNameSnapshot: "博客园(GEO排名可发)",
LockedCostPriceCents: 300,
LockedSellPriceCents: 900,
}}
matches := matchMeijiequanPublishedArticles(
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选", ExternalOrderCode: &orderCode},
items,
[]MeijiequanPublishedArticle{{
OrderCode: "WM120091",
Title: "合肥全屋定制怎么选",
ResourceName: "博客园(GEO排名可发)",
PriceCents: 300,
ExternalArticleURL: "https://news.example.test/a.html",
}},
)
if len(matches) != 0 {
t.Fatalf("article with different order code should not match: %#v", matches)
}
}
func TestMatchMeijiequanProblemArticleReturnsRejectReasonForCustomer(t *testing.T) {
orderCode := "WM119065"
article := matchMeijiequanProblemArticle(
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议", ExternalOrderCode: &orderCode},
[]MediaSupplyOrderItem{{ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "博客园(GEO排名可发)"}},
[]MeijiequanPublishedArticle{{
OrderCode: "WM119065",
Title: "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议",
ResourceName: "博客园(GEO排名可发)",
Status: "退稿申请中",
RejectReason: "不想发布了",
}},
)
if article == nil {
t.Fatal("expected problem article match")
}
if got := mediaSupplyProblemArticleReason(*article); got != "退稿原因:不想发布了" {
t.Fatalf("unexpected customer reject reason: %s", got)
}
}
func TestMatchMeijiequanProblemArticleDoesNotFallbackWhenOrderCodeDiffers(t *testing.T) {
orderCode := "WM119065"
article := matchMeijiequanProblemArticle(
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选", ExternalOrderCode: &orderCode},
[]MediaSupplyOrderItem{{
ID: 11,
SupplierResourceID: "36122",
ResourceNameSnapshot: "博客园(GEO排名可发)",
LockedCostPriceCents: 300,
}},
[]MeijiequanPublishedArticle{{
OrderCode: "WM119066",
Title: "合肥全屋定制怎么选",
ResourceName: "博客园(GEO排名可发)",
PriceCents: 300,
Status: "退稿申请中",
RejectReason: "不想发布了",
}},
)
if article != nil {
t.Fatalf("problem article with different order code should not match: %#v", article)
}
}
func TestMeijiequanOrderCodeOnlyAcceptsWMOrderNumber(t *testing.T) {
if isLikelyMeijiequanOrderCode("U6210") {
t.Fatal("U6210 should not be treated as an external order number")
}
if got := firstMeijiequanOrderCode("user U6210 created WM120090"); got != "WM120090" {
t.Fatalf("unexpected order code extraction: %s", got)
}
invalidCode := "U6210"
if got := mediaSupplyOrderCodeValue(mediaSupplySubmitOrder{ExternalOrderCode: &invalidCode}); got != "" {
t.Fatalf("invalid external order code should not be used for matching: %s", got)
}
}
func TestMaskMeijiequanUsername(t *testing.T) {
if got := maskMeijiequanUsername("17788409108"); got != "177******08" {
t.Fatalf("unexpected masked username: %s", got)
}
}
func TestMeijiequanUpstreamThrottleSerializesRequests(t *testing.T) {
server := miniredis.RunT(t)
redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()})
defer redis.Close()
client := NewMeijiequanClient(redis, config.MeijiequanConfig{UpstreamMinInterval: 80 * time.Millisecond})
ctx := context.Background()
if err := client.waitForUpstreamTurn(ctx, 80*time.Millisecond); err != nil {
t.Fatalf("first wait failed: %v", err)
}
start := time.Now()
if err := client.waitForUpstreamTurn(ctx, 80*time.Millisecond); err != nil {
t.Fatalf("second wait failed: %v", err)
}
if elapsed := time.Since(start); elapsed < 50*time.Millisecond {
t.Fatalf("expected serialized upstream wait, elapsed %s", elapsed)
}
}