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,
ScheduleTaskRequest,
ScheduleTaskStatusRequest,
SyncMediaSupplyBacklinksResponse,
TemplateAnalyzeTaskRequest,
TemplateAnalyzeTaskResultResponse,
TemplateAssistTaskCreateResponse,
@@ -1257,12 +1256,6 @@ export const mediaSupplyApi = {
getOrder(id: number) {
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() {
return apiClient.get<MediaSupplyWalletStatus>('/api/tenant/media-supply/wallet')
},
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons-vue'
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 { computed, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { mediaSupplyApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display'
@@ -19,11 +19,14 @@ function copyToClipboard(text?: string | null): void {
message.warning('链接为空,无法复制')
return
}
void navigator.clipboard.writeText(url).then(() => {
message.success('已复制到剪贴板')
}).catch(() => {
message.error('复制失败,请手动复制')
})
void navigator.clipboard
.writeText(url)
.then(() => {
message.success('复制到剪贴板')
})
.catch(() => {
message.error('复制失败,请手动复制')
})
}
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 total = computed(() => ordersQuery.data.value?.total ?? 0)
@@ -90,10 +64,6 @@ function refresh(): void {
void ordersQuery.refetch()
}
function syncBacklinks(): void {
void syncBacklinksMutation.mutateAsync()
}
function handleTableChange(nextPage: number, nextPageSize: number): void {
page.value = nextPage
pageSize.value = nextPageSize
@@ -119,7 +89,7 @@ function orderStatusMeta(order: MediaSupplyOrderDetail): { label: string; color:
? { label: '已发表', color: 'success' }
: { label: '发表中', color: 'processing' }
case 'failed':
return order.wallet_refunded_at
return order.wallet_refunded_at && order.supplier_status === 'rejected'
? { label: '已退稿', color: 'default' }
: { label: '投稿失败', color: 'error' }
default:
@@ -149,6 +119,52 @@ function pendingBacklinkCount(order: MediaSupplyOrderDetail): number {
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[] {
const seen = new Set<string>()
const names: string[] = []
@@ -233,15 +249,6 @@ function formatOrderError(message?: string | null): string {
<ReloadOutlined :class="{ 'refresh-spinning': ordersQuery.isFetching.value }" />
刷新
</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>
</section>
@@ -320,7 +327,7 @@ function formatOrderError(message?: string | null): string {
<span v-else>{{ backlinkSummary(record) }}</span>
</template>
<template v-else-if="column.key === 'external_order_code'">
{{ record.external_order_code || record.external_order_id || '--' }}
{{ record.external_order_code || '--' }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}