Files
geo/server/internal/tenant/app/media_supply_worker.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

1770 lines
52 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/config"
)
type MediaSupplyWorker struct {
service *MediaSupplyService
pool *pgxpool.Pool
monitoringPool *pgxpool.Pool
logger *zap.Logger
}
type mediaSupplySyncJob struct {
ID int64
ModelID *int
AttemptCount int
}
type mediaSupplySubmitOrder struct {
ID int64
TenantID int64
BrandID int64
UserID int64
ModelID int
Title string
ContentSnapshot string
Remark *string
OrderBrand *string
ExternalOrderID *string
ExternalOrderCode *string
RequestPayload json.RawMessage
AttemptCount int
}
var (
errMediaSupplySelectedResourcesMissing = errors.New("failed to refresh supplier price")
errMediaSupplyLockedSellBelowCost = errors.New("supplier cost increased above locked sell price")
)
var mediaSupplyOrderCodePattern = regexp.MustCompile(`(?i)\bWM\d{3,}\b`)
const (
mediaSupplyValidExternalOrderCodeSQL = `TRIM(COALESCE(external_order_code, '')) ~* '^WM[0-9]{3,}$'`
mediaSupplyBacklinkSyncCooldownKey = "media_supply:backlink_sync:cooldown"
)
func NewMediaSupplyWorker(service *MediaSupplyService, pool *pgxpool.Pool, logger *zap.Logger) *MediaSupplyWorker {
return &MediaSupplyWorker{service: service, pool: pool, logger: logger}
}
func (w *MediaSupplyWorker) WithMonitoringPool(monitoringPool *pgxpool.Pool) *MediaSupplyWorker {
if w != nil {
w.monitoringPool = monitoringPool
}
return w
}
func (w *MediaSupplyWorker) Start(ctx context.Context) {
if w == nil || w.service == nil || w.pool == nil {
return
}
cfg := w.service.mediaSupplyConfig()
if !cfg.Enabled || !cfg.Worker.Enabled {
return
}
go w.loop(ctx)
}
func (w *MediaSupplyWorker) loop(ctx context.Context) {
cfg := w.service.mediaSupplyConfig()
ticker := time.NewTicker(cfg.Worker.PollInterval)
defer ticker.Stop()
w.logInfo("media supply worker started")
for {
w.tick(ctx)
select {
case <-ctx.Done():
w.logInfo("media supply worker stopped")
return
case <-ticker.C:
}
}
}
func (w *MediaSupplyWorker) tick(ctx context.Context) {
cfg := w.service.mediaSupplyConfig()
for i := 0; i < cfg.Worker.BatchSize; i++ {
worked := false
if err := w.processOneSyncJob(ctx); err != nil {
w.logWarn("media supply sync job failed", zap.Error(err))
} else {
worked = true
}
if err := w.processOneOrder(ctx); err != nil {
w.logWarn("media supply order submit failed", zap.Error(err))
} else {
worked = true
}
if !worked {
break
}
}
if err := w.processBacklinkSync(ctx); err != nil {
w.logWarn("media supply backlink sync failed", zap.Error(err))
}
}
func (w *MediaSupplyWorker) processOneSyncJob(ctx context.Context) error {
job, ok, err := w.claimSyncJob(ctx)
if err != nil || !ok {
return err
}
modelID := 1
if job.ModelID != nil && *job.ModelID > 0 {
modelID = *job.ModelID
}
stats, syncErr := w.service.RunMediaResourceSyncOnce(ctx, modelID)
if syncErr != nil {
return w.failSyncJob(ctx, job, syncErr)
}
raw, _ := json.Marshal(stats)
if _, err := w.pool.Exec(ctx, `
UPDATE media_supply_sync_jobs
SET status = $2, finished_at = NOW(), stats_json = $3, updated_at = NOW()
WHERE id = $1
`, job.ID, mediaSupplySyncStatusSuccess, raw); err != nil {
return err
}
return nil
}
func (w *MediaSupplyWorker) syncModel(ctx context.Context, modelID, pageSize, maxPages int, pageDelay time.Duration) (int, error) {
stats, err := w.service.syncMediaResourceModel(ctx, modelID, pageSize, maxPages, pageDelay)
return stats.Synced, err
}
func (w *MediaSupplyWorker) processOneOrder(ctx context.Context) error {
order, ok, err := w.claimSubmitOrder(ctx)
if err != nil || !ok {
return err
}
cfg := w.service.mediaSupplyConfig()
userID := strings.TrimSpace(cfg.Meijiequan.UserID)
if status, statusErr := w.service.client.SessionStatus(ctx); statusErr != nil || status == nil || !status.Available {
if loginStatus, loginErr := w.service.client.LoginWithConfiguredAccount(ctx); loginErr != nil {
return w.failOrder(ctx, order, loginErr)
} else if userID == "" && loginStatus != nil {
userID = strings.TrimSpace(loginStatus.UserID)
}
} else if userID == "" {
userID = strings.TrimSpace(status.UserID)
}
if userID == "" {
return w.failOrder(ctx, order, errMediaSupplyAccountUserIDMissing)
}
items, err := w.loadSubmitOrderItems(ctx, order.ID)
if err != nil {
return w.failOrder(ctx, order, err)
}
if err := w.refreshOrderCostsBeforeSubmit(ctx, order, items); err != nil {
return w.failOrder(ctx, order, err)
}
content, err := w.service.prepareMeijiequanArticleContent(ctx, order.TenantID, order.BrandID, order.ContentSnapshot)
if err != nil {
return w.failOrder(ctx, order, err)
}
req := MeijiequanCreateOrderRequest{
UID: userID,
ModelID: order.ModelID,
Title: order.Title,
Content: content,
Remark: mediaSupplyStringPtrValue(order.Remark),
OrderBrand: mediaSupplyStringPtrValue(order.OrderBrand),
ResourceIDArr: make([]string, 0, len(items)),
Extra: mediaSupplySubmitOrderExtra(order.RequestPayload),
}
for _, item := range items {
req.ResourceIDArr = append(req.ResourceIDArr, formatMeijiequanResourceID(item))
}
resp, err := w.service.client.CreateOrder(ctx, req)
if err != nil {
return w.failOrder(ctx, order, err)
}
raw := json.RawMessage(`{}`)
if resp != nil && len(resp.Raw) > 0 {
raw = resp.Raw
}
externalID, externalCode := extractMeijiequanOrderIDs(resp)
w.logInfo("media supply create order response",
zap.Int64("order_id", order.ID),
zap.Int("upstream_status", mediaSupplyCreateOrderStatus(resp)),
zap.String("upstream_info", mediaSupplyCreateOrderInfo(resp)),
zap.String("raw_response", string(raw)),
zap.Stringp("external_order_id", externalID),
zap.Stringp("external_order_code", externalCode),
)
if _, err := w.pool.Exec(ctx, `
UPDATE media_supply_orders
SET status = $2,
supplier_status = $3,
external_order_id = COALESCE($4, external_order_id),
external_order_code = COALESCE($5, external_order_code),
response_payload_json = $6,
submitted_at = COALESCE(submitted_at, NOW()),
updated_at = NOW(),
error_message = NULL
WHERE id = $1
`, order.ID, mediaSupplyOrderStatusSubmitted, "submitted", externalID, externalCode, raw); err != nil {
return err
}
_, _ = w.pool.Exec(ctx, `
UPDATE media_supply_order_items
SET status = $2, updated_at = NOW()
WHERE order_id = $1
`, order.ID, mediaSupplyOrderStatusSubmitted)
return nil
}
func (w *MediaSupplyWorker) refreshOrderCostsBeforeSubmit(ctx context.Context, order *mediaSupplySubmitOrder, items []MediaSupplyOrderItem) error {
if order == nil || len(items) == 0 {
return nil
}
if err := w.refreshSelectedResourcesFromUpstream(ctx, order.ModelID, items); err != nil {
return err
}
// Resource sync jobs keep the catalog fresh. The submit path rechecks the
// persisted supplier cost under a row lock so a manual price override cannot
// slip below the latest known cost.
tx, err := w.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for _, item := range items {
resource, _, _, loadErr := w.service.loadResourceForOrder(ctx, tx, item.ResourceID, order.ModelID, item.PriceType)
if loadErr != nil {
return loadErr
}
currentCost := costForResourcePriceType(resource, item.PriceType)
if currentCost > item.LockedSellPriceCents {
return fmt.Errorf("%w for resource %s", errMediaSupplyLockedSellBelowCost, item.SupplierResourceID)
}
if currentCost > item.LockedCostPriceCents {
if _, err := tx.Exec(ctx, `
UPDATE media_supply_order_items
SET locked_cost_price_cents = $3, updated_at = NOW()
WHERE order_id = $1 AND id = $2
`, order.ID, item.ID, currentCost); err != nil {
return err
}
}
}
_, err = tx.Exec(ctx, `
UPDATE media_supply_orders o
SET cost_total_cents = totals.cost_total,
updated_at = NOW()
FROM (
SELECT order_id, SUM(locked_cost_price_cents) AS cost_total
FROM media_supply_order_items
WHERE order_id = $1
GROUP BY order_id
) totals
WHERE o.id = totals.order_id
`, order.ID)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (w *MediaSupplyWorker) refreshSelectedResourcesFromUpstream(ctx context.Context, modelID int, items []MediaSupplyOrderItem) error {
cfg := w.service.mediaSupplyConfig().Meijiequan
pending := make(map[string]struct{}, len(items))
for _, item := range items {
pending[strings.TrimSpace(item.SupplierResourceID)] = struct{}{}
}
delete(pending, "")
for page := 1; page <= cfg.MaxSyncPages && len(pending) > 0; page++ {
if page > 1 && cfg.SyncPageDelay > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(cfg.SyncPageDelay):
}
}
mediaPage, err := w.service.client.ListMedia(ctx, modelID, page, cfg.SyncPageSize)
if err != nil {
return err
}
if _, err := w.service.UpsertSyncedResources(ctx, mediaPage.Items); err != nil {
return err
}
for _, resource := range mediaPage.Items {
delete(pending, resource.SupplierResourceID)
}
if len(mediaPage.Items) == 0 {
break
}
if mediaPage.AllPage > 0 && page >= mediaPage.AllPage {
break
}
}
if len(pending) > 0 {
return w.validatePendingResourcesFromCache(ctx, modelID, items, pending)
}
return nil
}
func (w *MediaSupplyWorker) validatePendingResourcesFromCache(ctx context.Context, modelID int, items []MediaSupplyOrderItem, pending map[string]struct{}) error {
if len(pending) == 0 {
return nil
}
tx, err := w.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
missing := 0
for _, item := range items {
resourceID := strings.TrimSpace(item.SupplierResourceID)
if _, ok := pending[resourceID]; !ok {
continue
}
resource, _, _, loadErr := w.service.loadResourceForOrder(ctx, tx, item.ResourceID, modelID, item.PriceType)
if loadErr != nil {
missing++
continue
}
currentCost := costForResourcePriceType(resource, item.PriceType)
if currentCost <= 0 {
missing++
continue
}
if currentCost > item.LockedSellPriceCents {
return fmt.Errorf("%w for resource %s", errMediaSupplyLockedSellBelowCost, item.SupplierResourceID)
}
}
if missing > 0 {
return fmt.Errorf("%w for %d selected resources", errMediaSupplySelectedResourcesMissing, missing)
}
return tx.Commit(ctx)
}
func (w *MediaSupplyWorker) processBacklinkSync(ctx context.Context) error {
cfg := w.service.mediaSupplyConfig()
if cfg.Worker.BacklinkSyncInterval <= 0 {
return nil
}
cleared, err := w.clearInvalidBacklinks(ctx)
if err != nil {
return err
}
lockKey := "media_supply_backlink_sync:meijiequan"
tx, err := w.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var locked bool
if err := tx.QueryRow(ctx, `SELECT pg_try_advisory_xact_lock(hashtext($1))`, lockKey).Scan(&locked); err != nil {
return err
}
if !locked {
return nil
}
var due bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM media_supply_orders
WHERE supplier = $1
AND status = $2
AND deleted_at IS NULL
AND (
completed_at IS NULL
OR EXISTS (
SELECT 1
FROM media_supply_order_items item
WHERE item.order_id = media_supply_orders.id
AND NULLIF(TRIM(COALESCE(item.external_article_url, '')), '') IS NULL
)
)
)
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted).Scan(&due); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
if !due && cleared == 0 {
return nil
}
if allowed, err := w.claimBacklinkSyncCooldown(ctx, cfg.Worker.BacklinkSyncInterval); err != nil || !allowed {
return err
}
_, err = w.syncPublishedBacklinksNowUnthrottled(ctx, cfg)
return err
}
func (w *MediaSupplyWorker) claimBacklinkSyncCooldown(ctx context.Context, interval time.Duration) (bool, error) {
if interval <= 0 || w == nil || w.service == nil || w.service.redis == nil {
return true, nil
}
return w.service.redis.SetNX(ctx, mediaSupplyBacklinkSyncCooldownKey, time.Now().UTC().Format(time.RFC3339), interval).Result()
}
func (w *MediaSupplyWorker) clearInvalidBacklinks(ctx context.Context) (int64, error) {
tag, err := w.pool.Exec(ctx, `
UPDATE media_supply_order_items
SET external_article_url = NULL,
updated_at = NOW()
WHERE external_article_url IS NOT NULL
AND (
LOWER(external_article_url) LIKE '%show.meitidaren.top%'
OR LOWER(external_article_url) LIKE '%meijiequan.com%'
OR LOWER(external_article_url) LIKE '%/advsupply/%'
OR LOWER(external_article_url) LIKE '%/supply/%'
OR LOWER(external_article_url) LIKE '%/api/%'
)
`)
return tag.RowsAffected(), err
}
func (w *MediaSupplyWorker) syncPublishedBacklinks(ctx context.Context) error {
cfg := w.service.mediaSupplyConfig()
if allowed, err := w.claimBacklinkSyncCooldown(ctx, cfg.Worker.BacklinkSyncInterval); err != nil {
return err
} else if !allowed {
return nil
}
_, err := w.syncPublishedBacklinksNowUnthrottled(ctx, cfg)
return err
}
func (w *MediaSupplyWorker) syncPublishedBacklinksNowUnthrottled(ctx context.Context, cfg config.MediaSupplyConfig) (*SyncMediaSupplyBacklinksResponse, error) {
result := &SyncMediaSupplyBacklinksResponse{}
if _, err := w.clearInvalidBacklinks(ctx); err != nil {
return result, err
}
userID := strings.TrimSpace(cfg.Meijiequan.UserID)
if status, statusErr := w.service.client.SessionStatus(ctx); statusErr != nil || status == nil || !status.Available {
loginStatus, loginErr := w.service.client.LoginWithConfiguredAccount(ctx)
if loginErr != nil {
return result, loginErr
}
if userID == "" && loginStatus != nil {
userID = strings.TrimSpace(loginStatus.UserID)
}
} else if userID == "" {
userID = strings.TrimSpace(status.UserID)
}
if userID == "" {
return result, errMediaSupplyAccountUserIDMissing
}
allArticles, err := w.loadArticleList(ctx, userID, "all", cfg.Meijiequan.PublishedPageSize, cfg.Meijiequan.PublishedMaxPages, cfg.Meijiequan.SyncPageDelay)
if err != nil {
return result, err
}
pendingArticles := filterMeijiequanPendingArticles(allArticles)
problemArticles := filterMeijiequanProblemArticles(allArticles)
publishedArticles := filterMeijiequanPublishedArticles(allArticles)
result.PendingFetched = len(pendingArticles)
result.ProblemFetched = len(problemArticles)
result.PublishedFetched = len(publishedArticles)
if len(allArticles) > 0 {
orders, err := w.loadOrderCodeSyncCandidates(ctx, cfg.Worker.BacklinkBatchSize)
if err != nil {
return result, err
}
result.OrdersChecked += len(orders)
for _, order := range orders {
updated, err := w.updateOrderCode(ctx, order, allArticles)
if err != nil {
return result, err
}
if updated {
result.OrdersUpdated++
result.OrderCodesUpdated++
}
}
}
if len(problemArticles) > 0 {
orders, err := w.loadRefundSyncCandidates(ctx, cfg.Worker.BacklinkBatchSize)
if err != nil {
return result, err
}
result.OrdersChecked += len(orders)
for _, order := range orders {
refunded, err := w.refundProblemOrder(ctx, order, problemArticles)
if err != nil {
return result, err
}
if refunded {
result.OrdersUpdated++
result.RefundedOrders++
}
}
}
if len(publishedArticles) == 0 {
return result, nil
}
orders, err := w.loadBacklinkSyncCandidates(ctx, cfg.Worker.BacklinkBatchSize)
if err != nil {
return result, err
}
result.OrdersChecked += len(orders)
if len(orders) == 0 {
return result, nil
}
for _, order := range orders {
updatedLinks, err := w.updateOrderBacklinks(ctx, order, publishedArticles)
if err != nil {
return result, err
}
if updatedLinks > 0 {
result.OrdersUpdated++
result.LinksUpdated += updatedLinks
}
}
return result, nil
}
func (w *MediaSupplyWorker) loadPublishedArticles(ctx context.Context, userID string, pageSize, maxPages int, pageDelay time.Duration) ([]MeijiequanPublishedArticle, error) {
return w.loadArticleList(ctx, userID, "published", pageSize, maxPages, pageDelay)
}
func (w *MediaSupplyWorker) loadAllArticles(ctx context.Context, userID string, pageSize, maxPages int, pageDelay time.Duration) ([]MeijiequanPublishedArticle, error) {
return w.loadArticleList(ctx, userID, "all", pageSize, maxPages, pageDelay)
}
func (w *MediaSupplyWorker) loadProblemArticles(ctx context.Context, userID string, pageSize, maxPages int, pageDelay time.Duration) ([]MeijiequanPublishedArticle, error) {
return w.loadArticleList(ctx, userID, "problem", pageSize, maxPages, pageDelay)
}
func (w *MediaSupplyWorker) loadArticleList(ctx context.Context, userID, listType string, pageSize, maxPages int, pageDelay time.Duration) ([]MeijiequanPublishedArticle, error) {
if pageSize <= 0 {
pageSize = 20
}
if maxPages <= 0 {
maxPages = 1
}
out := make([]MeijiequanPublishedArticle, 0, pageSize)
seen := make(map[string]struct{}, pageSize)
for page := 1; page <= maxPages; page++ {
if page > 1 && pageDelay > 0 {
select {
case <-ctx.Done():
return out, ctx.Err()
case <-time.After(pageDelay):
}
}
var result MeijiequanPublishedArticlePage
var err error
switch listType {
case "all":
result, err = w.service.client.ListAllArticles(ctx, userID, page, pageSize)
case "pending":
result, err = w.service.client.ListPendingArticles(ctx, userID, page, pageSize)
case "problem":
result, err = w.service.client.ListProblemArticles(ctx, userID, page, pageSize)
default:
result, err = w.service.client.ListPublishedArticles(ctx, userID, page, pageSize)
}
if err != nil {
return out, err
}
for _, item := range result.Items {
item.ExternalArticleURL = strings.TrimSpace(item.ExternalArticleURL)
key := articleListDedupeKey(item)
if key == "" {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, item)
}
if len(result.Items) == 0 {
break
}
if result.AllPage > 0 && page >= result.AllPage {
break
}
}
return out, nil
}
func articleListDedupeKey(item MeijiequanPublishedArticle) string {
if item.ExternalArticleURL != "" && isLikelyPublishedArticleURL(item.ExternalArticleURL) {
return "url:" + item.ExternalArticleURL
}
if code := strings.TrimSpace(item.OrderCode); code != "" {
return "code:" + strings.ToUpper(code)
}
if id := strings.TrimSpace(item.OrderID); id != "" {
return "id:" + id
}
title := normalizeMeijiequanMatchText(item.Title)
resource := normalizeMeijiequanMatchText(item.ResourceName)
if title != "" || resource != "" {
return "text:" + title + ":" + resource
}
return ""
}
func (w *MediaSupplyWorker) loadOrderCodeSyncCandidates(ctx context.Context, limit int) ([]mediaSupplySubmitOrder, error) {
if limit <= 0 {
limit = 20
}
rows, err := w.pool.Query(ctx, `
SELECT id, tenant_id, user_id, model_id, title, content_snapshot, remark, order_brand,
external_order_id, external_order_code, attempt_count, request_payload_json
FROM media_supply_orders
WHERE supplier = $1
AND status = $2
AND deleted_at IS NULL
AND NOT (`+mediaSupplyValidExternalOrderCodeSQL+`)
ORDER BY submitted_at DESC NULLS LAST, id DESC
LIMIT $3
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted, limit)
if err != nil {
return nil, err
}
defer rows.Close()
orders := make([]mediaSupplySubmitOrder, 0, limit)
for rows.Next() {
var order mediaSupplySubmitOrder
var requestPayload []byte
if err := rows.Scan(
&order.ID,
&order.TenantID,
&order.UserID,
&order.ModelID,
&order.Title,
&order.ContentSnapshot,
&order.Remark,
&order.OrderBrand,
&order.ExternalOrderID,
&order.ExternalOrderCode,
&order.AttemptCount,
&requestPayload,
); err != nil {
return nil, err
}
order.RequestPayload = append(order.RequestPayload[:0], requestPayload...)
orders = append(orders, order)
}
return orders, rows.Err()
}
func (w *MediaSupplyWorker) updateOrderCode(ctx context.Context, order mediaSupplySubmitOrder, pending []MeijiequanPublishedArticle) (bool, error) {
if order.ExternalOrderCode != nil && isLikelyMeijiequanOrderCode(*order.ExternalOrderCode) {
return false, nil
}
items, err := w.service.listOrderItems(ctx, order.ID)
if err != nil {
return false, err
}
article := matchMeijiequanOrderCodeArticle(order, items, pending)
if article == nil || strings.TrimSpace(article.OrderCode) == "" {
return false, nil
}
raw := article.Raw
if len(raw) == 0 {
raw, _ = json.Marshal(article)
}
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
tag, err := w.pool.Exec(ctx, `
UPDATE media_supply_orders
SET external_order_code = COALESCE(NULLIF($2::text, ''), external_order_code),
external_order_id = COALESCE(NULLIF($3::text, ''), external_order_id),
response_payload_json = CASE
WHEN response_payload_json IS NULL OR response_payload_json = '{}'::jsonb THEN $4
ELSE response_payload_json
END,
updated_at = NOW()
WHERE id = $1
AND NOT (`+mediaSupplyValidExternalOrderCodeSQL+`)
`, order.ID, article.OrderCode, article.OrderID, compactJSON(raw))
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
func (w *MediaSupplyWorker) loadBacklinkSyncCandidates(ctx context.Context, limit int) ([]mediaSupplySubmitOrder, error) {
if limit <= 0 {
limit = 20
}
rows, err := w.pool.Query(ctx, `
SELECT id, tenant_id, user_id, model_id, title, content_snapshot, remark, order_brand,
external_order_id, external_order_code, attempt_count, request_payload_json
FROM media_supply_orders
WHERE supplier = $1
AND status = $2
AND deleted_at IS NULL
AND (
completed_at IS NULL
OR EXISTS (
SELECT 1
FROM media_supply_order_items item
WHERE item.order_id = media_supply_orders.id
AND NULLIF(TRIM(COALESCE(item.external_article_url, '')), '') IS NULL
)
)
ORDER BY submitted_at ASC NULLS LAST, id ASC
LIMIT $3
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted, limit)
if err != nil {
return nil, err
}
defer rows.Close()
orders := make([]mediaSupplySubmitOrder, 0, limit)
for rows.Next() {
var order mediaSupplySubmitOrder
var requestPayload []byte
if err := rows.Scan(
&order.ID,
&order.TenantID,
&order.UserID,
&order.ModelID,
&order.Title,
&order.ContentSnapshot,
&order.Remark,
&order.OrderBrand,
&order.ExternalOrderID,
&order.ExternalOrderCode,
&order.AttemptCount,
&requestPayload,
); err != nil {
return nil, err
}
order.RequestPayload = append(order.RequestPayload[:0], requestPayload...)
orders = append(orders, order)
}
return orders, rows.Err()
}
func (w *MediaSupplyWorker) loadRefundSyncCandidates(ctx context.Context, limit int) ([]mediaSupplySubmitOrder, error) {
if limit <= 0 {
limit = 20
}
rows, err := w.pool.Query(ctx, `
SELECT id, tenant_id, user_id, model_id, title, content_snapshot, remark, order_brand,
external_order_id, external_order_code, attempt_count, request_payload_json
FROM media_supply_orders
WHERE supplier = $1
AND status = $2
AND wallet_debited_at IS NOT NULL
AND wallet_refunded_at IS NULL
AND deleted_at IS NULL
ORDER BY submitted_at ASC NULLS LAST, id ASC
LIMIT $3
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted, limit)
if err != nil {
return nil, err
}
defer rows.Close()
orders := make([]mediaSupplySubmitOrder, 0, limit)
for rows.Next() {
var order mediaSupplySubmitOrder
var requestPayload []byte
if err := rows.Scan(
&order.ID,
&order.TenantID,
&order.UserID,
&order.ModelID,
&order.Title,
&order.ContentSnapshot,
&order.Remark,
&order.OrderBrand,
&order.ExternalOrderID,
&order.ExternalOrderCode,
&order.AttemptCount,
&requestPayload,
); err != nil {
return nil, err
}
order.RequestPayload = append(order.RequestPayload[:0], requestPayload...)
orders = append(orders, order)
}
return orders, rows.Err()
}
func (w *MediaSupplyWorker) refundProblemOrder(ctx context.Context, order mediaSupplySubmitOrder, problemArticles []MeijiequanPublishedArticle) (bool, error) {
items, err := w.service.listOrderItems(ctx, order.ID)
if err != nil {
return false, err
}
article := matchMeijiequanProblemArticle(order, items, problemArticles)
if article == nil {
return false, nil
}
raw := article.Raw
if len(raw) == 0 {
raw, _ = json.Marshal(article)
}
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
reason := mediaSupplyProblemArticleReason(*article)
tx, err := w.pool.Begin(ctx)
if err != nil {
return false, err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `
UPDATE media_supply_orders
SET status = $2,
supplier_status = $3,
external_order_code = COALESCE(NULLIF($4::text, ''), external_order_code),
external_order_id = COALESCE(NULLIF($5::text, ''), external_order_id),
response_payload_json = CASE
WHEN response_payload_json IS NULL OR response_payload_json = '{}'::jsonb THEN $6
ELSE response_payload_json
END,
error_message = $7,
next_attempt_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND status = $8
AND wallet_refunded_at IS NULL
`, order.ID, mediaSupplyOrderStatusFailed, "rejected", article.OrderCode, article.OrderID, compactJSON(raw), reason, mediaSupplyOrderStatusSubmitted)
if err != nil {
return false, err
}
if tag.RowsAffected() == 0 {
return false, nil
}
if _, err := tx.Exec(ctx, `
UPDATE media_supply_order_items
SET status = $2, raw_json = $3, updated_at = NOW()
WHERE order_id = $1
`, order.ID, mediaSupplyOrderStatusFailed, compactJSON(raw)); err != nil {
return false, err
}
if err := w.service.refundMediaSupplyWalletForOrder(ctx, tx, order.ID, reason); err != nil {
return false, err
}
return true, tx.Commit(ctx)
}
func (w *MediaSupplyWorker) updateOrderBacklinks(ctx context.Context, order mediaSupplySubmitOrder, published []MeijiequanPublishedArticle) (int, error) {
items, err := w.service.listOrderItems(ctx, order.ID)
if err != nil {
return 0, err
}
if len(items) == 0 {
return 0, nil
}
matches := matchMeijiequanPublishedArticles(order, items, published)
if len(matches) == 0 {
_, err := w.pool.Exec(ctx, `
UPDATE media_supply_orders
SET updated_at = NOW()
WHERE id = $1
`, order.ID)
return 0, err
}
tx, err := w.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
updatedLinks := 0
for itemID, article := range matches {
article.ExternalArticleURL = strings.TrimSpace(article.ExternalArticleURL)
if article.ExternalArticleURL == "" || !isLikelyPublishedArticleURL(article.ExternalArticleURL) {
continue
}
raw := article.Raw
if len(raw) == 0 {
raw, _ = json.Marshal(article)
}
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
if _, err := tx.Exec(ctx, `
UPDATE media_supply_order_items
SET external_article_url = $3,
status = $4,
raw_json = $5,
updated_at = NOW()
WHERE order_id = $1 AND id = $2
`, order.ID, itemID, article.ExternalArticleURL, mediaSupplyOrderStatusSubmitted, compactJSON(raw)); err != nil {
return 0, err
}
updatedLinks++
}
if updatedLinks == 0 {
return 0, nil
}
var remaining int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM media_supply_order_items
WHERE order_id = $1
AND NULLIF(TRIM(COALESCE(external_article_url, '')), '') IS NULL
`, order.ID).Scan(&remaining); err != nil {
return 0, err
}
if _, err := tx.Exec(ctx, `
UPDATE media_supply_orders
SET supplier_status = $2,
completed_at = CASE WHEN $3::int = 0 THEN COALESCE(completed_at, NOW()) ELSE completed_at END,
external_order_code = COALESCE(NULLIF($4::text, ''), external_order_code),
external_order_id = COALESCE(NULLIF($5::text, ''), external_order_id),
updated_at = NOW()
WHERE id = $1
`, order.ID, "published", remaining, firstMatchedMeijiequanOrderCode(matches), firstMatchedMeijiequanOrderID(matches)); err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
w.refreshMonitoringAttributionForMediaSupplyBacklinks(ctx, order.ID)
return updatedLinks, nil
}
func (w *MediaSupplyWorker) refreshMonitoringAttributionForMediaSupplyBacklinks(ctx context.Context, orderID int64) {
if w == nil || w.monitoringPool == nil || w.pool == nil || orderID <= 0 {
return
}
rows, err := w.pool.Query(ctx, `
SELECT
o.tenant_id,
i.id,
i.external_article_url
FROM media_supply_order_items i
JOIN media_supply_orders o ON o.id = i.order_id
WHERE o.id = $1
AND o.deleted_at IS NULL
AND NULLIF(TRIM(COALESCE(i.external_article_url, '')), '') IS NOT NULL
`, orderID)
if err != nil {
w.logWarn("media supply monitoring attribution backlink lookup failed", zap.Int64("order_id", orderID), zap.Error(err))
return
}
defer rows.Close()
type backlinkSource struct {
tenantID int64
itemID int64
url string
}
sources := make([]backlinkSource, 0)
for rows.Next() {
var source backlinkSource
if scanErr := rows.Scan(&source.tenantID, &source.itemID, &source.url); scanErr != nil {
w.logWarn("media supply monitoring attribution backlink scan failed", zap.Int64("order_id", orderID), zap.Error(scanErr))
return
}
source.url = strings.TrimSpace(source.url)
if source.tenantID <= 0 || source.itemID <= 0 || source.url == "" {
continue
}
sources = append(sources, source)
}
if err := rows.Err(); err != nil {
w.logWarn("media supply monitoring attribution backlink iterate failed", zap.Int64("order_id", orderID), zap.Error(err))
return
}
for _, source := range sources {
if _, err := w.refreshMonitoringAttributionForMediaSupplyBacklink(ctx, source.tenantID, source.itemID, source.url); err != nil {
w.logWarn(
"media supply monitoring attribution refresh failed",
zap.Int64("tenant_id", source.tenantID),
zap.Int64("order_id", orderID),
zap.Int64("item_id", source.itemID),
zap.Error(err),
)
}
}
}
func (w *MediaSupplyWorker) refreshMonitoringAttributionForMediaSupplyBacklink(ctx context.Context, tenantID, itemID int64, rawURL string) (int64, error) {
matchKeys := monitoringCitationCandidateKeys(rawURL, normalizeCitationURL(rawURL))
if len(matchKeys) == 0 {
return 0, nil
}
normalizedURL := normalizeCitationURL(rawURL)
host, registrableDomain, _, _ := deriveCitationURLParts(rawURL, normalizedURL)
candidateURLs := uniqueTrimmedStrings(rawURL, normalizedURL)
rows, err := w.monitoringPool.Query(ctx, `
SELECT id, cited_url, normalized_url
FROM monitoring_citation_facts
WHERE tenant_id = $1
AND (
(content_source_type IS NULL AND content_source_id IS NULL)
OR content_source_type = ANY($5::text[])
)
AND (
cited_url = ANY($2::text[])
OR normalized_url = ANY($2::text[])
OR NULLIF(LOWER(TRIM(host)), '') = NULLIF($3, '')
OR NULLIF(LOWER(TRIM(registrable_domain)), '') = NULLIF($4, '')
OR NULLIF(LOWER(TRIM(site_key)), '') = NULLIF($4, '')
)
`, tenantID, candidateURLs, strings.ToLower(strings.TrimSpace(host)), strings.ToLower(strings.TrimSpace(registrableDomain)), []string{
monitoringContentSourcePublishedArticle,
monitoringContentSourceManualMark,
})
if err != nil {
return 0, err
}
defer rows.Close()
matchKeySet := make(map[string]struct{}, len(matchKeys))
for _, key := range matchKeys {
matchKeySet[strings.TrimSpace(key)] = struct{}{}
}
matchedIDs := make([]int64, 0)
for rows.Next() {
var id int64
var citedURL string
var factNormalizedURL string
if scanErr := rows.Scan(&id, &citedURL, &factNormalizedURL); scanErr != nil {
return 0, scanErr
}
for _, factKey := range monitoringCitationCandidateKeys(citedURL, factNormalizedURL) {
if _, ok := matchKeySet[strings.TrimSpace(factKey)]; ok {
matchedIDs = append(matchedIDs, id)
break
}
}
}
if err := rows.Err(); err != nil {
return 0, err
}
if len(matchedIDs) == 0 {
return 0, nil
}
tag, err := w.monitoringPool.Exec(ctx, `
UPDATE monitoring_citation_facts
SET content_source_type = $3,
content_source_id = $2
WHERE tenant_id = $1
AND id = ANY($4::bigint[])
AND (
(content_source_type IS NULL AND content_source_id IS NULL)
OR content_source_type = ANY($5::text[])
)
`, tenantID, itemID, monitoringContentSourceMediaSupplyOrderItem, matchedIDs, []string{
monitoringContentSourcePublishedArticle,
monitoringContentSourceManualMark,
})
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
func uniqueTrimmedStrings(values ...string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, exists := seen[trimmed]; exists {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result
}
func (w *MediaSupplyWorker) claimSyncJob(ctx context.Context) (*mediaSupplySyncJob, bool, error) {
tx, err := w.pool.Begin(ctx)
if err != nil {
return nil, false, err
}
defer tx.Rollback(ctx)
var locked bool
if err := tx.QueryRow(ctx, `SELECT pg_try_advisory_xact_lock(hashtext($1))`, "media_supply_sync:meijiequan").Scan(&locked); err != nil {
return nil, false, err
}
if !locked {
return nil, false, nil
}
var job mediaSupplySyncJob
var modelID *int
if err := tx.QueryRow(ctx, `
SELECT id, model_id, attempt_count
FROM media_supply_sync_jobs
WHERE supplier = $1 AND status = $2
AND NOT EXISTS (
SELECT 1
FROM media_supply_sync_jobs running
WHERE running.supplier = $1
AND running.status = $3
)
ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
`, mediaSupplySupplierMeijiequan, mediaSupplySyncStatusQueued, mediaSupplySyncStatusRunning).Scan(&job.ID, &modelID, &job.AttemptCount); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
return nil, false, err
}
job.ModelID = modelID
if _, err := tx.Exec(ctx, `
UPDATE media_supply_sync_jobs
SET status = $2, started_at = NOW(), attempt_count = attempt_count + 1, updated_at = NOW()
WHERE id = $1
`, job.ID, mediaSupplySyncStatusRunning); err != nil {
return nil, false, err
}
if err := tx.Commit(ctx); err != nil {
return nil, false, err
}
return &job, true, nil
}
func (w *MediaSupplyWorker) claimSubmitOrder(ctx context.Context) (*mediaSupplySubmitOrder, bool, error) {
tx, err := w.pool.Begin(ctx)
if err != nil {
return nil, false, err
}
defer tx.Rollback(ctx)
var order mediaSupplySubmitOrder
var requestPayload []byte
if err := tx.QueryRow(ctx, `
SELECT id, tenant_id, brand_id, user_id, model_id, title, content_snapshot, remark, order_brand, attempt_count, request_payload_json
FROM media_supply_orders
WHERE supplier = $1 AND status = $2 AND next_attempt_at <= NOW() AND deleted_at IS NULL
ORDER BY queued_at ASC, id ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusQueued).Scan(
&order.ID,
&order.TenantID,
&order.BrandID,
&order.UserID,
&order.ModelID,
&order.Title,
&order.ContentSnapshot,
&order.Remark,
&order.OrderBrand,
&order.AttemptCount,
&requestPayload,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
return nil, false, err
}
order.RequestPayload = append(order.RequestPayload[:0], requestPayload...)
if _, err := tx.Exec(ctx, `
UPDATE media_supply_orders
SET status = $2, attempt_count = attempt_count + 1, updated_at = NOW()
WHERE id = $1
`, order.ID, mediaSupplyOrderStatusSubmitting); err != nil {
return nil, false, err
}
if err := tx.Commit(ctx); err != nil {
return nil, false, err
}
return &order, true, nil
}
func (w *MediaSupplyWorker) loadSubmitOrderItems(ctx context.Context, orderID int64) ([]MediaSupplyOrderItem, error) {
return w.service.listOrderItems(ctx, orderID)
}
func (w *MediaSupplyWorker) failSyncJob(ctx context.Context, job *mediaSupplySyncJob, cause error) error {
if job == nil {
return cause
}
cfg := w.service.mediaSupplyConfig()
status := mediaSupplySyncStatusFailed
if job.AttemptCount+1 < cfg.Worker.SyncMaxAttempts {
status = mediaSupplySyncStatusQueued
}
_, err := w.pool.Exec(ctx, `
UPDATE media_supply_sync_jobs
SET status = $2::varchar,
error_message = $3,
finished_at = CASE WHEN $2::varchar = $4::varchar THEN NOW() ELSE finished_at END,
updated_at = NOW()
WHERE id = $1
`, job.ID, status, truncateText(mediaSupplyPublicErrorMessage(cause)), mediaSupplySyncStatusFailed)
if err != nil {
return err
}
return cause
}
func (w *MediaSupplyWorker) failOrder(ctx context.Context, order *mediaSupplySubmitOrder, cause error) error {
if order == nil {
return cause
}
cfg := w.service.mediaSupplyConfig()
status := mediaSupplyOrderStatusFailed
nextAttemptAt := time.Now().UTC()
if order.AttemptCount+1 < cfg.Worker.OrderMaxAttempts && retryableMediaSupplyError(cause) {
status = mediaSupplyOrderStatusQueued
nextAttemptAt = nextAttemptAt.Add(time.Duration(order.AttemptCount+1) * time.Minute)
}
publicMessage := truncateText(mediaSupplyPublicErrorMessage(cause))
tx, err := w.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
UPDATE media_supply_orders
SET status = $2, error_message = $3, next_attempt_at = $4, updated_at = NOW()
WHERE id = $1
`, order.ID, status, publicMessage, nextAttemptAt)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `
UPDATE media_supply_order_items
SET status = $2, updated_at = NOW()
WHERE order_id = $1 AND status IN ($3::varchar, $4::varchar)
`, order.ID, status, mediaSupplyOrderStatusQueued, mediaSupplyOrderStatusSubmitting); err != nil {
return err
}
if status == mediaSupplyOrderStatusFailed {
if err := w.service.refundMediaSupplyWalletForOrder(ctx, tx, order.ID, publicMessage); err != nil {
return err
}
}
if err := tx.Commit(ctx); err != nil {
return err
}
return cause
}
func retryableMediaSupplyError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, errMeijiequanSessionMissing) || errors.Is(err, errMeijiequanSessionExpired) || errors.Is(err, errMeijiequanAuthRequired) {
return false
}
if errors.Is(err, errMeijiequanOrderRejected) ||
errors.Is(err, errMediaSupplySelectedResourcesMissing) ||
errors.Is(err, errMediaSupplyLockedSellBelowCost) {
return false
}
return true
}
func formatMeijiequanResourceID(item MediaSupplyOrderItem) string {
priceType := normalizeMediaSupplyPriceType(item.PriceType)
switch priceType {
case "price":
return fmt.Sprintf("%s_price_0", item.SupplierResourceID)
default:
return fmt.Sprintf("%s_%s_0", item.SupplierResourceID, priceType)
}
}
func mediaSupplySubmitOrderExtra(raw json.RawMessage) map[string]any {
if len(raw) == 0 {
return map[string]any{}
}
var payload map[string]any
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
if err := decoder.Decode(&payload); err != nil {
return map[string]any{}
}
extra := make(map[string]any)
if nested, ok := payload["extra"].(map[string]any); ok {
for key, value := range nested {
extra[key] = value
}
}
for _, key := range []string{
"source",
"laiyuan",
"description",
"miaoshu",
"promotion",
"tuiguang",
"propaganda",
"xuanchuan",
"news_link",
"xinwenlianjie",
"business_license",
"yingyezhizhao",
"enterprise_logo",
"qiyelogo",
"coupon",
"youhuiquan",
"reference_link",
"cankaolianjie",
"daily_price",
"richangjia",
"live_price",
"zhibojia",
"stock",
"kucunliang",
"cover",
"fengmian",
"original_link",
"yuanwenlianjie",
"brand_id",
"extra_field_1",
"extra_field_2",
"order_brand",
"batch_resource_ids",
"point_time",
"point_price_range",
"limit_time_end",
"resource_spare_arr",
"resource_spare_ids",
"prop",
} {
if value, ok := payload[key]; ok {
extra[key] = value
}
}
if extra == nil {
return map[string]any{}
}
return extra
}
func matchMeijiequanPublishedArticles(order mediaSupplySubmitOrder, items []MediaSupplyOrderItem, published []MeijiequanPublishedArticle) map[int64]MeijiequanPublishedArticle {
matches := make(map[int64]MeijiequanPublishedArticle)
if len(items) == 0 || len(published) == 0 {
return matches
}
orderTitle := normalizeMeijiequanMatchText(order.Title)
orderCode := mediaSupplyOrderCodeValue(order)
unmatched := make(map[int64]MediaSupplyOrderItem, len(items))
for _, item := range items {
if item.ExternalArticleURL != nil && isLikelyPublishedArticleURL(strings.TrimSpace(*item.ExternalArticleURL)) {
continue
}
unmatched[item.ID] = item
}
for _, article := range published {
if len(unmatched) == 0 {
break
}
article.ExternalArticleURL = strings.TrimSpace(article.ExternalArticleURL)
if article.ExternalArticleURL == "" || !isLikelyPublishedArticleURL(article.ExternalArticleURL) {
continue
}
if orderCode != "" && strings.EqualFold(strings.TrimSpace(article.OrderCode), orderCode) && len(unmatched) == 1 {
for itemID := range unmatched {
matches[itemID] = article
delete(unmatched, itemID)
break
}
continue
}
itemID := matchMeijiequanPublishedArticleItem(orderTitle, orderCode, unmatched, article)
if itemID <= 0 {
continue
}
matches[itemID] = article
delete(unmatched, itemID)
}
return matches
}
func matchMeijiequanOrderCodeArticle(order mediaSupplySubmitOrder, items []MediaSupplyOrderItem, articles []MeijiequanPublishedArticle) *MeijiequanPublishedArticle {
if len(articles) == 0 {
return nil
}
if orderCode := mediaSupplyOrderCodeValue(order); orderCode != "" {
for idx := range articles {
if strings.EqualFold(strings.TrimSpace(articles[idx].OrderCode), orderCode) {
return &articles[idx]
}
}
return nil
}
orderTitle := normalizeMeijiequanMatchText(order.Title)
for idx := range articles {
article := &articles[idx]
if strings.TrimSpace(article.OrderCode) == "" {
continue
}
if articleMatchesOrderFallback(orderTitle, *article, items) {
return article
}
}
return nil
}
func matchMeijiequanProblemArticle(order mediaSupplySubmitOrder, items []MediaSupplyOrderItem, articles []MeijiequanPublishedArticle) *MeijiequanPublishedArticle {
if len(articles) == 0 {
return nil
}
if orderCode := mediaSupplyOrderCodeValue(order); orderCode != "" {
for idx := range articles {
article := &articles[idx]
if !isMeijiequanProblemArticle(*article) {
continue
}
if strings.EqualFold(strings.TrimSpace(article.OrderCode), orderCode) {
return article
}
}
return nil
}
orderTitle := normalizeMeijiequanMatchText(order.Title)
for idx := range articles {
article := &articles[idx]
if !isMeijiequanProblemArticle(*article) {
continue
}
if articleMatchesOrderFallback(orderTitle, *article, items) {
return article
}
}
return nil
}
func mediaSupplyOrderCodeValue(order mediaSupplySubmitOrder) string {
if order.ExternalOrderCode != nil {
if value := strings.TrimSpace(*order.ExternalOrderCode); isLikelyMeijiequanOrderCode(value) {
return value
}
}
if order.ExternalOrderID != nil {
if value := strings.TrimSpace(*order.ExternalOrderID); isLikelyMeijiequanOrderCode(value) {
return value
}
}
return ""
}
func filterMeijiequanPendingArticles(articles []MeijiequanPublishedArticle) []MeijiequanPublishedArticle {
out := make([]MeijiequanPublishedArticle, 0, len(articles))
for _, article := range articles {
if !isMeijiequanPublishedArticle(article) && !isMeijiequanProblemArticle(article) {
out = append(out, article)
}
}
return out
}
func filterMeijiequanPublishedArticles(articles []MeijiequanPublishedArticle) []MeijiequanPublishedArticle {
out := make([]MeijiequanPublishedArticle, 0, len(articles))
for _, article := range articles {
if isMeijiequanPublishedArticle(article) {
out = append(out, article)
}
}
return out
}
func filterMeijiequanProblemArticles(articles []MeijiequanPublishedArticle) []MeijiequanPublishedArticle {
out := make([]MeijiequanPublishedArticle, 0, len(articles))
for _, article := range articles {
if isMeijiequanProblemArticle(article) {
out = append(out, article)
}
}
return out
}
func isMeijiequanPublishedArticle(article MeijiequanPublishedArticle) bool {
status := strings.TrimSpace(article.Status)
return strings.Contains(status, "已发布") ||
strings.Contains(status, "已发表") ||
strings.Contains(status, "发布成功") ||
(article.ExternalArticleURL != "" && isLikelyPublishedArticleURL(article.ExternalArticleURL))
}
func isMeijiequanProblemArticle(article MeijiequanPublishedArticle) bool {
status := strings.TrimSpace(article.Status)
return strings.Contains(status, "退稿") ||
strings.Contains(status, "失败") ||
strings.Contains(status, "驳回") ||
strings.Contains(status, "拒") ||
strings.TrimSpace(article.RejectReason) != ""
}
func mediaSupplyProblemArticleReason(article MeijiequanPublishedArticle) string {
reason := strings.TrimSpace(article.RejectReason)
if reason == "" {
reason = strings.TrimSpace(article.Status)
}
if reason == "" {
reason = "投稿未通过"
}
return "退稿原因:" + reason
}
func articleMatchesAnyOrderItem(article MeijiequanPublishedArticle, items []MediaSupplyOrderItem) bool {
if len(items) == 0 {
return true
}
resourceID := normalizeMeijiequanPublishedResourceID(article.ResourceID)
resourceName := normalizeMeijiequanMatchText(article.ResourceName)
for _, item := range items {
if resourceID != "" && strings.EqualFold(strings.TrimSpace(item.SupplierResourceID), resourceID) {
return true
}
itemName := normalizeMeijiequanMatchText(item.ResourceNameSnapshot)
if resourceName != "" && itemName != "" && (strings.Contains(itemName, resourceName) || strings.Contains(resourceName, itemName)) {
return true
}
}
return false
}
func articleMatchesOrderFallback(orderTitle string, article MeijiequanPublishedArticle, items []MediaSupplyOrderItem) bool {
if !articleTitleMatchesOrder(orderTitle, article) {
return false
}
if len(items) == 0 {
return false
}
for _, item := range items {
if articleMatchesOrderItemFallback(article, item) {
return true
}
}
return false
}
func articleTitleMatchesOrder(orderTitle string, article MeijiequanPublishedArticle) bool {
articleTitle := normalizeMeijiequanMatchText(article.Title)
return orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle))
}
func articleMatchesOrderItemFallback(article MeijiequanPublishedArticle, item MediaSupplyOrderItem) bool {
return articleMatchesOrderItemResource(article, item) && articleMatchesOrderItemPrice(article, item)
}
func articleMatchesOrderItemResource(article MeijiequanPublishedArticle, item MediaSupplyOrderItem) bool {
resourceID := normalizeMeijiequanPublishedResourceID(article.ResourceID)
if resourceID != "" && strings.EqualFold(strings.TrimSpace(item.SupplierResourceID), resourceID) {
return true
}
resourceName := normalizeMeijiequanMatchText(article.ResourceName)
itemName := normalizeMeijiequanMatchText(item.ResourceNameSnapshot)
return resourceName != "" && itemName != "" && (strings.Contains(itemName, resourceName) || strings.Contains(resourceName, itemName))
}
func articleMatchesOrderItemPrice(article MeijiequanPublishedArticle, item MediaSupplyOrderItem) bool {
if article.PriceCents <= 0 {
return false
}
return article.PriceCents == item.LockedCostPriceCents
}
func matchMeijiequanPublishedArticleItem(orderTitle, orderCode string, items map[int64]MediaSupplyOrderItem, article MeijiequanPublishedArticle) int64 {
if orderCode != "" {
if !strings.EqualFold(strings.TrimSpace(article.OrderCode), orderCode) {
return 0
}
for itemID := range items {
return itemID
}
}
if articleTitleMatchesOrder(orderTitle, article) {
if len(items) == 1 {
for itemID, item := range items {
if articleMatchesOrderItemFallback(article, item) {
return itemID
}
}
}
for itemID, item := range items {
if articleMatchesOrderItemFallback(article, item) {
return itemID
}
}
}
return 0
}
func normalizeMeijiequanMatchText(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "\t", "")
value = strings.ReplaceAll(value, "\n", "")
value = strings.ReplaceAll(value, "\r", "")
return value
}
func firstMatchedMeijiequanOrderCode(matches map[int64]MeijiequanPublishedArticle) string {
for _, article := range matches {
if text := strings.TrimSpace(article.OrderCode); text != "" {
return text
}
}
return ""
}
func firstMatchedMeijiequanOrderID(matches map[int64]MeijiequanPublishedArticle) string {
for _, article := range matches {
if text := strings.TrimSpace(article.OrderID); text != "" {
return text
}
}
return ""
}
func pgInterval(value time.Duration) string {
if value <= 0 {
return "0 seconds"
}
return fmt.Sprintf("%f seconds", value.Seconds())
}
func mediaSupplyCreateOrderStatus(resp *MeijiequanCreateOrderResponse) int {
if resp == nil {
return 0
}
return resp.Status
}
func mediaSupplyCreateOrderInfo(resp *MeijiequanCreateOrderResponse) string {
if resp == nil {
return ""
}
return resp.Info
}
func extractMeijiequanOrderIDs(resp *MeijiequanCreateOrderResponse) (*string, *string) {
if resp == nil {
return nil, nil
}
id, code := extractMeijiequanOrderIDValue(resp.Data)
if id == "" && code == "" {
id, code = extractMeijiequanOrderIDValue(resp.Raw)
}
if code == "" {
code = firstMeijiequanOrderCode(resp.Info)
}
return optionalTextPtr(id), optionalTextPtr(code)
}
func extractMeijiequanOrderIDValue(raw json.RawMessage) (string, string) {
if len(raw) == 0 {
return "", ""
}
var value any
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.UseNumber()
if err := decoder.Decode(&value); err != nil {
return "", firstMeijiequanOrderCode(string(raw))
}
id, code := extractMeijiequanOrderIDFromValue(value)
if code == "" {
code = firstMeijiequanOrderCode(string(raw))
}
return id, code
}
func extractMeijiequanOrderIDFromValue(value any) (string, string) {
switch typed := value.(type) {
case map[string]any:
id := strings.TrimSpace(meijiequanAnyString(firstMapValue(typed,
"id", "order_id", "article_id", "article_order_id",
)))
code := strings.TrimSpace(meijiequanAnyString(firstMapValue(typed,
"order_code", "code", "sn", "order_sn", "order_no", "orderno", "danhao", "dingdanhao", "no",
)))
if code != "" && !isLikelyMeijiequanOrderCode(code) {
if extracted := firstMeijiequanOrderCode(code); extracted != "" {
code = extracted
} else {
code = ""
}
}
for _, key := range []string{"data", "result", "order", "article", "info", "message", "msg"} {
nestedID, nestedCode := extractMeijiequanOrderIDFromValue(typed[key])
if id == "" {
id = nestedID
}
if code == "" {
code = nestedCode
}
if id != "" && code != "" {
return id, code
}
}
if code == "" {
for _, value := range typed {
if text := firstMeijiequanOrderCode(meijiequanAnyString(value)); text != "" {
code = text
break
}
}
}
return id, code
case []any:
for _, item := range typed {
if id, code := extractMeijiequanOrderIDFromValue(item); id != "" || code != "" {
return id, code
}
}
case string:
return "", firstMeijiequanOrderCode(typed)
}
return "", ""
}
func firstMeijiequanOrderCode(value string) string {
return strings.ToUpper(strings.TrimSpace(mediaSupplyOrderCodePattern.FindString(value)))
}
func optionalTextPtr(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
func mediaSupplyStringPtrValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func truncateError(err error) string {
if err == nil {
return ""
}
return truncateText(err.Error())
}
func truncateText(value string) string {
if len(value) > 2000 {
return value[:2000]
}
return value
}
func (w *MediaSupplyWorker) logInfo(message string, fields ...zap.Field) {
if w != nil && w.logger != nil {
w.logger.Info(message, fields...)
}
}
func (w *MediaSupplyWorker) logWarn(message string, fields ...zap.Field) {
if w != nil && w.logger != nil {
w.logger.Warn(message, fields...)
}
}