diff --git a/apps/admin-web/src/lib/media-supply-favorites.ts b/apps/admin-web/src/lib/media-supply-favorites.ts index d400cc6..4834e2f 100644 --- a/apps/admin-web/src/lib/media-supply-favorites.ts +++ b/apps/admin-web/src/lib/media-supply-favorites.ts @@ -163,6 +163,33 @@ export function mergeMediaSupplyFavoriteResourceSnapshots( }) } +export function reconcileMediaSupplyFavoriteResources( + groups: MediaSupplyFavoriteGroup[], + resources: MediaSupplyFavoriteResourceInput[], +): MediaSupplyFavoriteGroup[] { + const snapshots = resources + .map((resource) => normalizeResourceSnapshot(resource)) + .filter((resource): resource is MediaSupplyFavoriteResourceSnapshot => Boolean(resource)) + const snapshotById = new Map(snapshots.map((resource) => [resource.id, resource])) + const now = new Date().toISOString() + + return (groups.length > 0 ? groups : [defaultGroup()]).map((group) => { + const resourceIds = group.resourceIds.filter((resourceId) => snapshotById.has(resourceId)) + const changed = + resourceIds.length !== group.resourceIds.length || + resourceIds.some((resourceId, index) => resourceId !== group.resourceIds[index]) + + return { + ...group, + resourceIds, + resources: resourceIds + .map((resourceId) => snapshotById.get(resourceId)) + .filter((resource): resource is MediaSupplyFavoriteResourceSnapshot => Boolean(resource)), + updatedAt: changed ? now : group.updatedAt, + } + }) +} + function normalizeGroup(value: unknown): MediaSupplyFavoriteGroup | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null diff --git a/apps/admin-web/src/views/MediaSupplyFavoritesView.vue b/apps/admin-web/src/views/MediaSupplyFavoritesView.vue index 41562a0..e8e6553 100644 --- a/apps/admin-web/src/views/MediaSupplyFavoritesView.vue +++ b/apps/admin-web/src/views/MediaSupplyFavoritesView.vue @@ -15,7 +15,7 @@ import { mediaSupplyApi } from '@/lib/api' import { formatDateTime } from '@/lib/display' import { loadMediaSupplyFavoriteGroups, - mergeMediaSupplyFavoriteResourceSnapshots, + reconcileMediaSupplyFavoriteResources, removeMediaSupplyFavorite, saveMediaSupplyFavoriteGroups, type MediaSupplyFavoriteGroup, @@ -81,11 +81,16 @@ watch( watch( () => resourceDetailsQuery.data.value?.items, (items) => { - if (!items?.length) { + if (!items || allResourceIds.value.length === 0) { return } - groups.value = mergeMediaSupplyFavoriteResourceSnapshots(groups.value, items) + const before = allResourceIds.value.length + groups.value = reconcileMediaSupplyFavoriteResources(groups.value, items) persist() + const removed = before - allResourceIds.value.length + if (removed > 0) { + message.info(`已移除 ${removed} 个已下架常发媒体`) + } }, ) diff --git a/apps/admin-web/src/views/MediaSupplyResourcesView.vue b/apps/admin-web/src/views/MediaSupplyResourcesView.vue index 26c7ba2..b4f8684 100644 --- a/apps/admin-web/src/views/MediaSupplyResourcesView.vue +++ b/apps/admin-web/src/views/MediaSupplyResourcesView.vue @@ -19,6 +19,7 @@ import { mediaSupplyApi } from '@/lib/api' import { formatDateTime } from '@/lib/display' import { loadMediaSupplyFavoriteGroups, + reconcileMediaSupplyFavoriteResources, removeMediaSupplyFavorite, saveMediaSupplyFavoriteGroups, toggleMediaSupplyFavorite, @@ -309,6 +310,18 @@ const ledgerQuery = useQuery({ queryFn: () => mediaSupplyApi.listWalletLedgers({ page: ledgerPage.value, page_size: 10 }), enabled: computed(() => ledgerDrawerOpen.value), }) +const favoriteReconcileInFlight = ref(false) + +watch( + () => resourcesQuery.data.value?.items, + (items) => { + const favoriteIds = [...new Set(favoriteGroups.value.flatMap((group) => group.resourceIds))] + if (!items || favoriteIds.length === 0) { + return + } + void reconcileFavoriteResources() + }, +) const resources = computed(() => (resourcesQuery.data.value?.items ?? []).map(toResourceRow), @@ -403,6 +416,7 @@ const ledgerColumns = [ function refresh(): void { void resourcesQuery.refetch() void walletQuery.refetch() + void reconcileFavoriteResources() } function applyFilters(): void { @@ -489,6 +503,27 @@ function toggleFavorite(resource: MediaSupplyResourceRow): void { favoriteModalOpen.value = true } +async function reconcileFavoriteResources(): Promise { + if (favoriteReconcileInFlight.value) { + return + } + const favoriteIds = [...new Set(favoriteGroups.value.flatMap((group) => group.resourceIds))] + if (favoriteIds.length === 0) { + return + } + favoriteReconcileInFlight.value = true + try { + const detail = await mediaSupplyApi.listResourcesByIds(favoriteIds, MODEL_ID_AUTHORITY_NEWS) + const before = favoriteIds.length + favoriteGroups.value = reconcileMediaSupplyFavoriteResources(favoriteGroups.value, detail.items) + if (before !== favoriteGroups.value.flatMap((group) => group.resourceIds).length) { + saveMediaSupplyFavoriteGroups(authStore.user?.id, favoriteGroups.value) + } + } finally { + favoriteReconcileInFlight.value = false + } +} + function confirmAddFavorite(): void { const resource = pendingFavoriteResource.value if (!resource) { diff --git a/server/internal/tenant/app/media_supply_service.go b/server/internal/tenant/app/media_supply_service.go index 0e241c9..3706c46 100644 --- a/server/internal/tenant/app/media_supply_service.go +++ b/server/internal/tenant/app/media_supply_service.go @@ -983,17 +983,21 @@ func (s *MediaSupplyService) GetOrder(ctx context.Context, orderID int64) (*Medi type mediaResourceSyncStats struct { Synced int RemovedPriceOverride int + RemovedStale int } func (s *MediaSupplyService) UpsertSyncedResources(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (int, error) { - stats, err := s.upsertSyncedResourcesWithStats(ctx, resources) + stats, err := s.upsertSyncedResourcesWithStats(ctx, resources, time.Now().UTC()) return stats.Synced, err } -func (s *MediaSupplyService) upsertSyncedResourcesWithStats(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (mediaResourceSyncStats, error) { +func (s *MediaSupplyService) upsertSyncedResourcesWithStats(ctx context.Context, resources []UpsertSupplierMediaResourceInput, syncedAt time.Time) (mediaResourceSyncStats, error) { if len(resources) == 0 { return mediaResourceSyncStats{}, nil } + if syncedAt.IsZero() { + syncedAt = time.Now().UTC() + } tx, err := s.pool.Begin(ctx) if err != nil { return mediaResourceSyncStats{}, err @@ -1014,7 +1018,7 @@ func (s *MediaSupplyService) upsertSyncedResourcesWithStats(ctx context.Context, channel_type, region, inclusion_effect, link_type, publish_rate, delivery_speed, supplier_updated_at, last_synced_at, raw_json ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW(), $19) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) ON CONFLICT (supplier, supplier_resource_id) DO UPDATE SET model_id = EXCLUDED.model_id, name = EXCLUDED.name, @@ -1032,7 +1036,7 @@ func (s *MediaSupplyService) upsertSyncedResourcesWithStats(ctx context.Context, publish_rate = EXCLUDED.publish_rate, delivery_speed = EXCLUDED.delivery_speed, supplier_updated_at = EXCLUDED.supplier_updated_at, - last_synced_at = NOW(), + last_synced_at = EXCLUDED.last_synced_at, raw_json = EXCLUDED.raw_json, updated_at = NOW(), deleted_at = NULL @@ -1043,7 +1047,7 @@ func (s *MediaSupplyService) upsertSyncedResourcesWithStats(ctx context.Context, nullableTrimmedString(resource.ChannelType), nullableTrimmedString(resource.Region), nullableTrimmedString(resource.InclusionEffect), nullableTrimmedString(resource.LinkType), nullableTrimmedString(resource.PublishRate), nullableTrimmedString(resource.DeliverySpeed), - resource.SupplierUpdatedAt, raw).Scan(&resourceID); err != nil { + resource.SupplierUpdatedAt, syncedAt, raw).Scan(&resourceID); err != nil { return stats, err } removed, err := s.deletePriceOverridesBelowCost(ctx, tx, resourceID) @@ -1110,11 +1114,15 @@ func (s *MediaSupplyService) RunMediaResourceSyncOnce(ctx context.Context, model "model_id": modelID, "synced": stats.Synced, "removed_price_overrides": stats.RemovedPriceOverride, + "removed_stale_resources": stats.RemovedStale, }, nil } func (s *MediaSupplyService) syncMediaResourceModel(ctx context.Context, modelID, pageSize, maxPages int, pageDelay time.Duration) (mediaResourceSyncStats, error) { var total mediaResourceSyncStats + syncedAt := time.Now().UTC() + completed := false + syncedResourceIDs := make(map[string]struct{}) for page := 1; page <= maxPages; page++ { if page > 1 && pageDelay > 0 { select { @@ -1127,22 +1135,66 @@ func (s *MediaSupplyService) syncMediaResourceModel(ctx context.Context, modelID if err != nil { return total, err } - stats, err := s.upsertSyncedResourcesWithStats(ctx, mediaPage.Items) + stats, err := s.upsertSyncedResourcesWithStats(ctx, mediaPage.Items, syncedAt) if err != nil { return total, err } total.Synced += stats.Synced total.RemovedPriceOverride += stats.RemovedPriceOverride - if len(mediaPage.Items) == 0 { + for _, resource := range mediaPage.Items { + resourceID := strings.TrimSpace(resource.SupplierResourceID) + if resourceID != "" { + syncedResourceIDs[resourceID] = struct{}{} + } + } + if mediaPage.RawItemCount == 0 { + completed = true + break + } + if pageSize > 0 && mediaPage.RawItemCount < pageSize { + completed = true break } if mediaPage.AllPage > 0 && page >= mediaPage.AllPage { + completed = true break } } + if !completed { + return total, fmt.Errorf("meijiequan media resource sync incomplete: reached max pages %d", maxPages) + } + removed, err := s.markMissingSyncedResourcesDeleted(ctx, mediaSupplySupplierMeijiequan, modelID, syncedResourceIDs) + if err != nil { + return total, err + } + total.RemovedStale = removed return total, nil } +func (s *MediaSupplyService) markMissingSyncedResourcesDeleted(ctx context.Context, supplier string, modelID int, syncedResourceIDs map[string]struct{}) (int, error) { + if s == nil || s.pool == nil || strings.TrimSpace(supplier) == "" || modelID <= 0 { + return 0, nil + } + ids := make([]string, 0, len(syncedResourceIDs)) + for id := range syncedResourceIDs { + if trimmed := strings.TrimSpace(id); trimmed != "" { + ids = append(ids, trimmed) + } + } + tag, err := s.pool.Exec(ctx, ` + UPDATE supplier_media_resources + SET deleted_at = NOW(), updated_at = NOW() + WHERE supplier = $1 + AND model_id = $2 + AND deleted_at IS NULL + AND NOT (supplier_resource_id = ANY($3::text[])) + `, supplier, modelID, ids) + if err != nil { + return 0, err + } + return int(tag.RowsAffected()), nil +} + func (s *MediaSupplyService) resourceCostForPriceType(ctx context.Context, resourceID int64, priceType string) (int64, error) { var costPrice int64 var costPricesRaw []byte diff --git a/server/internal/tenant/app/media_supply_test.go b/server/internal/tenant/app/media_supply_test.go index 853c312..1a58766 100644 --- a/server/internal/tenant/app/media_supply_test.go +++ b/server/internal/tenant/app/media_supply_test.go @@ -616,6 +616,38 @@ func TestDecodeMeijiequanMediaListAcceptsStringNumbers(t *testing.T) { } } +func TestMeijiequanListMediaKeepsRawItemCountForPaging(t *testing.T) { + client, cleanup := testMeijiequanClientWithUploadServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/advSupply/media/getMediaInfo" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "status": "1", + "page": "1", + "all_page": "0", + "all_num": "0", + "data": [ + {"id":"1","name":"有效媒体","sale_price":"12.5"}, + {"id":"2","sale_price":"18.0"} + ] + }`)) + }) + defer cleanup() + + page, err := client.ListMedia(context.Background(), 1, 1, 2) + if err != nil { + t.Fatalf("list media: %v", err) + } + if page.RawItemCount != 2 { + t.Fatalf("expected raw count to include invalid upstream rows, got %d", page.RawItemCount) + } + if len(page.Items) != 1 { + t.Fatalf("expected only valid rows to be upserted, got %d", len(page.Items)) + } +} + func TestDecodeMeijiequanSearchOptions(t *testing.T) { groups, err := decodeMeijiequanSearchOptions([]byte(`{ "pindaoleixing": {"name": "频道类型", "list": ["IT科技", "财经金融"]}, diff --git a/server/internal/tenant/app/meijiequan_client.go b/server/internal/tenant/app/meijiequan_client.go index d556c2b..d886648 100644 --- a/server/internal/tenant/app/meijiequan_client.go +++ b/server/internal/tenant/app/meijiequan_client.go @@ -128,10 +128,11 @@ func (r *meijiequanMediaListResponse) UnmarshalJSON(body []byte) error { } type MeijiequanMediaPage struct { - Items []UpsertSupplierMediaResourceInput - Page int - AllPage int - AllNum int + Items []UpsertSupplierMediaResourceInput + RawItemCount int + Page int + AllPage int + AllNum int } type meijiequanSearchOptionsCache struct { @@ -687,6 +688,7 @@ func (c *MeijiequanClient) ListMedia(ctx context.Context, modelID, page, pageSiz result.Page = parsed.Page result.AllPage = parsed.AllPage result.AllNum = parsed.AllNum + result.RawItemCount = len(parsed.Data) result.Items = make([]UpsertSupplierMediaResourceInput, 0, len(parsed.Data)) for _, item := range parsed.Data { resource, ok := parseMeijiequanMediaItem(modelID, item)