feat(media-supply): resource cache sync and order flow refinements
Frontend CI / Frontend (push) Successful in 3m38s
Backend CI / Backend (push) Failing after 26m59s

Add a resource cache sync entrypoint (RunMediaResourceSyncOnce) driven
by the new scheduler job, and rework the backlink/sync worker around it.
Tune the meijiequan client and slow the default backlink sync interval
from 10m to 30m to reduce upstream pressure.

Trim unused public/transport surface (handler + router + swagger) and
simplify admin-web order management and ops-web media-supply views,
dropping stale shared-types fields.
This commit is contained in:
2026-06-02 14:50:36 +08:00
parent 842782b3dd
commit 723c3ffb86
14 changed files with 529 additions and 208 deletions
-7
View File
@@ -128,7 +128,6 @@ import type {
ScheduleTaskListResponse, ScheduleTaskListResponse,
ScheduleTaskRequest, ScheduleTaskRequest,
ScheduleTaskStatusRequest, ScheduleTaskStatusRequest,
SyncMediaSupplyBacklinksResponse,
TemplateAnalyzeTaskRequest, TemplateAnalyzeTaskRequest,
TemplateAnalyzeTaskResultResponse, TemplateAnalyzeTaskResultResponse,
TemplateAssistTaskCreateResponse, TemplateAssistTaskCreateResponse,
@@ -1257,12 +1256,6 @@ export const mediaSupplyApi = {
getOrder(id: number) { getOrder(id: number) {
return apiClient.get<MediaSupplyOrderDetail>(`/api/tenant/media-supply/orders/${id}`) return apiClient.get<MediaSupplyOrderDetail>(`/api/tenant/media-supply/orders/${id}`)
}, },
syncBacklinks() {
return apiClient.post<SyncMediaSupplyBacklinksResponse, Record<string, never>>(
'/api/tenant/media-supply/orders/sync-backlinks',
{},
)
},
wallet() { wallet() {
return apiClient.get<MediaSupplyWalletStatus>('/api/tenant/media-supply/wallet') return apiClient.get<MediaSupplyWalletStatus>('/api/tenant/media-supply/wallet')
}, },
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons-vue' import { CopyOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import type { MediaSupplyOrderDetail } from '@geo/shared-types' import type { MediaSupplyOrderDetail } from '@geo/shared-types'
import { useMutation, useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import { message, type TableColumnsType } from 'ant-design-vue' import { message, type TableColumnsType } from 'ant-design-vue'
import { computed, ref } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { mediaSupplyApi } from '@/lib/api' import { mediaSupplyApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display' import { formatDateTime } from '@/lib/display'
@@ -19,11 +19,14 @@ function copyToClipboard(text?: string | null): void {
message.warning('链接为空,无法复制') message.warning('链接为空,无法复制')
return return
} }
void navigator.clipboard.writeText(url).then(() => { void navigator.clipboard
message.success('已复制到剪贴板') .writeText(url)
}).catch(() => { .then(() => {
message.error('复制失败,请手动复制') message.success('复制到剪贴板')
}) })
.catch(() => {
message.error('复制失败,请手动复制')
})
} }
const ordersQuery = useQuery({ const ordersQuery = useQuery({
@@ -36,35 +39,6 @@ const ordersQuery = useQuery({
}), }),
}) })
const syncBacklinksMutation = useMutation({
mutationFn: () => mediaSupplyApi.syncBacklinks(),
onSuccess: async (result) => {
await ordersQuery.refetch()
if (result.refunded_orders > 0) {
message.warning(`已同步 ${result.refunded_orders} 个退稿并退款`)
return
}
if (result.links_updated > 0) {
message.success(`已同步 ${result.links_updated} 条回链`)
return
}
if (result.order_codes_updated > 0) {
message.success(`已同步 ${result.order_codes_updated} 个外部单号`)
return
}
message.info(
result.published_fetched > 0
? '已检查已发表记录,暂未发现新的回链'
: result.problem_fetched > 0
? '已检查退稿记录,暂未发现需要退款的订单'
: result.pending_fetched > 0
? '已检查发表中记录,暂未发现新的外部单号'
: '暂未读取到投稿记录',
)
},
onError: () => message.error('同步回链失败,请稍后重试'),
})
const orders = computed(() => ordersQuery.data.value?.items ?? []) const orders = computed(() => ordersQuery.data.value?.items ?? [])
const total = computed(() => ordersQuery.data.value?.total ?? 0) const total = computed(() => ordersQuery.data.value?.total ?? 0)
@@ -90,10 +64,6 @@ function refresh(): void {
void ordersQuery.refetch() void ordersQuery.refetch()
} }
function syncBacklinks(): void {
void syncBacklinksMutation.mutateAsync()
}
function handleTableChange(nextPage: number, nextPageSize: number): void { function handleTableChange(nextPage: number, nextPageSize: number): void {
page.value = nextPage page.value = nextPage
pageSize.value = nextPageSize pageSize.value = nextPageSize
@@ -119,7 +89,7 @@ function orderStatusMeta(order: MediaSupplyOrderDetail): { label: string; color:
? { label: '已发表', color: 'success' } ? { label: '已发表', color: 'success' }
: { label: '发表中', color: 'processing' } : { label: '发表中', color: 'processing' }
case 'failed': case 'failed':
return order.wallet_refunded_at return order.wallet_refunded_at && order.supplier_status === 'rejected'
? { label: '已退稿', color: 'default' } ? { label: '已退稿', color: 'default' }
: { label: '投稿失败', color: 'error' } : { label: '投稿失败', color: 'error' }
default: default:
@@ -149,6 +119,52 @@ function pendingBacklinkCount(order: MediaSupplyOrderDetail): number {
return (order.items ?? []).filter((item) => !item.external_article_url).length return (order.items ?? []).filter((item) => !item.external_article_url).length
} }
const shouldPollOrders = computed(() =>
orders.value.some(
(order) =>
order.status === 'queued' ||
order.status === 'submitting' ||
(order.status === 'submitted' && pendingBacklinkCount(order) > 0),
),
)
let pollingTimer: ReturnType<typeof setInterval> | undefined
function stopOrderPolling(): void {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = undefined
}
}
function startOrderPolling(): void {
if (pollingTimer) {
return
}
pollingTimer = setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return
}
if (!ordersQuery.isFetching.value) {
void ordersQuery.refetch()
}
}, 10_000)
}
watch(
shouldPollOrders,
(enabled) => {
if (enabled) {
startOrderPolling()
} else {
stopOrderPolling()
}
},
{ immediate: true },
)
onBeforeUnmount(stopOrderPolling)
function mediaNames(order: MediaSupplyOrderDetail): string[] { function mediaNames(order: MediaSupplyOrderDetail): string[] {
const seen = new Set<string>() const seen = new Set<string>()
const names: string[] = [] const names: string[] = []
@@ -233,15 +249,6 @@ function formatOrderError(message?: string | null): string {
<ReloadOutlined :class="{ 'refresh-spinning': ordersQuery.isFetching.value }" /> <ReloadOutlined :class="{ 'refresh-spinning': ordersQuery.isFetching.value }" />
刷新 刷新
</button> </button>
<button
type="button"
class="toolbar-btn-sync"
:disabled="syncBacklinksMutation.isPending.value"
@click="syncBacklinks"
>
<ReloadOutlined v-if="syncBacklinksMutation.isPending.value" class="refresh-spinning" />
同步状态/回链
</button>
</div> </div>
</section> </section>
@@ -320,7 +327,7 @@ function formatOrderError(message?: string | null): string {
<span v-else>{{ backlinkSummary(record) }}</span> <span v-else>{{ backlinkSummary(record) }}</span>
</template> </template>
<template v-else-if="column.key === 'external_order_code'"> <template v-else-if="column.key === 'external_order_code'">
{{ record.external_order_code || record.external_order_id || '--' }} {{ record.external_order_code || '--' }}
</template> </template>
<template v-else-if="column.key === 'created_at'"> <template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }} {{ formatDateTime(record.created_at) }}
+6 -2
View File
@@ -87,8 +87,12 @@ export const opsMediaSupplyApi = {
) )
}, },
queueSync(modelId = 1) { queueSync(modelId = 1) {
return http.post<{ job_id: number }, { model_id: number }>('/media-supply/sync-jobs', { return http.post<
model_id: modelId, { id: number; job_key: string; status: string },
{ reason: string; config_override: { model_id: number } }
>('/scheduler/jobs/media_supply_cache_sync/run', {
reason: 'ops 手动同步媒体资源缓存',
config_override: { model_id: modelId },
}) })
}, },
listWallets(params: OpsMediaSupplyWalletsParams) { listWallets(params: OpsMediaSupplyWalletsParams) {
@@ -461,7 +461,7 @@ async function queueSync(): Promise<void> {
syncLoading.value = true syncLoading.value = true
try { try {
await opsMediaSupplyApi.queueSync(MODEL_ID_AUTHORITY_NEWS) await opsMediaSupplyApi.queueSync(MODEL_ID_AUTHORITY_NEWS)
message.success('同步任务已提交') message.success('调度任务已提交')
} catch (error) { } catch (error) {
showOpsError(error) showOpsError(error)
} finally { } finally {
-12
View File
@@ -652,7 +652,6 @@ export interface MediaSupplyOrderDetail {
title: string title: string
remark?: string | null remark?: string | null
order_brand?: string | null order_brand?: string | null
external_order_id?: string | null
external_order_code?: string | null external_order_code?: string | null
supplier_status?: string | null supplier_status?: string | null
cost_total_cents: number cost_total_cents: number
@@ -684,17 +683,6 @@ export interface ListMediaSupplyOrdersResponse {
page_size: number page_size: number
} }
export interface SyncMediaSupplyBacklinksResponse {
published_fetched: number
pending_fetched: number
problem_fetched: number
orders_checked: number
orders_updated: number
order_codes_updated: number
refunded_orders: number
links_updated: number
}
export interface MediaSupplyWalletStatus { export interface MediaSupplyWalletStatus {
tenant_id: number tenant_id: number
user_id: number user_id: number
+1 -1
View File
@@ -844,7 +844,7 @@ func NormalizeMediaSupplyConfig(cfg *MediaSupplyConfig) {
cfg.Worker.SyncMaxAttempts = 2 cfg.Worker.SyncMaxAttempts = 2
} }
if cfg.Worker.BacklinkSyncInterval <= 0 { if cfg.Worker.BacklinkSyncInterval <= 0 {
cfg.Worker.BacklinkSyncInterval = 10 * time.Minute cfg.Worker.BacklinkSyncInterval = 30 * time.Minute
} }
if cfg.Worker.BacklinkBatchSize <= 0 { if cfg.Worker.BacklinkBatchSize <= 0 {
cfg.Worker.BacklinkBatchSize = 20 cfg.Worker.BacklinkBatchSize = 20
@@ -214,7 +214,6 @@ var routeDocs = map[string]routeDoc{
"POST /api/tenant/media-supply/orders": {"创建媒介投放订单", "为选定媒介资源创建投放订单。"}, "POST /api/tenant/media-supply/orders": {"创建媒介投放订单", "为选定媒介资源创建投放订单。"},
"GET /api/tenant/media-supply/orders": {"媒介订单列表", "分页查询当前 Workspace 的媒介投放订单。"}, "GET /api/tenant/media-supply/orders": {"媒介订单列表", "分页查询当前 Workspace 的媒介投放订单。"},
"GET /api/tenant/media-supply/orders/:id": {"媒介订单详情", "读取指定媒介投放订单详情。"}, "GET /api/tenant/media-supply/orders/:id": {"媒介订单详情", "读取指定媒介投放订单详情。"},
"POST /api/tenant/media-supply/orders/sync-backlinks": {"同步媒介订单链接", "从供应商侧同步媒介订单的发布链接。"},
"GET /api/tenant/media-supply/wallet": {"媒介钱包状态", "读取当前 Workspace 的媒介供应钱包余额与状态。"}, "GET /api/tenant/media-supply/wallet": {"媒介钱包状态", "读取当前 Workspace 的媒介供应钱包余额与状态。"},
"GET /api/tenant/media-supply/wallet/ledgers": {"媒介钱包流水", "分页查询媒介供应钱包收支流水。"}, "GET /api/tenant/media-supply/wallet/ledgers": {"媒介钱包流水", "分页查询媒介供应钱包收支流水。"},
"POST /api/tenant/media-supply/wallet/adjustments": {"调整媒介钱包", "创建媒介钱包余额调整记录。"}, "POST /api/tenant/media-supply/wallet/adjustments": {"调整媒介钱包", "创建媒介钱包余额调整记录。"},
@@ -45,7 +45,7 @@ func mediaSupplyPublicErrorMessage(err error) string {
case errors.Is(err, errMeijiequanOrderRejected): case errors.Is(err, errMeijiequanOrderRejected):
return "媒体供应商拒绝投稿,请检查文章内容或联系管理员处理" return "媒体供应商拒绝投稿,请检查文章内容或联系管理员处理"
case errors.Is(err, errMediaSupplySelectedResourcesMissing): case errors.Is(err, errMediaSupplySelectedResourcesMissing):
return "媒体资源价格刷新失败,请稍后重试或重新选择媒体" return "媒体资源暂不可用或已下架,请重新选择媒体"
case errors.Is(err, errMediaSupplyLockedSellBelowCost): case errors.Is(err, errMediaSupplyLockedSellBelowCost):
return "媒体成本价已变化,当前锁定价格低于成本,请重新提交" return "媒体成本价已变化,当前锁定价格低于成本,请重新提交"
case errors.Is(err, errMediaSupplyAccountUserIDMissing): case errors.Is(err, errMediaSupplyAccountUserIDMissing):
@@ -63,7 +63,7 @@ func mediaSupplyPublicGenericErrorMessage(message string) string {
lower := strings.ToLower(message) lower := strings.ToLower(message)
switch { switch {
case strings.Contains(lower, "failed to refresh supplier price"): case strings.Contains(lower, "failed to refresh supplier price"):
return "媒体资源价格刷新失败,请稍后重试或重新选择媒体" return "媒体资源暂不可用或已下架,请重新选择媒体"
case strings.Contains(lower, "supplier cost increased above locked sell price"): case strings.Contains(lower, "supplier cost increased above locked sell price"):
return "媒体成本价已变化,当前锁定价格低于成本,请重新提交" return "媒体成本价已变化,当前锁定价格低于成本,请重新提交"
case strings.Contains(lower, "username") || strings.Contains(lower, "password") || strings.Contains(lower, "credentials"): case strings.Contains(lower, "username") || strings.Contains(lower, "password") || strings.Contains(lower, "credentials"):
@@ -22,6 +22,7 @@ import (
type MediaSupplyService struct { type MediaSupplyService struct {
pool *pgxpool.Pool pool *pgxpool.Pool
redis *goredis.Client
logger *zap.Logger logger *zap.Logger
cfg config.Provider cfg config.Provider
client *MeijiequanClient client *MeijiequanClient
@@ -35,6 +36,7 @@ func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *za
client := NewMeijiequanClient(redis, current.MediaSupply.Meijiequan) client := NewMeijiequanClient(redis, current.MediaSupply.Meijiequan)
return &MediaSupplyService{ return &MediaSupplyService{
pool: pool, pool: pool,
redis: redis,
logger: logger, logger: logger,
cfg: cfg, cfg: cfg,
client: client, client: client,
@@ -403,19 +405,6 @@ func (s *MediaSupplyService) SessionStatus(ctx context.Context) (*MeijiequanSess
return status, nil return status, nil
} }
func (s *MediaSupplyService) SyncBacklinks(ctx context.Context) (*SyncMediaSupplyBacklinksResponse, error) {
if !isTenantAdmin(auth.MustActor(ctx)) {
return nil, response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限")
}
_ = s.mediaSupplyConfig()
worker := NewMediaSupplyWorker(s, s.pool, s.logger)
result, err := worker.syncPublishedBacklinksNow(ctx)
if err != nil {
return nil, mediaSupplyClientError(err)
}
return result, nil
}
func (s *MediaSupplyService) SetResourcePrice(ctx context.Context, resourceID int64, req SetSupplierMediaPriceRequest) error { func (s *MediaSupplyService) SetResourcePrice(ctx context.Context, resourceID int64, req SetSupplierMediaPriceRequest) error {
if actor, ok := auth.ActorFromCtx(ctx); ok && !isTenantAdmin(actor) { if actor, ok := auth.ActorFromCtx(ctx); ok && !isTenantAdmin(actor) {
return response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限") return response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限")
@@ -982,18 +971,34 @@ func (s *MediaSupplyService) GetOrder(ctx context.Context, orderID int64) (*Medi
return &item, nil return &item, nil
} }
type mediaResourceSyncStats struct {
Synced int
RemovedPriceOverride int
}
func (s *MediaSupplyService) UpsertSyncedResources(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (int, error) { func (s *MediaSupplyService) UpsertSyncedResources(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (int, error) {
stats, err := s.upsertSyncedResourcesWithStats(ctx, resources)
return stats.Synced, err
}
func (s *MediaSupplyService) upsertSyncedResourcesWithStats(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (mediaResourceSyncStats, error) {
if len(resources) == 0 { if len(resources) == 0 {
return 0, nil return mediaResourceSyncStats{}, nil
} }
count := 0 tx, err := s.pool.Begin(ctx)
if err != nil {
return mediaResourceSyncStats{}, err
}
defer tx.Rollback(ctx)
stats := mediaResourceSyncStats{}
for _, resource := range resources { for _, resource := range resources {
raw := compactJSON(resource.Raw) raw := compactJSON(resource.Raw)
costPrices, _ := json.Marshal(resource.CostPrices) costPrices, _ := json.Marshal(resource.CostPrices)
if len(costPrices) == 0 { if len(costPrices) == 0 {
costPrices = []byte(`{}`) costPrices = []byte(`{}`)
} }
if _, err := s.pool.Exec(ctx, ` var resourceID int64
if err := tx.QueryRow(ctx, `
INSERT INTO supplier_media_resources ( INSERT INTO supplier_media_resources (
supplier, supplier_resource_id, model_id, name, status, cost_price_cents, supplier, supplier_resource_id, model_id, name, status, cost_price_cents,
cost_prices_json, sale_price_label, resource_url, baidu_weight, resource_remark, cost_prices_json, sale_price_label, resource_url, baidu_weight, resource_remark,
@@ -1022,18 +1027,111 @@ func (s *MediaSupplyService) UpsertSyncedResources(ctx context.Context, resource
raw_json = EXCLUDED.raw_json, raw_json = EXCLUDED.raw_json,
updated_at = NOW(), updated_at = NOW(),
deleted_at = NULL deleted_at = NULL
RETURNING id
`, resource.Supplier, resource.SupplierResourceID, resource.ModelID, resource.Name, resource.Status, `, resource.Supplier, resource.SupplierResourceID, resource.ModelID, resource.Name, resource.Status,
resource.CostPriceCents, costPrices, nullableTrimmedString(resource.SalePriceLabel), resource.CostPriceCents, costPrices, nullableTrimmedString(resource.SalePriceLabel),
nullableTrimmedString(resource.ResourceURL), resource.BaiduWeight, nullableTrimmedString(resource.ResourceRemark), nullableTrimmedString(resource.ResourceURL), resource.BaiduWeight, nullableTrimmedString(resource.ResourceRemark),
nullableTrimmedString(resource.ChannelType), nullableTrimmedString(resource.Region), nullableTrimmedString(resource.ChannelType), nullableTrimmedString(resource.Region),
nullableTrimmedString(resource.InclusionEffect), nullableTrimmedString(resource.LinkType), nullableTrimmedString(resource.InclusionEffect), nullableTrimmedString(resource.LinkType),
nullableTrimmedString(resource.PublishRate), nullableTrimmedString(resource.DeliverySpeed), nullableTrimmedString(resource.PublishRate), nullableTrimmedString(resource.DeliverySpeed),
resource.SupplierUpdatedAt, raw); err != nil { resource.SupplierUpdatedAt, raw).Scan(&resourceID); err != nil {
return count, err return stats, err
} }
count++ removed, err := s.deletePriceOverridesBelowCost(ctx, tx, resourceID)
if err != nil {
return stats, err
}
stats.RemovedPriceOverride += removed
stats.Synced++
} }
return count, nil if err := tx.Commit(ctx); err != nil {
return stats, err
}
return stats, nil
}
func (s *MediaSupplyService) deletePriceOverridesBelowCost(ctx context.Context, q interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
}, resourceID int64) (int, error) {
if s == nil || q == nil || resourceID <= 0 {
return 0, nil
}
tag, err := q.Exec(ctx, `
DELETE FROM supplier_media_price_overrides o
USING supplier_media_resources r
WHERE o.resource_id = r.id
AND r.id = $1
AND r.supplier = $2
AND r.deleted_at IS NULL
AND o.enabled = TRUE
AND o.sell_price_cents < COALESCE(
CASE
WHEN o.price_type = 'price' THEN COALESCE(NULLIF(r.cost_prices_json->>'price', '')::bigint, NULLIF(r.cost_prices_json->>'sale_price', '')::bigint)
ELSE NULLIF(r.cost_prices_json->>o.price_type, '')::bigint
END,
r.cost_price_cents
)
`, resourceID, mediaSupplySupplierMeijiequan)
return int(tag.RowsAffected()), err
}
func (s *MediaSupplyService) RunMediaResourceSyncOnce(ctx context.Context, modelID int) (map[string]any, error) {
if s == nil || s.pool == nil || s.client == nil {
return nil, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用")
}
cfg := s.mediaSupplyConfig()
if !cfg.Enabled {
return map[string]any{"synced": 0, "model_id": modelID, "disabled": true}, nil
}
if modelID <= 0 {
modelID = 1
}
if status, statusErr := s.client.SessionStatus(ctx); statusErr != nil || status == nil || !status.Available {
if loginStatus, loginErr := s.client.LoginWithConfiguredAccount(ctx); loginErr != nil {
return nil, mediaSupplyClientError(loginErr)
} else if loginStatus == nil || !loginStatus.Available {
return nil, mediaSupplyClientError(errMeijiequanSessionMissing)
}
}
stats, err := s.syncMediaResourceModel(ctx, modelID, cfg.Meijiequan.SyncPageSize, cfg.Meijiequan.MaxSyncPages, cfg.Meijiequan.SyncPageDelay)
if err != nil {
return nil, mediaSupplyClientError(err)
}
return map[string]any{
"model_id": modelID,
"synced": stats.Synced,
"removed_price_overrides": stats.RemovedPriceOverride,
}, nil
}
func (s *MediaSupplyService) syncMediaResourceModel(ctx context.Context, modelID, pageSize, maxPages int, pageDelay time.Duration) (mediaResourceSyncStats, error) {
var total mediaResourceSyncStats
for page := 1; page <= maxPages; page++ {
if page > 1 && pageDelay > 0 {
select {
case <-ctx.Done():
return total, ctx.Err()
case <-time.After(pageDelay):
}
}
mediaPage, err := s.client.ListMedia(ctx, modelID, page, pageSize)
if err != nil {
return total, err
}
stats, err := s.upsertSyncedResourcesWithStats(ctx, mediaPage.Items)
if err != nil {
return total, err
}
total.Synced += stats.Synced
total.RemovedPriceOverride += stats.RemovedPriceOverride
if len(mediaPage.Items) == 0 {
break
}
if mediaPage.AllPage > 0 && page >= mediaPage.AllPage {
break
}
}
return total, nil
} }
func (s *MediaSupplyService) resourceCostForPriceType(ctx context.Context, resourceID int64, priceType string) (int64, error) { func (s *MediaSupplyService) resourceCostForPriceType(ctx context.Context, resourceID int64, priceType string) (int64, error) {
@@ -1253,6 +1351,14 @@ func costForResourcePriceType(resource SupplierMediaResource, priceType string)
return resource.CostPriceCents return resource.CostPriceCents
} }
func mediaSupplyOverrideBelowCost(resource SupplierMediaResource, priceType string, overrideCents int64, overrideEnabled bool) bool {
if !overrideEnabled {
return false
}
cost := costForResourcePriceType(resource, normalizeMediaSupplyPriceType(priceType))
return cost > 0 && overrideCents < cost
}
func mediaSupplyClientError(err error) error { func mediaSupplyClientError(err error) error {
switch { switch {
case errors.Is(err, errMeijiequanCredentialsMissing): case errors.Is(err, errMeijiequanCredentialsMissing):
@@ -1335,5 +1441,14 @@ func scanMediaSupplyOrder(row mediaSupplyOrderScanner) (MediaSupplyOrderDetail,
message := mediaSupplyPublicGenericErrorMessage(*item.ErrorMessage) message := mediaSupplyPublicGenericErrorMessage(*item.ErrorMessage)
item.ErrorMessage = &message item.ErrorMessage = &message
} }
item.ExternalOrderID = nil
if item.ExternalOrderCode != nil {
code := strings.ToUpper(strings.TrimSpace(*item.ExternalOrderCode))
if !isLikelyMeijiequanOrderCode(code) {
item.ExternalOrderCode = nil
} else {
item.ExternalOrderCode = &code
}
}
return item, nil return item, nil
} }
+157 -5
View File
@@ -104,6 +104,28 @@ func TestMediaSupplySellPriceAppliesMarkupBeforeRoundUp(t *testing.T) {
} }
} }
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) { func TestMediaSupplyResourceOrderSQL(t *testing.T) {
priceSQL := "sell_price_expr" priceSQL := "sell_price_expr"
if got := mediaSupplyResourceOrderSQL("price", priceSQL); got != "(sell_price_expr) ASC, r.id DESC" { if got := mediaSupplyResourceOrderSQL("price", priceSQL); got != "(sell_price_expr) ASC, r.id DESC" {
@@ -149,9 +171,23 @@ func TestRetryableMediaSupplyErrorTreatsBusinessFailuresAsTerminal(t *testing.T)
} }
} }
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) { func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
errorMessage := "failed to refresh supplier price for 1 selected resources" errorMessage := "failed to refresh supplier price for 1 selected resources"
externalOrderID := "U6210"
externalOrderCode := "U6210"
item, err := scanMediaSupplyOrder(mediaSupplyOrderScanStub{values: []any{ item, err := scanMediaSupplyOrder(mediaSupplyOrderScanStub{values: []any{
int64(1), int64(1),
int64(2), int64(2),
@@ -165,8 +201,8 @@ func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) {
"测试标题", "测试标题",
(*string)(nil), (*string)(nil),
(*string)(nil), (*string)(nil),
(*string)(nil), &externalOrderID,
(*string)(nil), &externalOrderCode,
(*string)(nil), (*string)(nil),
int64(100), int64(100),
int64(120), int64(120),
@@ -187,9 +223,15 @@ func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) {
if strings.Contains(strings.ToLower(item.Supplier), "meijiequan") { if strings.Contains(strings.ToLower(item.Supplier), "meijiequan") {
t.Fatalf("supplier was not redacted: %s", item.Supplier) t.Fatalf("supplier was not redacted: %s", item.Supplier)
} }
if item.ErrorMessage == nil || *item.ErrorMessage != "媒体资源价格刷新失败,请稍后重试或重新选择媒体" { if item.ErrorMessage == nil || *item.ErrorMessage != "媒体资源暂不可用或已下架,请重新选择媒体" {
t.Fatalf("unexpected public error message: %#v", 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) { func TestMeijiequanCreateOrderFormMatchesUpstreamShape(t *testing.T) {
@@ -471,6 +513,7 @@ func TestDecodeMeijiequanAllArticleHTMLKeepsStatusAndRejectReasonColumns(t *test
if problem.OrderCode != "WM119065" || if problem.OrderCode != "WM119065" ||
problem.Title != "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议" || problem.Title != "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议" ||
problem.ResourceName != "博客园(GEO排名可发)" || problem.ResourceName != "博客园(GEO排名可发)" ||
problem.PriceCents != 300 ||
problem.Status != "退稿申请中" || problem.Status != "退稿申请中" ||
problem.RejectReason != "不想发布了" { problem.RejectReason != "不想发布了" {
t.Fatalf("unexpected problem article: %#v", problem) t.Fatalf("unexpected problem article: %#v", problem)
@@ -478,6 +521,7 @@ func TestDecodeMeijiequanAllArticleHTMLKeepsStatusAndRejectReasonColumns(t *test
published := page.Items[1] published := page.Items[1]
if published.OrderCode != "WM118996" || if published.OrderCode != "WM118996" ||
published.ResourceName != "金融树洞网" || published.ResourceName != "金融树洞网" ||
published.PriceCents != 300 ||
published.Status != "已发布" || published.Status != "已发布" ||
published.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" { published.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" {
t.Fatalf("unexpected published article: %#v", published) t.Fatalf("unexpected published article: %#v", published)
@@ -507,14 +551,15 @@ func TestDecodeMeijiequanPublishedArticleJSONRejectsShowURL(t *testing.T) {
func TestMatchMeijiequanPublishedArticlesPrefersResourceID(t *testing.T) { func TestMatchMeijiequanPublishedArticlesPrefersResourceID(t *testing.T) {
items := []MediaSupplyOrderItem{ items := []MediaSupplyOrderItem{
{ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "测试新闻网"}, {ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "测试新闻网", LockedCostPriceCents: 300, LockedSellPriceCents: 500},
{ID: 12, SupplierResourceID: "36123", ResourceNameSnapshot: "另一个媒体"}, {ID: 12, SupplierResourceID: "36123", ResourceNameSnapshot: "另一个媒体", LockedCostPriceCents: 300, LockedSellPriceCents: 500},
} }
matches := matchMeijiequanPublishedArticles(mediaSupplySubmitOrder{Title: "2026合肥品牌推荐"}, items, []MeijiequanPublishedArticle{ matches := matchMeijiequanPublishedArticles(mediaSupplySubmitOrder{Title: "2026合肥品牌推荐"}, items, []MeijiequanPublishedArticle{
{ {
Title: "2026合肥品牌推荐", Title: "2026合肥品牌推荐",
ResourceID: "36123_price_0", ResourceID: "36123_price_0",
ResourceName: "另一个媒体", ResourceName: "另一个媒体",
PriceCents: 300,
ExternalArticleURL: "https://news.example.test/b.html", ExternalArticleURL: "https://news.example.test/b.html",
}, },
}) })
@@ -526,6 +571,76 @@ func TestMatchMeijiequanPublishedArticlesPrefersResourceID(t *testing.T) {
} }
} }
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) { func TestMatchMeijiequanProblemArticleReturnsRejectReasonForCustomer(t *testing.T) {
orderCode := "WM119065" orderCode := "WM119065"
article := matchMeijiequanProblemArticle( article := matchMeijiequanProblemArticle(
@@ -547,6 +662,43 @@ func TestMatchMeijiequanProblemArticleReturnsRejectReasonForCustomer(t *testing.
} }
} }
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) { func TestMaskMeijiequanUsername(t *testing.T) {
if got := maskMeijiequanUsername("17788409108"); got != "177******08" { if got := maskMeijiequanUsername("17788409108"); got != "177******08" {
t.Fatalf("unexpected masked username: %s", got) t.Fatalf("unexpected masked username: %s", got)
+135 -83
View File
@@ -12,6 +12,8 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/config"
) )
type MediaSupplyWorker struct { type MediaSupplyWorker struct {
@@ -46,7 +48,12 @@ var (
errMediaSupplyLockedSellBelowCost = errors.New("supplier cost increased above locked sell price") errMediaSupplyLockedSellBelowCost = errors.New("supplier cost increased above locked sell price")
) )
var mediaSupplyOrderCodePattern = regexp.MustCompile(`(?i)\b[A-Z]{1,12}\d{3,}\b`) 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 { func NewMediaSupplyWorker(service *MediaSupplyService, pool *pgxpool.Pool, logger *zap.Logger) *MediaSupplyWorker {
return &MediaSupplyWorker{service: service, pool: pool, logger: logger} return &MediaSupplyWorker{service: service, pool: pool, logger: logger}
@@ -107,27 +114,14 @@ func (w *MediaSupplyWorker) processOneSyncJob(ctx context.Context) error {
if err != nil || !ok { if err != nil || !ok {
return err return err
} }
cfg := w.service.mediaSupplyConfig()
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.failSyncJob(ctx, job, loginErr)
} else if loginStatus == nil || !loginStatus.Available {
return w.failSyncJob(ctx, job, errMeijiequanSessionMissing)
}
}
modelID := 1 modelID := 1
if job.ModelID != nil && *job.ModelID > 0 { if job.ModelID != nil && *job.ModelID > 0 {
modelID = *job.ModelID modelID = *job.ModelID
} }
totalSynced := 0 stats, syncErr := w.service.RunMediaResourceSyncOnce(ctx, modelID)
stats := map[string]any{"models": map[int]int{}}
modelSynced, syncErr := w.syncModel(ctx, modelID, cfg.Meijiequan.SyncPageSize, cfg.Meijiequan.MaxSyncPages, cfg.Meijiequan.SyncPageDelay)
if syncErr != nil { if syncErr != nil {
return w.failSyncJob(ctx, job, syncErr) return w.failSyncJob(ctx, job, syncErr)
} }
totalSynced += modelSynced
stats["models"].(map[int]int)[modelID] = modelSynced
stats["synced"] = totalSynced
raw, _ := json.Marshal(stats) raw, _ := json.Marshal(stats)
if _, err := w.pool.Exec(ctx, ` if _, err := w.pool.Exec(ctx, `
UPDATE media_supply_sync_jobs UPDATE media_supply_sync_jobs
@@ -140,32 +134,8 @@ func (w *MediaSupplyWorker) processOneSyncJob(ctx context.Context) error {
} }
func (w *MediaSupplyWorker) syncModel(ctx context.Context, modelID, pageSize, maxPages int, pageDelay time.Duration) (int, error) { func (w *MediaSupplyWorker) syncModel(ctx context.Context, modelID, pageSize, maxPages int, pageDelay time.Duration) (int, error) {
total := 0 stats, err := w.service.syncMediaResourceModel(ctx, modelID, pageSize, maxPages, pageDelay)
for page := 1; page <= maxPages; page++ { return stats.Synced, err
if page > 1 && pageDelay > 0 {
select {
case <-ctx.Done():
return total, ctx.Err()
case <-time.After(pageDelay):
}
}
mediaPage, err := w.service.client.ListMedia(ctx, modelID, page, pageSize)
if err != nil {
return total, err
}
count, err := w.service.UpsertSyncedResources(ctx, mediaPage.Items)
if err != nil {
return total, err
}
total += count
if len(mediaPage.Items) == 0 {
break
}
if mediaPage.AllPage > 0 && page >= mediaPage.AllPage {
break
}
}
return total, nil
} }
func (w *MediaSupplyWorker) processOneOrder(ctx context.Context) error { func (w *MediaSupplyWorker) processOneOrder(ctx context.Context) error {
@@ -331,11 +301,46 @@ func (w *MediaSupplyWorker) refreshSelectedResourcesFromUpstream(ctx context.Con
} }
} }
if len(pending) > 0 { if len(pending) > 0 {
return fmt.Errorf("%w for %d selected resources", errMediaSupplySelectedResourcesMissing, len(pending)) return w.validatePendingResourcesFromCache(ctx, modelID, items, pending)
} }
return nil 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 { func (w *MediaSupplyWorker) processBacklinkSync(ctx context.Context) error {
cfg := w.service.mediaSupplyConfig() cfg := w.service.mediaSupplyConfig()
if cfg.Worker.BacklinkSyncInterval <= 0 { if cfg.Worker.BacklinkSyncInterval <= 0 {
@@ -375,12 +380,8 @@ func (w *MediaSupplyWorker) processBacklinkSync(ctx context.Context) error {
AND NULLIF(TRIM(COALESCE(item.external_article_url, '')), '') IS NULL AND NULLIF(TRIM(COALESCE(item.external_article_url, '')), '') IS NULL
) )
) )
AND (
supplier_status IS DISTINCT FROM $3
OR updated_at <= NOW() - $4::interval
)
) )
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted, "published", pgInterval(cfg.Worker.BacklinkSyncInterval)).Scan(&due); err != nil { `, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted).Scan(&due); err != nil {
return err return err
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
@@ -389,7 +390,18 @@ func (w *MediaSupplyWorker) processBacklinkSync(ctx context.Context) error {
if !due && cleared == 0 { if !due && cleared == 0 {
return nil return nil
} }
return w.syncPublishedBacklinks(ctx) 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) { func (w *MediaSupplyWorker) clearInvalidBacklinks(ctx context.Context) (int64, error) {
@@ -410,16 +422,21 @@ func (w *MediaSupplyWorker) clearInvalidBacklinks(ctx context.Context) (int64, e
} }
func (w *MediaSupplyWorker) syncPublishedBacklinks(ctx context.Context) error { func (w *MediaSupplyWorker) syncPublishedBacklinks(ctx context.Context) error {
_, err := w.syncPublishedBacklinksNow(ctx) 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 return err
} }
func (w *MediaSupplyWorker) syncPublishedBacklinksNow(ctx context.Context) (*SyncMediaSupplyBacklinksResponse, error) { func (w *MediaSupplyWorker) syncPublishedBacklinksNowUnthrottled(ctx context.Context, cfg config.MediaSupplyConfig) (*SyncMediaSupplyBacklinksResponse, error) {
result := &SyncMediaSupplyBacklinksResponse{} result := &SyncMediaSupplyBacklinksResponse{}
if _, err := w.clearInvalidBacklinks(ctx); err != nil { if _, err := w.clearInvalidBacklinks(ctx); err != nil {
return result, err return result, err
} }
cfg := w.service.mediaSupplyConfig()
userID := strings.TrimSpace(cfg.Meijiequan.UserID) userID := strings.TrimSpace(cfg.Meijiequan.UserID)
if status, statusErr := w.service.client.SessionStatus(ctx); statusErr != nil || status == nil || !status.Available { if status, statusErr := w.service.client.SessionStatus(ctx); statusErr != nil || status == nil || !status.Available {
loginStatus, loginErr := w.service.client.LoginWithConfiguredAccount(ctx) loginStatus, loginErr := w.service.client.LoginWithConfiguredAccount(ctx)
@@ -598,7 +615,7 @@ func (w *MediaSupplyWorker) loadOrderCodeSyncCandidates(ctx context.Context, lim
WHERE supplier = $1 WHERE supplier = $1
AND status = $2 AND status = $2
AND deleted_at IS NULL AND deleted_at IS NULL
AND NULLIF(TRIM(COALESCE(external_order_code, '')), '') IS NULL AND NOT (`+mediaSupplyValidExternalOrderCodeSQL+`)
ORDER BY submitted_at DESC NULLS LAST, id DESC ORDER BY submitted_at DESC NULLS LAST, id DESC
LIMIT $3 LIMIT $3
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted, limit) `, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted, limit)
@@ -633,7 +650,7 @@ func (w *MediaSupplyWorker) loadOrderCodeSyncCandidates(ctx context.Context, lim
} }
func (w *MediaSupplyWorker) updateOrderCode(ctx context.Context, order mediaSupplySubmitOrder, pending []MeijiequanPublishedArticle) (bool, error) { func (w *MediaSupplyWorker) updateOrderCode(ctx context.Context, order mediaSupplySubmitOrder, pending []MeijiequanPublishedArticle) (bool, error) {
if order.ExternalOrderCode != nil && strings.TrimSpace(*order.ExternalOrderCode) != "" { if order.ExternalOrderCode != nil && isLikelyMeijiequanOrderCode(*order.ExternalOrderCode) {
return false, nil return false, nil
} }
items, err := w.service.listOrderItems(ctx, order.ID) items, err := w.service.listOrderItems(ctx, order.ID)
@@ -661,7 +678,7 @@ func (w *MediaSupplyWorker) updateOrderCode(ctx context.Context, order mediaSupp
END, END,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
AND NULLIF(TRIM(COALESCE(external_order_code, '')), '') IS NULL AND NOT (`+mediaSupplyValidExternalOrderCodeSQL+`)
`, order.ID, article.OrderCode, article.OrderID, compactJSON(raw)) `, order.ID, article.OrderCode, article.OrderID, compactJSON(raw))
if err != nil { if err != nil {
return false, err return false, err
@@ -1185,7 +1202,7 @@ func matchMeijiequanPublishedArticles(order mediaSupplySubmitOrder, items []Medi
} }
continue continue
} }
itemID := matchMeijiequanPublishedArticleItem(orderTitle, unmatched, article) itemID := matchMeijiequanPublishedArticleItem(orderTitle, orderCode, unmatched, article)
if itemID <= 0 { if itemID <= 0 {
continue continue
} }
@@ -1205,6 +1222,7 @@ func matchMeijiequanOrderCodeArticle(order mediaSupplySubmitOrder, items []Media
return &articles[idx] return &articles[idx]
} }
} }
return nil
} }
orderTitle := normalizeMeijiequanMatchText(order.Title) orderTitle := normalizeMeijiequanMatchText(order.Title)
for idx := range articles { for idx := range articles {
@@ -1212,11 +1230,8 @@ func matchMeijiequanOrderCodeArticle(order mediaSupplySubmitOrder, items []Media
if strings.TrimSpace(article.OrderCode) == "" { if strings.TrimSpace(article.OrderCode) == "" {
continue continue
} }
articleTitle := normalizeMeijiequanMatchText(article.Title) if articleMatchesOrderFallback(orderTitle, *article, items) {
if orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle)) { return article
if len(items) <= 1 || articleMatchesAnyOrderItem(*article, items) {
return article
}
} }
} }
return nil return nil
@@ -1236,6 +1251,7 @@ func matchMeijiequanProblemArticle(order mediaSupplySubmitOrder, items []MediaSu
return article return article
} }
} }
return nil
} }
orderTitle := normalizeMeijiequanMatchText(order.Title) orderTitle := normalizeMeijiequanMatchText(order.Title)
for idx := range articles { for idx := range articles {
@@ -1243,11 +1259,8 @@ func matchMeijiequanProblemArticle(order mediaSupplySubmitOrder, items []MediaSu
if !isMeijiequanProblemArticle(*article) { if !isMeijiequanProblemArticle(*article) {
continue continue
} }
articleTitle := normalizeMeijiequanMatchText(article.Title) if articleMatchesOrderFallback(orderTitle, *article, items) {
if orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle)) { return article
if len(items) <= 1 || articleMatchesAnyOrderItem(*article, items) {
return article
}
} }
} }
return nil return nil
@@ -1255,7 +1268,7 @@ func matchMeijiequanProblemArticle(order mediaSupplySubmitOrder, items []MediaSu
func mediaSupplyOrderCodeValue(order mediaSupplySubmitOrder) string { func mediaSupplyOrderCodeValue(order mediaSupplySubmitOrder) string {
if order.ExternalOrderCode != nil { if order.ExternalOrderCode != nil {
if value := strings.TrimSpace(*order.ExternalOrderCode); value != "" { if value := strings.TrimSpace(*order.ExternalOrderCode); isLikelyMeijiequanOrderCode(value) {
return value return value
} }
} }
@@ -1343,31 +1356,69 @@ func articleMatchesAnyOrderItem(article MeijiequanPublishedArticle, items []Medi
return false return false
} }
func matchMeijiequanPublishedArticleItem(orderTitle string, items map[int64]MediaSupplyOrderItem, article MeijiequanPublishedArticle) int64 { func articleMatchesOrderFallback(orderTitle string, article MeijiequanPublishedArticle, items []MediaSupplyOrderItem) bool {
resourceID := normalizeMeijiequanPublishedResourceID(article.ResourceID) if !articleTitleMatchesOrder(orderTitle, article) {
if resourceID != "" { return false
for itemID, item := range items { }
if strings.EqualFold(strings.TrimSpace(item.SupplierResourceID), resourceID) { if len(items) == 0 {
return itemID 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) articleTitle := normalizeMeijiequanMatchText(article.Title)
if orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle)) { return orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle))
if len(items) == 1 { }
for itemID := range items {
return itemID 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
} }
resourceName := normalizeMeijiequanMatchText(article.ResourceName) for itemID := range items {
if resourceName != "" { return itemID
}
}
if articleTitleMatchesOrder(orderTitle, article) {
if len(items) == 1 {
for itemID, item := range items { for itemID, item := range items {
itemName := normalizeMeijiequanMatchText(item.ResourceNameSnapshot) if articleMatchesOrderItemFallback(article, item) {
if itemName != "" && (strings.Contains(itemName, resourceName) || strings.Contains(resourceName, itemName)) {
return itemID return itemID
} }
} }
} }
for itemID, item := range items {
if articleMatchesOrderItemFallback(article, item) {
return itemID
}
}
} }
return 0 return 0
} }
@@ -1463,6 +1514,8 @@ func extractMeijiequanOrderIDFromValue(value any) (string, string) {
if code != "" && !isLikelyMeijiequanOrderCode(code) { if code != "" && !isLikelyMeijiequanOrderCode(code) {
if extracted := firstMeijiequanOrderCode(code); extracted != "" { if extracted := firstMeijiequanOrderCode(code); extracted != "" {
code = extracted code = extracted
} else {
code = ""
} }
} }
for _, key := range []string{"data", "result", "order", "article", "info", "message", "msg"} { for _, key := range []string{"data", "result", "order", "article", "info", "message", "msg"} {
@@ -1542,4 +1595,3 @@ func (w *MediaSupplyWorker) logWarn(message string, fields ...zap.Field) {
w.logger.Warn(message, fields...) w.logger.Warn(message, fields...)
} }
} }
+34 -13
View File
@@ -189,6 +189,7 @@ type MeijiequanPublishedArticle struct {
Title string Title string
ResourceID string ResourceID string
ResourceName string ResourceName string
PriceCents int64
ExternalArticleURL string ExternalArticleURL string
Status string Status string
RejectReason string RejectReason string
@@ -1600,13 +1601,18 @@ func parseMeijiequanPublishedArticleMap(item map[string]any) (MeijiequanPublishe
if title == "" && articleURL == "" { if title == "" && articleURL == "" {
return MeijiequanPublishedArticle{}, false return MeijiequanPublishedArticle{}, false
} }
orderCode := strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "order_code", "code", "sn", "order_sn", "dingdanhao", "order_no", "orderno", "danhao")))
if !isLikelyMeijiequanOrderCode(orderCode) {
orderCode = firstMeijiequanOrderCode(orderCode)
}
raw, _ := json.Marshal(item) raw, _ := json.Marshal(item)
return MeijiequanPublishedArticle{ return MeijiequanPublishedArticle{
OrderID: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "id", "order_id", "article_id"))), OrderID: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "id", "order_id", "article_id"))),
OrderCode: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "order_code", "code", "sn", "order_sn", "dingdanhao", "order_no", "orderno", "danhao"))), OrderCode: orderCode,
Title: title, Title: title,
ResourceID: normalizeMeijiequanPublishedResourceID(meijiequanAnyString(firstMapValue(item, "resource_id", "media_id", "uid", "mid"))), ResourceID: normalizeMeijiequanPublishedResourceID(meijiequanAnyString(firstMapValue(item, "resource_id", "media_id", "uid", "mid"))),
ResourceName: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "meiti", "media_name", "resource_name", "name"))), ResourceName: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "meiti", "media_name", "resource_name", "name"))),
PriceCents: firstMeijiequanArticlePriceCents(item),
ExternalArticleURL: articleURL, ExternalArticleURL: articleURL,
Status: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "status", "status_text", "state"))), Status: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "status", "status_text", "state"))),
RejectReason: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "reject_reason", "reason", "tuigao_reason", "tuigaoyuanyin", "refund_reason", "error_message"))), RejectReason: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "reject_reason", "reason", "tuigao_reason", "tuigaoyuanyin", "refund_reason", "error_message"))),
@@ -1732,6 +1738,7 @@ func parseMeijiequanPublishedArticleHTMLRow(row *nethtml.Node, cells []*nethtml.
if status == "" { if status == "" {
status = extractMeijiequanPublishedStatus(rowText) status = extractMeijiequanPublishedStatus(rowText)
} }
priceCents, _ := parsePriceCents(columnValues["价格"])
rejectReason := strings.TrimSpace(columnValues["退稿原因"]) rejectReason := strings.TrimSpace(columnValues["退稿原因"])
return MeijiequanPublishedArticle{ return MeijiequanPublishedArticle{
OrderID: strings.TrimSpace(firstMeijiequanHTMLAttr(row, "data-id", "data-order-id", "data-article-id")), OrderID: strings.TrimSpace(firstMeijiequanHTMLAttr(row, "data-id", "data-order-id", "data-article-id")),
@@ -1739,6 +1746,7 @@ func parseMeijiequanPublishedArticleHTMLRow(row *nethtml.Node, cells []*nethtml.
Title: strings.TrimSpace(title), Title: strings.TrimSpace(title),
ResourceID: resourceID, ResourceID: resourceID,
ResourceName: resourceName, ResourceName: resourceName,
PriceCents: priceCents,
ExternalArticleURL: articleURL, ExternalArticleURL: articleURL,
Status: status, Status: status,
RejectReason: rejectReason, RejectReason: rejectReason,
@@ -1814,6 +1822,8 @@ func normalizeMeijiequanHeaderName(value string) string {
return "订单号" return "订单号"
case strings.Contains(value, "资源名称"): case strings.Contains(value, "资源名称"):
return "资源名称" return "资源名称"
case strings.Contains(value, "价格") || strings.Contains(value, "金额"):
return "价格"
case strings.Contains(value, "退稿原因"): case strings.Contains(value, "退稿原因"):
return "退稿原因" return "退稿原因"
case strings.Contains(value, "状态"): case strings.Contains(value, "状态"):
@@ -2091,7 +2101,10 @@ func extractMeijiequanOrderCodeFromText(value string) string {
for idx, field := range fields { for idx, field := range fields {
if strings.Contains(field, "单号") || strings.Contains(strings.ToLower(field), "order") { if strings.Contains(field, "单号") || strings.Contains(strings.ToLower(field), "order") {
if idx+1 < len(fields) { if idx+1 < len(fields) {
return strings.Trim(fields[idx+1], " :,;") token := strings.Trim(fields[idx+1], " :,;")
if isLikelyMeijiequanOrderCode(token) {
return token
}
} }
} }
} }
@@ -2115,25 +2128,21 @@ func extractMeijiequanOrderCodeFromTexts(texts []string) string {
func isLikelyMeijiequanOrderCode(value string) bool { func isLikelyMeijiequanOrderCode(value string) bool {
value = strings.ToUpper(strings.TrimSpace(value)) value = strings.ToUpper(strings.TrimSpace(value))
if len(value) < 4 || len(value) > 64 { if len(value) < 5 || len(value) > 64 {
return false return false
} }
if strings.Contains(value, "HTTP") || strings.Contains(value, "://") { if strings.Contains(value, "HTTP") || strings.Contains(value, "://") {
return false return false
} }
hasDigit := false if !strings.HasPrefix(value, "WM") {
hasLetter := false return false
for _, r := range value { }
switch { for _, r := range strings.TrimPrefix(value, "WM") {
case r >= '0' && r <= '9': if r < '0' || r > '9' {
hasDigit = true
case r >= 'A' && r <= 'Z':
hasLetter = true
default:
return false return false
} }
} }
return hasDigit && hasLetter return true
} }
func extractMeijiequanResourceNameFromTexts(texts []string) string { func extractMeijiequanResourceNameFromTexts(texts []string) string {
@@ -2469,6 +2478,18 @@ func extractMeijiequanPrices(item map[string]any) map[string]int64 {
return out return out
} }
func firstMeijiequanArticlePriceCents(item map[string]any) int64 {
for _, key := range []string{
"price", "sale_price", "cost_price", "amount", "money", "jine", "jiage",
"media_price", "resource_price", "order_price", "total_price",
} {
if cents, ok := parsePriceCents(item[key]); ok {
return cents
}
}
return 0
}
func parsePriceCents(value any) (int64, bool) { func parsePriceCents(value any) (int64, bool) {
switch typed := value.(type) { switch typed := value.(type) {
case nil: case nil:
@@ -241,15 +241,6 @@ func (h *MediaSupplyHandler) SessionStatus(c *gin.Context) {
response.Success(c, data) response.Success(c, data)
} }
func (h *MediaSupplyHandler) SyncBacklinks(c *gin.Context) {
data, err := h.svc.SyncBacklinks(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func parseOptionalIntQuery(c *gin.Context, key string) (int, error) { func parseOptionalIntQuery(c *gin.Context, key string) (int, error) {
raw := c.Query(key) raw := c.Query(key)
if raw == "" { if raw == "" {
@@ -116,7 +116,6 @@ func RegisterRoutes(app *bootstrap.App) {
mediaSupply.PUT("/resources/:id/price", mediaSupplyHandler.SetResourcePrice) mediaSupply.PUT("/resources/:id/price", mediaSupplyHandler.SetResourcePrice)
mediaSupply.POST("/orders", middleware.RequireCurrentBrand(), mediaSupplyHandler.CreateOrder) mediaSupply.POST("/orders", middleware.RequireCurrentBrand(), mediaSupplyHandler.CreateOrder)
mediaSupply.GET("/orders", mediaSupplyHandler.ListOrders) mediaSupply.GET("/orders", mediaSupplyHandler.ListOrders)
mediaSupply.POST("/orders/sync-backlinks", mediaSupplyHandler.SyncBacklinks)
mediaSupply.GET("/orders/:id", mediaSupplyHandler.GetOrder) mediaSupply.GET("/orders/:id", mediaSupplyHandler.GetOrder)
mediaSupply.GET("/wallet", mediaSupplyHandler.WalletStatus) mediaSupply.GET("/wallet", mediaSupplyHandler.WalletStatus)
mediaSupply.GET("/wallet/ledgers", mediaSupplyHandler.ListWalletLedgers) mediaSupply.GET("/wallet/ledgers", mediaSupplyHandler.ListWalletLedgers)