diff --git a/apps/admin-web/src/components/ArticleEditorCanvas.vue b/apps/admin-web/src/components/ArticleEditorCanvas.vue index 24cdb01..927cfae 100644 --- a/apps/admin-web/src/components/ArticleEditorCanvas.vue +++ b/apps/admin-web/src/components/ArticleEditorCanvas.vue @@ -108,14 +108,19 @@ import { type RichImageAlign, } from '@/lib/milkdown/richImageBlock' -const props = defineProps<{ - articleId: number +const props = withDefaults(defineProps<{ + articleId?: number | null title: string modelValue: string disabled?: boolean + toolbar?: boolean + aiOptimizeEnabled?: boolean complianceViolations?: ComplianceViolation[] uploadImage?: (file: File) => Promise -}>() +}>(), { + toolbar: true, + aiOptimizeEnabled: true, +}) const emit = defineEmits<{ 'update:title': [value: string] @@ -532,6 +537,9 @@ function createCrepe(root: HTMLElement): Crepe { featureConfigs: { [CrepeFeature.Toolbar]: { buildToolbar: (builder) => { + if (!canUseAiOptimize.value) { + return + } builder.addGroup('ai-optimize', 'AI Optimize').addItem('ai-optimize', { icon: AI_OPTIMIZE_TOOLBAR_ICON, active: () => false, @@ -604,6 +612,13 @@ let aiOptimizeIgnoreNextWindowPointerDown = false const EDITOR_ACTION_MENU_DROPDOWN_SELECTOR = '.editor-action-menu__dropdown-overlay' const tableContextHeaderRow = computed(() => tableContextMenu.value.rowIndex === 0) +const showEditorToolbar = computed(() => props.toolbar !== false) +const canUseAiOptimize = computed( + () => + props.aiOptimizeEnabled !== false && + Number.isInteger(props.articleId) && + Number(props.articleId) > 0, +) const aiOptimizeStreaming = computed(() => aiOptimizeStatus.value === 'generating') const aiOptimizeHasPreview = computed(() => aiOptimizePreview.value.trim().length > 0) const aiOptimizeUiText = computed(() => ({ @@ -1354,7 +1369,7 @@ function resolveAiOptimizePanelFrame(anchorRect: DOMRect): { } function openAiOptimizePanel(ctx: Ctx): void { - if (editorDisabled.value) { + if (editorDisabled.value || !canUseAiOptimize.value) { return } @@ -1434,7 +1449,7 @@ function handleAiOptimizeStreamEvent(event: ArticleSelectionOptimizeStreamEvent) } async function generateAiOptimizePreview(): Promise { - if (!aiOptimizePanel.value.open || editorDisabled.value) { + if (!canUseAiOptimize.value || !aiOptimizePanel.value.open || editorDisabled.value) { return } @@ -1454,7 +1469,7 @@ async function generateAiOptimizePreview(): Promise { try { await streamArticleSelectionOptimize( - props.articleId, + Number(props.articleId), { title: props.title, markdown_content: props.modelValue, @@ -2259,7 +2274,7 @@ function runTableContextAction(action: TableContextMenuAction): void { 'article-editor-canvas--ai-optimize-open': aiOptimizePanel.open, }" > -
+
@@ -2385,7 +2400,7 @@ function runTableContextAction(action: TableContextMenuAction): void { /> import { AppstoreOutlined, + BankOutlined, BarChartOutlined, CopyOutlined, DatabaseOutlined, @@ -384,6 +385,15 @@ const navSections = computed(() => { { key: '/images', label: t('nav.images'), icon: PictureOutlined }, ], }, + { + key: 'authorityMedia', + title: t('nav.authorityMedia'), + items: [ + { key: '/media-supply/resources', label: t('nav.mediaSupplyResources'), icon: BankOutlined }, + { key: '/media-supply/orders', label: t('nav.mediaSupplyOrders'), icon: SendOutlined }, + { key: '/media-supply/favorites', label: t('nav.mediaSupplyFavorites'), icon: TagsOutlined }, + ], + }, { key: 'kolMarket', title: t('nav.kolMarket'), diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 1be5b8c..fc01482 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -12,6 +12,8 @@ import type { BrandLibrarySummary, BrandRequest, ChangePasswordRequest, + ClassifiedQuestion, + ClassifyQuestionsRequest, Competitor, CompetitorRequest, ComplianceAckRecord, @@ -24,6 +26,8 @@ import type { CreateArticleRequest, CreateKolPackageRequest, CreateKolPromptRequest, + CreateMediaSupplyOrderRequest, + CreateMediaSupplyOrderResponse, CreatePluginSessionRequest, CreatePluginSessionResponse, CreatePublishBatchRequest, @@ -70,18 +74,28 @@ import type { KolSubscriptionPromptCard, KolSubscriptionPromptSchema, KolWorkspaceCard, + ListCustomerSupplierMediaResourcesResponse, ListDesktopPublishTasksParams, + ListMediaSupplyOrdersParams, + ListMediaSupplyOrdersResponse, + ListMediaSupplyWalletLedgersParams, + ListMediaSupplyWalletLedgersResponse, + ListSupplierMediaResourcesParams, LoginRequest, LoginResponse, MaterializeQuestionsRequest, MaterializeQuestionsResult, MediaPlatform, + MediaSupplyOrderDetail, + MediaSupplySearchOptionsResponse, + MediaSupplySessionStatus, + MediaSupplyWalletStatus, MonitoringCitationSummaryResponse, MonitoringCollectNowResponse, MonitoringDashboardCompositeResponse, MonitoringQuestionDetailResponse, - PlatformAccount, PasswordPublicKeyResponse, + PlatformAccount, PromptRule, PromptRuleGroup, PromptRuleGroupRequest, @@ -110,6 +124,7 @@ import type { ScheduleTaskListResponse, ScheduleTaskRequest, ScheduleTaskStatusRequest, + SyncMediaSupplyBacklinksResponse, TemplateAnalyzeTaskRequest, TemplateAnalyzeTaskResultResponse, TemplateAssistTaskCreateResponse, @@ -131,10 +146,9 @@ import type { UpdateQuestionRequest, UserInfo, WorkspaceOverview, - ClassifyQuestionsRequest, - ClassifiedQuestion, } from '@geo/shared-types' +import { readStoredCurrentBrandId } from './current-brand' import { convertImageFileToWebp, markImageUploadRequestFailed, @@ -149,7 +163,6 @@ import { readStoredSession, setStoredTokens, } from './session' -import { readStoredCurrentBrandId } from './current-brand' const rawBaseURL = import.meta.env.VITE_API_BASE_URL ?? '' const baseURL = normalizeApiBaseURL(rawBaseURL) @@ -323,6 +336,7 @@ const currentBrandScopedPathPatterns = [ /^\/api\/tenant\/instant-tasks(?:\/|$)/, /^\/api\/tenant\/publish-jobs(?:\/|$)/, /^\/api\/tenant\/publish-tasks\/[^/]+\/retry(?:\/|$)/, + /^\/api\/tenant\/media-supply\/orders(?:\/|$)/, /^\/api\/tenant\/prompt-rules(?:\/|$)/, ] @@ -1204,6 +1218,61 @@ export const mediaApi = { }, } +export const mediaSupplyApi = { + listResources(params: ListSupplierMediaResourcesParams = {}) { + return apiClient.get( + '/api/tenant/media-supply/resources', + { + params, + }, + ) + }, + listResourcesByIds(ids: number[], modelId?: number) { + return apiClient.get( + '/api/tenant/media-supply/resources/by-ids', + { params: { ids: ids.join(','), model_id: modelId } }, + ) + }, + searchOptions(modelId?: number) { + return apiClient.get( + '/api/tenant/media-supply/resources/search-options', + { params: { model_id: modelId } }, + ) + }, + createOrder(payload: CreateMediaSupplyOrderRequest) { + return apiClient.post( + '/api/tenant/media-supply/orders', + payload, + ) + }, + listOrders(params: ListMediaSupplyOrdersParams = {}) { + return apiClient.get('/api/tenant/media-supply/orders', { + params, + }) + }, + getOrder(id: number) { + return apiClient.get(`/api/tenant/media-supply/orders/${id}`) + }, + syncBacklinks() { + return apiClient.post>( + '/api/tenant/media-supply/orders/sync-backlinks', + {}, + ) + }, + wallet() { + return apiClient.get('/api/tenant/media-supply/wallet') + }, + listWalletLedgers(params: ListMediaSupplyWalletLedgersParams = {}) { + return apiClient.get( + '/api/tenant/media-supply/wallet/ledgers', + { params }, + ) + }, + sessionStatus() { + return apiClient.get('/api/tenant/media-supply/supplier-session') + }, +} + export const tenantAccountsApi = { list() { return apiClient.get('/api/tenant/accounts') diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 0a1ea8f..becbaa4 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -35,6 +35,53 @@ const errorMessageMap: Record = { quota_insufficient: '生成额度不足', ai_points_insufficient: 'AI 点数余额不足', ai_points_unavailable: 'AI 点数服务暂不可用,请稍后重试', + invalid_media_supply_model: '不支持的媒体资源类型', + invalid_media_supply_price: '媒体售价参数不正确', + invalid_media_supply_resource: '媒体资源参数不正确', + media_supply_balance_insufficient: '媒体投稿余额不足', + media_supply_admin_required: '需要管理员权限', + media_supply_store_unavailable: '媒体投稿服务暂不可用', + media_supply_resource_count_failed: '媒体资源统计失败', + media_supply_resource_query_failed: '媒体资源读取失败', + media_supply_resource_scan_failed: '媒体资源解析失败', + media_supply_resource_lookup_failed: '媒体资源读取失败', + media_supply_resource_not_found: '媒体资源不存在或已下架', + media_supply_sync_queue_failed: '媒体资源同步排队失败', + media_supply_price_update_failed: '媒体价格更新失败', + media_supply_price_below_cost: '媒体售价不能低于成本价', + media_supply_visibility_update_failed: '媒体展示状态更新失败', + media_supply_order_items_required: '请至少选择一个媒体资源', + media_supply_order_items_too_many: '一次投稿选择的媒体资源过多', + media_supply_order_title_required: '请输入投稿标题', + media_supply_order_content_required: '请输入投稿内容', + media_supply_order_begin_failed: '媒体投稿创建失败', + media_supply_order_create_failed: '媒体投稿创建失败', + media_supply_order_item_create_failed: '媒体投稿明细创建失败', + media_supply_order_commit_failed: '媒体投稿创建失败', + media_supply_order_count_failed: '媒体投稿订单统计失败', + media_supply_order_query_failed: '媒体投稿订单读取失败', + media_supply_order_scan_failed: '媒体投稿订单解析失败', + media_supply_order_not_found: '媒体投稿订单不存在', + media_supply_order_item_query_failed: '媒体投稿明细读取失败', + media_supply_order_item_scan_failed: '媒体投稿明细解析失败', + media_supply_cost_missing: '部分媒体资源成本价缺失,请联系管理员处理', + media_supply_wallet_prepare_failed: '媒体投稿余额初始化失败', + media_supply_wallet_lookup_failed: '媒体投稿余额读取失败', + media_supply_wallet_update_failed: '媒体投稿余额更新失败', + media_supply_wallet_begin_failed: '媒体投稿余额调整失败', + media_supply_wallet_commit_failed: '媒体投稿余额调整失败', + media_supply_wallet_ledger_count_failed: '媒体投稿账单统计失败', + media_supply_wallet_ledger_query_failed: '媒体投稿账单读取失败', + media_supply_wallet_ledger_scan_failed: '媒体投稿账单解析失败', + media_supply_wallet_ledger_create_failed: '媒体投稿账单创建失败', + media_supply_wallet_user_lookup_failed: '媒体投稿余额用户校验失败', + media_supply_wallet_user_not_found: '用户不存在或不属于当前租户', + invalid_media_supply_wallet_user: '用户参数不正确', + invalid_media_supply_wallet_delta: '调整金额不能为 0', + media_supply_credentials_required: '媒体供应商账号未配置,请联系管理员处理', + media_supply_session_required: '媒体供应商登录状态已失效,请联系管理员处理', + media_supply_challenge_required: '媒体供应商登录验证失败,请联系管理员处理', + media_supply_request_failed: '媒体供应商请求失败,请稍后重试或联系管理员', trial_plan_expired: '免费试用已到期,请联系管理员开通', subscription_required: '当前租户尚未开通有效套餐,请联系管理员', subscription_inactive: '当前租户套餐已失效,请联系管理员', @@ -144,17 +191,44 @@ const TIMEOUT_MESSAGE_PATTERN = /^timeout of \d+ms exceeded$/i const VALIDATION_MIN_SEED_TOPIC_PATTERN = /QuestionDistillRequest\.SeedTopic|seed_topic.*min|SeedTopic.*min/i +const LEGACY_MEDIA_SUPPLY_REFRESH_PATTERN = + /^failed to refresh supplier price(?: for \d+ selected resources?)?$/i +const LEGACY_MEDIA_SUPPLY_LOCKED_COST_PATTERN = + /^supplier cost increased above locked sell price/i + function translateRawErrorMessage(raw: string): string | null { - const mapped = errorMessageMap[raw] + const normalized = raw.trim() + const mapped = errorMessageMap[normalized] if (mapped) { return mapped } - if (TIMEOUT_MESSAGE_PATTERN.test(raw)) { + if (TIMEOUT_MESSAGE_PATTERN.test(normalized)) { return errorMessageMap.request_timeout } + if (LEGACY_MEDIA_SUPPLY_REFRESH_PATTERN.test(normalized)) { + return '媒体资源价格刷新失败,请稍后重试或重新选择媒体' + } + if (LEGACY_MEDIA_SUPPLY_LOCKED_COST_PATTERN.test(normalized)) { + return '媒体成本价已变化,当前锁定价格低于成本,请重新提交' + } + const lower = normalized.toLowerCase() + const hiddenSupplierToken = ['mei', 'jie', 'quan'].join('') + if (lower.includes(hiddenSupplierToken)) { + return '媒体供应商请求失败,请稍后重试或联系管理员' + } + if (lower.includes('supplier') || lower.includes('failed to')) { + return '媒体投稿失败,请稍后重试或联系管理员' + } return null } +export function formatStoredErrorMessage(message?: string | null): string { + if (!message) { + return '' + } + return translateRawErrorMessage(message) ?? message +} + export function formatError(error: unknown): string { if (isHandledAuthError(error)) { return '登录已过期,请重新登录' diff --git a/apps/admin-web/src/lib/media-supply-favorites.ts b/apps/admin-web/src/lib/media-supply-favorites.ts new file mode 100644 index 0000000..d400cc6 --- /dev/null +++ b/apps/admin-web/src/lib/media-supply-favorites.ts @@ -0,0 +1,246 @@ +export interface MediaSupplyFavoriteResourceSnapshot { + id: number + supplier_resource_id?: string | null + name: string + status?: string | null + sell_price_cents?: number | null + resource_url?: string | null + baidu_weight?: number | null + resource_remark?: string | null + channel_type?: string | null + region?: string | null + inclusion_effect?: string | null + link_type?: string | null + publish_rate?: string | null + delivery_speed?: string | null + last_synced_at?: string | null +} + +export type MediaSupplyFavoriteResourceInput = Partial & { + id: number +} + +export interface MediaSupplyFavoriteGroup { + id: string + name: string + resourceIds: number[] + resources: MediaSupplyFavoriteResourceSnapshot[] + updatedAt: string +} + +function storageKey(userId?: number | null): string { + return `media_supply_favorite_groups:${userId || 'anonymous'}` +} + +function defaultGroup(): MediaSupplyFavoriteGroup { + return { + id: 'default', + name: '默认分组', + resourceIds: [], + resources: [], + updatedAt: new Date().toISOString(), + } +} + +export function loadMediaSupplyFavoriteGroups(userId?: number | null): MediaSupplyFavoriteGroup[] { + if (typeof window === 'undefined') { + return [defaultGroup()] + } + try { + const parsed = JSON.parse(window.localStorage.getItem(storageKey(userId)) || '[]') as unknown + if (!Array.isArray(parsed)) { + return [defaultGroup()] + } + const groups = parsed + .map((item) => normalizeGroup(item)) + .filter((item): item is MediaSupplyFavoriteGroup => Boolean(item)) + return groups.length > 0 ? groups : [defaultGroup()] + } catch { + return [defaultGroup()] + } +} + +export function saveMediaSupplyFavoriteGroups( + userId: number | null | undefined, + groups: MediaSupplyFavoriteGroup[], +): void { + if (typeof window === 'undefined') { + return + } + window.localStorage.setItem(storageKey(userId), JSON.stringify(groups)) +} + +export function toggleMediaSupplyFavorite( + groups: MediaSupplyFavoriteGroup[], + resourceId: number, + groupId = 'default', + resource?: MediaSupplyFavoriteResourceInput, +): MediaSupplyFavoriteGroup[] { + const now = new Date().toISOString() + const next = groups.length > 0 ? [...groups] : [defaultGroup()] + const index = next.findIndex((group) => group.id === groupId) + if (index < 0) { + next.push({ + id: groupId, + name: '默认分组', + resourceIds: [resourceId], + resources: compactResourceSnapshots( + [], + resource ? { ...resource, id: resourceId } : undefined, + ), + updatedAt: now, + }) + return next + } + const group = next[index] + const exists = group.resourceIds.includes(resourceId) + next[index] = { + ...group, + resourceIds: exists + ? group.resourceIds.filter((id) => id !== resourceId) + : [...group.resourceIds, resourceId], + resources: exists + ? group.resources.filter((item) => item.id !== resourceId) + : compactResourceSnapshots( + group.resources, + resource ? { ...resource, id: resourceId } : undefined, + ), + updatedAt: now, + } + return next +} + +export function removeMediaSupplyFavorite( + groups: MediaSupplyFavoriteGroup[], + resourceId: number, +): MediaSupplyFavoriteGroup[] { + const now = new Date().toISOString() + return (groups.length > 0 ? groups : [defaultGroup()]).map((group) => { + if (!group.resourceIds.includes(resourceId)) { + return group + } + return { + ...group, + resourceIds: group.resourceIds.filter((id) => id !== resourceId), + resources: group.resources.filter((item) => item.id !== resourceId), + updatedAt: now, + } + }) +} + +export function mergeMediaSupplyFavoriteResourceSnapshots( + groups: MediaSupplyFavoriteGroup[], + resources: MediaSupplyFavoriteResourceInput[], +): MediaSupplyFavoriteGroup[] { + const snapshots = resources + .map((resource) => normalizeResourceSnapshot(resource)) + .filter((resource): resource is MediaSupplyFavoriteResourceSnapshot => Boolean(resource)) + if (snapshots.length === 0) { + return groups + } + const snapshotById = new Map(snapshots.map((resource) => [resource.id, resource])) + return groups.map((group) => { + let changed = false + const resources = group.resourceIds + .map((resourceId) => { + const fresh = snapshotById.get(resourceId) + if (fresh) { + changed = true + return fresh + } + return group.resources.find((resource) => resource.id === resourceId) + }) + .filter((resource): resource is MediaSupplyFavoriteResourceSnapshot => Boolean(resource)) + + if (!changed) { + return group + } + + return { + ...group, + resources, + } + }) +} + +function normalizeGroup(value: unknown): MediaSupplyFavoriteGroup | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const record = value as Record + const id = typeof record.id === 'string' && record.id.trim() ? record.id.trim() : '' + const name = typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '' + if (!id || !name) { + return null + } + const normalizedResources = Array.isArray(record.resources) + ? record.resources + .map((item) => normalizeResourceSnapshot(item)) + .filter((item): item is MediaSupplyFavoriteResourceSnapshot => Boolean(item)) + : [] + const resourceIds = Array.isArray(record.resourceIds) + ? record.resourceIds + .map((item) => (typeof item === 'number' ? item : Number(item))) + .filter((item) => Number.isFinite(item) && item > 0) + : [] + const mergedResourceIds = [ + ...new Set([...resourceIds, ...normalizedResources.map((resource) => resource.id)]), + ] + const resources = normalizedResources.filter((resource) => + mergedResourceIds.includes(resource.id), + ) + const updatedAt = + typeof record.updatedAt === 'string' && record.updatedAt.trim() + ? record.updatedAt + : new Date().toISOString() + return { id, name, resourceIds: mergedResourceIds, resources, updatedAt } +} + +function compactResourceSnapshots( + resources: MediaSupplyFavoriteResourceSnapshot[], + resource?: MediaSupplyFavoriteResourceInput, +): MediaSupplyFavoriteResourceSnapshot[] { + const snapshot = resource ? normalizeResourceSnapshot(resource) : null + if (!snapshot) { + return resources + } + return [...resources.filter((item) => item.id !== snapshot.id), snapshot] +} + +function normalizeResourceSnapshot(value: unknown): MediaSupplyFavoriteResourceSnapshot | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const record = value as Record + const id = typeof record.id === 'number' ? record.id : Number(record.id) + if (!Number.isInteger(id) || id <= 0) { + return null + } + const name = textValue(record.name) || `媒体 #${id}` + return { + id, + supplier_resource_id: textValue(record.supplier_resource_id), + name, + status: textValue(record.status), + sell_price_cents: numberValue(record.sell_price_cents), + resource_url: textValue(record.resource_url), + baidu_weight: numberValue(record.baidu_weight), + resource_remark: textValue(record.resource_remark), + channel_type: textValue(record.channel_type), + region: textValue(record.region), + inclusion_effect: textValue(record.inclusion_effect), + link_type: textValue(record.link_type), + publish_rate: textValue(record.publish_rate), + delivery_speed: textValue(record.delivery_speed), + last_synced_at: textValue(record.last_synced_at), + } +} + +function textValue(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function numberValue(value: unknown): number | null { + const numeric = typeof value === 'number' ? value : Number(value) + return Number.isFinite(numeric) ? numeric : null +} diff --git a/apps/admin-web/src/lib/media-supply-submit-selection.ts b/apps/admin-web/src/lib/media-supply-submit-selection.ts new file mode 100644 index 0000000..d4bee14 --- /dev/null +++ b/apps/admin-web/src/lib/media-supply-submit-selection.ts @@ -0,0 +1,90 @@ +const STORAGE_KEY = 'geo:media-supply:submit-selection:v1' +const STORAGE_TTL_MS = 1000 * 60 * 60 * 6 + +export interface MediaSupplySubmitResourceSnapshot { + id: number + supplier_resource_id: string + name: string + sell_price_cents: number + resource_url?: string | null + baidu_weight?: number | null + resource_remark?: string | null + channel_type?: string | null + region?: string | null + inclusion_effect?: string | null + link_type?: string | null + publish_rate?: string | null + delivery_speed?: string | null +} + +interface MediaSupplySubmitSelectionEnvelope { + createdAt: number + resources: MediaSupplySubmitResourceSnapshot[] +} + +export function saveMediaSupplySubmitSelection( + resources: MediaSupplySubmitResourceSnapshot[], +): void { + if (typeof window === 'undefined') { + return + } + const normalized = resources + .map((resource) => ({ + ...resource, + id: Number(resource.id), + sell_price_cents: Number(resource.sell_price_cents), + })) + .filter( + (resource) => + Number.isInteger(resource.id) && + resource.id > 0 && + Number.isFinite(resource.sell_price_cents), + ) + + window.sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + createdAt: Date.now(), + resources: normalized, + } satisfies MediaSupplySubmitSelectionEnvelope), + ) +} + +export function loadMediaSupplySubmitSelection(): MediaSupplySubmitResourceSnapshot[] { + if (typeof window === 'undefined') { + return [] + } + + const raw = window.sessionStorage.getItem(STORAGE_KEY) + if (!raw) { + return [] + } + + try { + const envelope = JSON.parse(raw) as Partial + const createdAt = Number(envelope.createdAt) + if (!Number.isFinite(createdAt) || Date.now() - createdAt > STORAGE_TTL_MS) { + clearMediaSupplySubmitSelection() + return [] + } + if (!Array.isArray(envelope.resources)) { + return [] + } + return envelope.resources.filter( + (resource) => + Number.isInteger(resource.id) && + resource.id > 0 && + Number.isFinite(resource.sell_price_cents), + ) + } catch { + clearMediaSupplySubmitSelection() + return [] + } +} + +export function clearMediaSupplySubmitSelection(): void { + if (typeof window === 'undefined') { + return + } + window.sessionStorage.removeItem(STORAGE_KEY) +} diff --git a/apps/admin-web/src/main.ts b/apps/admin-web/src/main.ts index eea283d..075db11 100644 --- a/apps/admin-web/src/main.ts +++ b/apps/admin-web/src/main.ts @@ -32,6 +32,7 @@ import { Result, Row, Select, + Segmented, Skeleton, Slider, Space, @@ -140,6 +141,7 @@ app.component('IconFont', IconFont) Result, Row, Select, + Segmented, Skeleton, Space, Spin, diff --git a/apps/admin-web/src/router/index.ts b/apps/admin-web/src/router/index.ts index df5e987..d67d3a2 100644 --- a/apps/admin-web/src/router/index.ts +++ b/apps/admin-web/src/router/index.ts @@ -169,6 +169,46 @@ const router = createRouter({ navKey: '/publish-management', }, }, + { + path: 'media-supply/resources', + name: 'media-supply-resources', + component: () => import('@/views/MediaSupplyResourcesView.vue'), + meta: { + titleKey: 'route.mediaSupplyResources.title', + descriptionKey: 'route.mediaSupplyResources.description', + navKey: '/media-supply/resources', + }, + }, + { + path: 'media-supply/submit', + name: 'media-supply-submit', + component: () => import('@/views/MediaSupplySubmitView.vue'), + meta: { + titleKey: 'route.mediaSupplySubmit.title', + descriptionKey: 'route.mediaSupplySubmit.description', + navKey: '/media-supply/resources', + }, + }, + { + path: 'media-supply/orders', + name: 'media-supply-orders', + component: () => import('@/views/MediaSupplyOrdersView.vue'), + meta: { + titleKey: 'route.mediaSupplyOrders.title', + descriptionKey: 'route.mediaSupplyOrders.description', + navKey: '/media-supply/orders', + }, + }, + { + path: 'media-supply/favorites', + name: 'media-supply-favorites', + component: () => import('@/views/MediaSupplyFavoritesView.vue'), + meta: { + titleKey: 'route.mediaSupplyFavorites.title', + descriptionKey: 'route.mediaSupplyFavorites.description', + navKey: '/media-supply/favorites', + }, + }, { path: 'brands', name: 'brands', diff --git a/apps/admin-web/src/views/MediaSupplyFavoritesView.vue b/apps/admin-web/src/views/MediaSupplyFavoritesView.vue new file mode 100644 index 0000000..41562a0 --- /dev/null +++ b/apps/admin-web/src/views/MediaSupplyFavoritesView.vue @@ -0,0 +1,972 @@ + + + + + diff --git a/apps/admin-web/src/views/MediaSupplyOrdersView.vue b/apps/admin-web/src/views/MediaSupplyOrdersView.vue new file mode 100644 index 0000000..7d0eaa1 --- /dev/null +++ b/apps/admin-web/src/views/MediaSupplyOrdersView.vue @@ -0,0 +1,662 @@ + + + + + diff --git a/apps/admin-web/src/views/MediaSupplyResourcesView.vue b/apps/admin-web/src/views/MediaSupplyResourcesView.vue new file mode 100644 index 0000000..33d77b0 --- /dev/null +++ b/apps/admin-web/src/views/MediaSupplyResourcesView.vue @@ -0,0 +1,1529 @@ + + + + + diff --git a/apps/admin-web/src/views/MediaSupplySubmitView.vue b/apps/admin-web/src/views/MediaSupplySubmitView.vue new file mode 100644 index 0000000..181c790 --- /dev/null +++ b/apps/admin-web/src/views/MediaSupplySubmitView.vue @@ -0,0 +1,761 @@ + + + + + diff --git a/apps/admin-web/src/views/PublishManagementView.vue b/apps/admin-web/src/views/PublishManagementView.vue index a13680e..b7f75a4 100644 --- a/apps/admin-web/src/views/PublishManagementView.vue +++ b/apps/admin-web/src/views/PublishManagementView.vue @@ -214,12 +214,12 @@ function summaryForTask( ? '文章已保存到草稿箱,需在平台后台完成发表。' : '文章已成功发送到目标平台。' case 'failed': - return '本次发送失败,请检查账号状态或平台提示后重新创建发布。' + return '本次发送失败,请检查账号状态或平台提示后重新提交发布。' case 'aborted': - return '任务已被取消,请检查原因后重新创建发布。' + return '任务已被取消,请检查原因后重新提交发布。' case 'unknown': default: - return '发送结果异常,已按失败处理,请检查平台侧结果后重新创建发布。' + return '发送结果异常,已按失败处理,请检查平台侧结果后重新提交发布。' } } @@ -490,8 +490,11 @@ async function retryTask(record: PublishTaskItem): Promise { const result = await publishTasksApi.retry(record.id) const created = result.created_task_ids.length const existing = result.existing_task_ids.length + const requeued = result.requeued_task_ids?.length ?? 0 if (created > 0) { - message.success(created === 1 ? '已重新创建发布任务' : `已重新创建 ${created} 个发布任务`) + message.success(created === 1 ? '已创建新的发布任务' : `已创建 ${created} 个新的发布任务`) + } else if (requeued > 0) { + message.success(requeued === 1 ? '已重新提交发布任务' : `已重新提交 ${requeued} 个发布任务`) } else if (existing > 0) { if (record.status === 'unknown') { message.warning('原任务结果未知且已有发布记录,可能已成功,请先核实平台侧结果。') @@ -725,12 +728,12 @@ async function copyErrorMessage(record: PublishTaskItem): Promise { - + diff --git a/apps/desktop-client/src/renderer/views/PublishManagementView.vue b/apps/desktop-client/src/renderer/views/PublishManagementView.vue index 9ce5de2..a874121 100644 --- a/apps/desktop-client/src/renderer/views/PublishManagementView.vue +++ b/apps/desktop-client/src/renderer/views/PublishManagementView.vue @@ -302,12 +302,12 @@ function summaryForTask( } return '文章已成功发送到目标平台。' case 'failed': - return '本次发送失败,请检查账号状态或平台提示后重新创建发布。' + return '本次发送失败,请检查账号状态或平台提示后重新提交发布。' case 'aborted': - return '任务已被取消,请检查原因后重新创建发布。' + return '任务已被取消,请检查原因后重新提交发布。' case 'unknown': default: - return '发送结果异常,已按失败处理,请检查平台侧结果后重新创建发布。' + return '发送结果异常,已按失败处理,请检查平台侧结果后重新提交发布。' } } @@ -487,8 +487,11 @@ async function retryTask(record: PublishTaskItem) { const result = await window.desktopBridge.app.retryPublishTask(record.id) const created = result.created_task_ids.length const existing = result.existing_task_ids.length + const requeued = result.requeued_task_ids?.length ?? 0 if (created > 0) { - notifySuccess(created === 1 ? '已重新创建发布任务' : `已重新创建 ${created} 个发布任务`) + notifySuccess(created === 1 ? '已创建新的发布任务' : `已创建 ${created} 个新的发布任务`) + } else if (requeued > 0) { + notifySuccess(requeued === 1 ? '已重新提交发布任务' : `已重新提交 ${requeued} 个发布任务`) } else if (existing > 0) { if (record.status === 'unknown') { notifyActionError('原任务结果未知且已有发布记录,可能已成功,请先核实平台侧结果。') @@ -501,8 +504,8 @@ async function retryTask(record: PublishTaskItem) { await refreshTasks(1) } catch (err) { notifyActionError( - normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '重新创建发布任务失败')) ?? - '重新创建发布任务失败', + normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '重新提交发布任务失败')) ?? + '重新提交发布任务失败', ) } finally { retryingTaskId.value = null @@ -882,12 +885,12 @@ const tableScroll = { x: 1080 } as const - + diff --git a/apps/ops-web/src/layouts/AppShell.vue b/apps/ops-web/src/layouts/AppShell.vue index bd4f624..e0109f0 100644 --- a/apps/ops-web/src/layouts/AppShell.vue +++ b/apps/ops-web/src/layouts/AppShell.vue @@ -54,6 +54,7 @@ import { ControlOutlined, CrownOutlined, DatabaseOutlined, + DollarCircleOutlined, FileSearchOutlined, GlobalOutlined, LineChartOutlined, @@ -94,6 +95,7 @@ const menuLeaves: MenuLeaf[] = [ { key: '/compliance/stats', label: '合规统计', path: '/compliance/stats' }, { key: '/accounts', label: '操作员管理', path: '/accounts' }, { key: '/jobs', label: '任务中心', path: '/jobs' }, + { key: '/media-supply', label: '媒体资源运营', path: '/media-supply' }, { key: '/scheduler', label: '调度中心', path: '/scheduler' }, { key: '/audits', label: '审计日志', path: '/audits' }, ] @@ -171,6 +173,11 @@ const menuItems = computed(() => [ label: '任务中心', icon: () => h(PartitionOutlined), }, + { + key: '/media-supply', + label: '媒体资源运营', + icon: () => h(DollarCircleOutlined), + }, { key: '/scheduler', label: '调度中心', diff --git a/apps/ops-web/src/lib/media-supply.ts b/apps/ops-web/src/lib/media-supply.ts new file mode 100644 index 0000000..569ff0d --- /dev/null +++ b/apps/ops-web/src/lib/media-supply.ts @@ -0,0 +1,103 @@ +import { http } from '@/lib/http' + +export interface OpsMediaSupplyResource { + id: number + supplier_resource_id: string + model_id: number + name: string + status: string + cost_price_cents: number + cost_prices: Record + sell_price_cents: number + resource_url?: string | null + baidu_weight?: number | null + resource_remark?: string | null + customer_visible: boolean + channel_type?: string | null + region?: string | null + inclusion_effect?: string | null + link_type?: string | null + publish_rate?: string | null + delivery_speed?: string | null + last_synced_at: string +} + +export interface OpsMediaSupplyResourcesParams { + model_id?: number + keyword?: string + remark_keyword?: string + channel_type?: string + region?: string + inclusion_effect?: string + link_type?: string + min_price_cents?: number + max_price_cents?: number + visibility?: string + page?: number + page_size?: number +} + +export interface OpsMediaSupplyResourcesResponse { + items: OpsMediaSupplyResource[] + total: number + page: number + size: number +} + +export interface OpsMediaSupplyWallet { + tenant_id: number + tenant_name?: string | null + user_id: number + user_name?: string | null + user_phone?: string | null + balance_cents: number + updated_at: string + last_ledger_at?: string | null + ledger_entries: number +} + +export interface OpsMediaSupplyWalletsResponse { + items: OpsMediaSupplyWallet[] + total: number + page: number + size: number +} + +export interface OpsMediaSupplyWalletsParams { + keyword?: string + tenant_id?: number + page?: number + page_size?: number +} + +export const opsMediaSupplyApi = { + listResources(params: OpsMediaSupplyResourcesParams) { + return http.get( + '/media-supply/resources', + params as Record, + ) + }, + setPrice(id: number, payload: { price_type?: string; sell_price_cents: number; enabled?: boolean }) { + return http.put<{ updated: boolean }, typeof payload>(`/media-supply/resources/${id}/price`, payload) + }, + setVisibility(id: number, customerVisible: boolean) { + return http.put<{ updated: boolean }, { customer_visible: boolean }>( + `/media-supply/resources/${id}/visibility`, + { customer_visible: customerVisible }, + ) + }, + queueSync(modelId = 1) { + return http.post<{ job_id: number }, { model_id: number }>('/media-supply/sync-jobs', { + model_id: modelId, + }) + }, + listWallets(params: OpsMediaSupplyWalletsParams) { + return http.get( + '/media-supply/wallets', + params as Record, + ) + }, + adjustWallet(payload: { tenant_id: number; user_id: number; delta_cents: number; note?: string }) { + return http.post('/media-supply/wallets/adjustments', payload) + }, +} diff --git a/apps/ops-web/src/router/index.ts b/apps/ops-web/src/router/index.ts index 23af7b7..7b1f6f7 100644 --- a/apps/ops-web/src/router/index.ts +++ b/apps/ops-web/src/router/index.ts @@ -49,6 +49,12 @@ export const router = createRouter({ component: () => import('@/views/JobsView.vue'), meta: { title: '任务中心' }, }, + { + path: 'media-supply', + name: 'media-supply', + component: () => import('@/views/MediaSupplyOpsView.vue'), + meta: { title: '媒体资源运营' }, + }, { path: 'scheduler', name: 'scheduler', diff --git a/apps/ops-web/src/views/MediaSupplyOpsView.vue b/apps/ops-web/src/views/MediaSupplyOpsView.vue new file mode 100644 index 0000000..4c4c192 --- /dev/null +++ b/apps/ops-web/src/views/MediaSupplyOpsView.vue @@ -0,0 +1,725 @@ + + + + + diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index ea730d5..007e376 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -411,6 +411,7 @@ export interface CreatePublishJobResponse { task_ids: string[] created_task_ids: string[] existing_task_ids: string[] + requeued_task_ids?: string[] } export interface WorkspaceOverview { @@ -470,6 +471,244 @@ export interface AIPointUsageListResponse { page_size: number } +export interface SupplierMediaResource { + id: number + supplier: string + supplier_resource_id: string + model_id: number + name: string + status: string + cost_price_cents: number + cost_prices: Record + sell_price_cents: number + sale_price_label?: string | null + resource_url?: string | null + baidu_weight?: number | null + resource_remark?: string | null + channel_type?: string | null + region?: string | null + inclusion_effect?: string | null + link_type?: string | null + publish_rate?: string | null + delivery_speed?: string | null + supplier_updated_at?: string | null + last_synced_at: string + raw?: JsonValue +} + +export interface CustomerSupplierMediaResource { + id: number + supplier_resource_id: string + model_id: number + name: string + status: string + sell_price_cents: number + resource_url?: string | null + baidu_weight?: number | null + resource_remark?: string | null + channel_type?: string | null + region?: string | null + inclusion_effect?: string | null + link_type?: string | null + publish_rate?: string | null + delivery_speed?: string | null + last_synced_at: string +} + +export interface ListSupplierMediaResourcesParams { + model_id?: number + keyword?: string + remark_keyword?: string + channel_type?: string + portal?: string + region?: string + inclusion_effect?: string + special_industry?: string + link_type?: string + delivery_speed?: string + ai_include?: string + other_option?: string + min_price_cents?: number + max_price_cents?: number + sort?: 'default' | 'price' | 'weight' + page?: number + page_size?: number +} + +export interface ListSupplierMediaResourcesResponse { + items: SupplierMediaResource[] + total: number + page: number + page_size: number +} + +export interface ListCustomerSupplierMediaResourcesResponse { + items: CustomerSupplierMediaResource[] + total: number + page: number + page_size: number +} + +export interface MediaSupplySearchOptionGroup { + key: string + name: string + list: string[] +} + +export interface MediaSupplySearchOptionsResponse { + model_id: number + groups: MediaSupplySearchOptionGroup[] + updated_at: string +} + +export interface CreateMediaSupplyOrderRequest { + article_id?: number | null + model_id: number + title: string + content: string + remark?: string + order_brand?: string + items: Array<{ + resource_id: number + price_type?: string + }> + extra?: Record +} + +export interface CreateMediaSupplyOrderResponse { + order_id: number + status: string + cost_total_cents: number + sell_total_cents: number + balance_after_cents: number +} + +export interface SetSupplierMediaPriceRequest { + price_type?: string + sell_price_cents: number + enabled?: boolean +} + +export interface MediaSupplyOrderItem { + id: number + order_id: number + resource_id: number + supplier_resource_id: string + price_type: string + resource_name_snapshot: string + locked_cost_price_cents: number + locked_sell_price_cents: number + status: string + external_article_url?: string | null +} + +export interface MediaSupplyOrderDetail { + id: number + tenant_id: number + workspace_id: number + brand_id: number + user_id: number + article_id?: number | null + supplier: string + model_id: number + status: string + title: string + remark?: string | null + order_brand?: string | null + external_order_id?: string | null + external_order_code?: string | null + supplier_status?: string | null + cost_total_cents: number + sell_total_cents: number + wallet_debit_cents: number + wallet_debited_at?: string | null + wallet_refunded_at?: string | null + error_message?: string | null + attempt_count: number + queued_at: string + submitted_at?: string | null + completed_at?: string | null + created_at: string + updated_at: string + items?: MediaSupplyOrderItem[] +} + +export interface ListMediaSupplyOrdersParams { + status?: string + article_id?: number + page?: number + page_size?: number +} + +export interface ListMediaSupplyOrdersResponse { + items: MediaSupplyOrderDetail[] + total: number + page: 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 { + tenant_id: number + user_id: number + balance_cents: number + updated_at: string + ledger_total?: number | null + last_ledger_at?: string | null +} + +export interface MediaSupplyWalletLedger { + id: number + tenant_id: number + user_id: number + order_id?: number | null + order_title?: string | null + delta_cents: number + balance_after_cents: number + reason: string + note?: string | null + created_by?: number | null + created_at: string +} + +export interface ListMediaSupplyWalletLedgersParams { + page?: number + page_size?: number + user_id?: number + created_from?: string + created_to?: string +} + +export interface ListMediaSupplyWalletLedgersResponse { + items: MediaSupplyWalletLedger[] + total: number + page: number + page_size: number +} + +export interface AdjustMediaSupplyWalletRequest { + user_id: number + delta_cents: number + note?: string +} + +export interface MediaSupplySessionStatus { + available: boolean + expires_at?: string | null + updated_at?: string | null + account_configured: boolean +} + export interface TemplateCard { id: number scope: string diff --git a/server/cmd/ops-api/main.go b/server/cmd/ops-api/main.go index bbdf5a3..096fd20 100644 --- a/server/cmd/ops-api/main.go +++ b/server/cmd/ops-api/main.go @@ -19,6 +19,7 @@ import ( sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth" sharedbootstrap "github.com/geo-platform/tenant-api/internal/shared/bootstrap" "github.com/geo-platform/tenant-api/internal/shared/cache" + sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/shared/ipregion" "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" "github.com/geo-platform/tenant-api/internal/shared/objectstorage" @@ -125,6 +126,12 @@ func main() { objectStorageSvc := app.NewObjectStorageService(objectStorageClient, auditSvc).WithLogger(logger) complianceSvc := app.NewComplianceService(pool, auditSvc, logger) schedulerSvc := app.NewSchedulerService(schedulerRepo, auditSvc) + mediaSupplySvc := app.NewMediaSupplyService(pool, auditSvc, func() sharedconfig.MediaSupplyConfig { + if current := configStore.Current(); current != nil { + return current.MediaSupply + } + return sharedconfig.MediaSupplyConfig{} + }) if err := app.SeedDefaultAdmin(ctx, accountsRepo, app.DefaultAdminSeed{ Username: cfg.DefaultAdmin.Username, @@ -175,6 +182,7 @@ func main() { ObjectStorage: objectStorageSvc, Compliance: complianceSvc, Scheduler: schedulerSvc, + MediaSupply: mediaSupplySvc, }) addr := fmt.Sprintf(":%d", cfg.Server.Port) diff --git a/server/cmd/tenant-api/main.go b/server/cmd/tenant-api/main.go index e08f689..94a8929 100644 --- a/server/cmd/tenant-api/main.go +++ b/server/cmd/tenant-api/main.go @@ -42,6 +42,11 @@ func main() { tenantapp.NewDesktopAccountHealthSinkWorker(app.DB, app.RabbitMQ, app.Logger).Start(workerCtx) imageService := tenantapp.NewImageService(app.DB, app.ObjectStorage, app.RabbitMQ, app.Logger) tenantapp.NewImageAssetWorker(imageService, app.RabbitMQ, app.Logger).Start(workerCtx) + mediaSupplyService := app.MediaSupplyService + if mediaSupplyService == nil { + mediaSupplyService = tenantapp.NewMediaSupplyService(app.DB, app.Redis, app.Logger, app.ConfigStore) + } + tenantapp.NewMediaSupplyWorker(mediaSupplyService, app.DB, app.Logger).Start(workerCtx) knowledgeCleanupService := tenantapp.NewKnowledgeService( app.DB, app.RetrievalProvider, diff --git a/server/configs/config.local.yaml.example b/server/configs/config.local.yaml.example index 74cb416..6e70caa 100644 --- a/server/configs/config.local.yaml.example +++ b/server/configs/config.local.yaml.example @@ -255,3 +255,35 @@ browser_fetch: - 502 - 503 - 504 + +media_supply: + enabled: true + default_markup_percent: 50 + minimum_markup_cents: 0 + worker: + enabled: true + poll_interval: 5s + batch_size: 1 + order_max_attempts: 3 + sync_max_attempts: 2 + backlink_sync_interval: 10m + backlink_batch_size: 20 + meijiequan: + base_url: http://www.meijiequan.com + # 建议用环境变量注入,或放到未入库的 configs/config.local.yaml: + # export MEIJIEQUAN_USER_ID=962 + # export MEIJIEQUAN_USERNAME=your-phone + # export MEIJIEQUAN_PASSWORD=your-password + user_id: "" + username: "" + password: "" + session_ttl: 4h + request_timeout: 30s + sync_page_delay: 800ms + sync_page_size: 100 + max_sync_pages: 200 + published_page_size: 20 + published_max_pages: 3 + search_options_ttl: 12h + upstream_lock_ttl: 2m + upstream_min_interval: 800ms diff --git a/server/configs/config.yaml b/server/configs/config.yaml index 5891c34..6acc28b 100644 --- a/server/configs/config.yaml +++ b/server/configs/config.yaml @@ -187,7 +187,7 @@ auth: private_key_pem: "" log: - level: debug,info,warn,error + level: info format: json # https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seed-2-0-pro @@ -261,4 +261,31 @@ browser_fetch: - 502 - 503 - 504 - + +media_supply: + enabled: true + default_markup_percent: 50 + minimum_markup_cents: 0 + worker: + enabled: true + poll_interval: 5s + batch_size: 1 + order_max_attempts: 3 + sync_max_attempts: 2 + backlink_sync_interval: 30m + backlink_batch_size: 20 + meijiequan: + base_url: http://www.meijiequan.com + user_id: "" + username: "17788409108" + password: "happy114" + session_ttl: 4h + request_timeout: 30s + sync_page_delay: 800ms + sync_page_size: 100 + max_sync_pages: 200 + published_page_size: 20 + published_max_pages: 3 + search_options_ttl: 12h + upstream_lock_ttl: 2m + upstream_min_interval: 800ms diff --git a/server/configs/ops-config.yaml b/server/configs/ops-config.yaml index 803c2a6..7260b50 100644 --- a/server/configs/ops-config.yaml +++ b/server/configs/ops-config.yaml @@ -78,6 +78,30 @@ default_admin: admin_users: default_plan_code: free +media_supply: + enabled: true + default_markup_percent: 50 + minimum_markup_cents: 0 + worker: + enabled: true + poll_interval: 5s + batch_size: 1 + order_max_attempts: 3 + sync_max_attempts: 2 + meijiequan: + base_url: http://www.meijiequan.com + user_id: "" + username: "17788409108" + password: "happy114" + session_ttl: 4h + request_timeout: 30s + sync_page_delay: 800ms + sync_page_size: 100 + max_sync_pages: 200 + search_options_ttl: 6h + upstream_lock_ttl: 30s + upstream_min_interval: 2s + ip_region: # 可选外部 xdb 路径;文件不存在时自动使用二进制 embed 内置数据。 v4_xdb_path: "" diff --git a/server/go.mod b/server/go.mod index 0872871..ecbfe66 100644 --- a/server/go.mod +++ b/server/go.mod @@ -27,6 +27,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/volcengine/volcengine-go-sdk v1.2.22 github.com/xuri/excelize/v2 v2.8.1 + github.com/yuin/goldmark v1.8.2 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.46.0 golang.org/x/image v0.38.0 diff --git a/server/go.sum b/server/go.sum index e923c8e..4522743 100644 --- a/server/go.sum +++ b/server/go.sum @@ -206,6 +206,8 @@ github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGu github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE= github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4= github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/server/internal/bootstrap/bootstrap.go b/server/internal/bootstrap/bootstrap.go index dea6ca3..1cbfd22 100644 --- a/server/internal/bootstrap/bootstrap.go +++ b/server/internal/bootstrap/bootstrap.go @@ -61,6 +61,7 @@ type App struct { BrandService *tenantapp.BrandService QuestionExpansion *tenantapp.QuestionExpansionService MonitoringService *tenantapp.MonitoringService + MediaSupplyService *tenantapp.MediaSupplyService KolProfiles repository.KolProfileRepository KolPackages repository.KolPackageRepository KolMarketplace repository.KolMarketplaceRepository @@ -168,6 +169,7 @@ func New(configPath string) (*App, error) { WithCache(appCache). WithIPRegionResolver(ipRegions) monitoringService := tenantapp.NewMonitoringService(pool, monitoringPool, mqClient, cfg.MonitoringDispatch, cfg.BrandLibrary, logger).WithRedis(rdb) + mediaSupplyService := tenantapp.NewMediaSupplyService(pool, rdb, logger, configStore) kolProfiles := repository.NewKolProfileRepository(pool) kolPackages := repository.NewKolPackageRepository(pool) kolMarketplace := repository.NewKolMarketplaceRepository(pool) @@ -246,6 +248,7 @@ func New(configPath string) (*App, error) { BrandService: brandService, QuestionExpansion: questionExpansion, MonitoringService: monitoringService, + MediaSupplyService: mediaSupplyService, KolProfiles: kolProfiles, KolPackages: kolPackages, KolMarketplace: kolMarketplace, diff --git a/server/internal/ops/app/media_supply.go b/server/internal/ops/app/media_supply.go new file mode 100644 index 0000000..389c3ff --- /dev/null +++ b/server/internal/ops/app/media_supply.go @@ -0,0 +1,631 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +const ( + opsMediaSupplySupplierMeijiequan = "meijiequan" +) + +type MediaSupplyService struct { + pool *pgxpool.Pool + audits *AuditService + cfgFunc func() sharedconfig.MediaSupplyConfig +} + +func NewMediaSupplyService(pool *pgxpool.Pool, audits *AuditService, cfgFunc func() sharedconfig.MediaSupplyConfig) *MediaSupplyService { + return &MediaSupplyService{pool: pool, audits: audits, cfgFunc: cfgFunc} +} + +type MediaSupplyResource struct { + ID int64 `json:"id"` + SupplierResourceID string `json:"supplier_resource_id"` + ModelID int `json:"model_id"` + Name string `json:"name"` + Status string `json:"status"` + CostPriceCents int64 `json:"cost_price_cents"` + CostPrices map[string]int64 `json:"cost_prices"` + SellPriceCents int64 `json:"sell_price_cents"` + ResourceURL *string `json:"resource_url,omitempty"` + BaiduWeight *int `json:"baidu_weight,omitempty"` + ResourceRemark *string `json:"resource_remark,omitempty"` + CustomerVisible bool `json:"customer_visible"` + ChannelType *string `json:"channel_type,omitempty"` + Region *string `json:"region,omitempty"` + InclusionEffect *string `json:"inclusion_effect,omitempty"` + LinkType *string `json:"link_type,omitempty"` + PublishRate *string `json:"publish_rate,omitempty"` + DeliverySpeed *string `json:"delivery_speed,omitempty"` + LastSyncedAt time.Time `json:"last_synced_at"` +} + +type ListMediaSupplyResourcesInput struct { + ModelID int + Keyword string + RemarkKeyword string + ChannelType string + Region string + InclusionEffect string + LinkType string + MinPriceCents int64 + MaxPriceCents int64 + Visibility string + Page int + Size int +} + +type ListMediaSupplyResourcesResult struct { + Items []MediaSupplyResource `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +type SetMediaSupplyPriceInput struct { + PriceType string + SellPriceCents int64 + Enabled bool +} + +type SetMediaSupplyVisibilityInput struct { + CustomerVisible bool +} + +type MediaSupplyWalletStatus struct { + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + UserName *string `json:"user_name,omitempty"` + UserPhone *string `json:"user_phone,omitempty"` + BalanceCents int64 `json:"balance_cents"` + UpdatedAt time.Time `json:"updated_at"` + LastLedgerAt *time.Time `json:"last_ledger_at,omitempty"` + LedgerEntries int64 `json:"ledger_entries"` + TenantName *string `json:"tenant_name,omitempty"` +} + +type ListMediaSupplyWalletsInput struct { + Keyword string + TenantID int64 + Page int + Size int +} + +type ListMediaSupplyWalletsResult struct { + Items []MediaSupplyWalletStatus `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +type AdjustMediaSupplyWalletInput struct { + TenantID int64 + UserID int64 + DeltaCents int64 + Note string + OperatorID int64 + OperatorTag string +} + +func (s *MediaSupplyService) ListResources(ctx context.Context, input ListMediaSupplyResourcesInput) (*ListMediaSupplyResourcesResult, error) { + if s == nil || s.pool == nil { + return nil, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用") + } + page, size := normalizeOpsMediaSupplyPagination(input.Page, input.Size) + args := []any{opsMediaSupplySupplierMeijiequan} + where := []string{"r.supplier = $1", "r.deleted_at IS NULL"} + cfg := s.mediaSupplyConfig() + sellPriceSQL := opsMediaSupplySellPriceSQL(cfg) + if input.ModelID > 0 { + args = append(args, input.ModelID) + where = append(where, fmt.Sprintf("r.model_id = $%d", len(args))) + } + if keyword := strings.TrimSpace(input.Keyword); keyword != "" { + args = append(args, "%"+keyword+"%") + where = append(where, fmt.Sprintf("r.name ILIKE $%d", len(args))) + } + if keyword := strings.TrimSpace(input.RemarkKeyword); keyword != "" { + args = append(args, "%"+keyword+"%") + where = append(where, fmt.Sprintf("r.resource_remark ILIKE $%d", len(args))) + } + if value := strings.TrimSpace(input.ChannelType); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.channel_type = $%d", len(args))) + } + if value := strings.TrimSpace(input.Region); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.region = $%d", len(args))) + } + if value := strings.TrimSpace(input.InclusionEffect); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.inclusion_effect = $%d", len(args))) + } + if value := strings.TrimSpace(input.LinkType); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.link_type = $%d", len(args))) + } + if input.MinPriceCents > 0 { + args = append(args, input.MinPriceCents) + where = append(where, fmt.Sprintf("(%s) >= $%d", sellPriceSQL, len(args))) + } + if input.MaxPriceCents > 0 { + args = append(args, input.MaxPriceCents) + where = append(where, fmt.Sprintf("(%s) <= $%d", sellPriceSQL, len(args))) + } + switch strings.TrimSpace(input.Visibility) { + case "visible": + where = append(where, "r.customer_visible = TRUE") + case "hidden": + where = append(where, "r.customer_visible = FALSE") + } + whereSQL := strings.Join(where, " AND ") + var total int64 + if err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM supplier_media_resources r + LEFT JOIN supplier_media_price_overrides o + ON o.resource_id = r.id AND o.price_type = 'price' + WHERE `+whereSQL, args...).Scan(&total); err != nil { + return nil, response.ErrInternal(50060, "media_supply_resource_count_failed", "媒体资源统计失败") + } + args = append(args, size, (page-1)*size) + rows, err := s.pool.Query(ctx, ` + SELECT r.id, r.supplier_resource_id, r.model_id, r.name, r.status, + r.cost_price_cents, r.cost_prices_json, + `+sellPriceSQL+` AS sell_price_cents, + r.resource_url, r.baidu_weight, r.resource_remark, r.customer_visible, + r.channel_type, r.region, r.inclusion_effect, r.link_type, + r.publish_rate, r.delivery_speed, r.last_synced_at + FROM supplier_media_resources r + LEFT JOIN supplier_media_price_overrides o + ON o.resource_id = r.id AND o.price_type = 'price' + WHERE `+whereSQL+` + ORDER BY r.updated_at DESC, r.id DESC + LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args))+` + `, args...) + if err != nil { + return nil, response.ErrInternal(50061, "media_supply_resource_query_failed", "媒体资源读取失败") + } + defer rows.Close() + items := make([]MediaSupplyResource, 0, size) + for rows.Next() { + var item MediaSupplyResource + var costPricesRaw []byte + if err := rows.Scan( + &item.ID, + &item.SupplierResourceID, + &item.ModelID, + &item.Name, + &item.Status, + &item.CostPriceCents, + &costPricesRaw, + &item.SellPriceCents, + &item.ResourceURL, + &item.BaiduWeight, + &item.ResourceRemark, + &item.CustomerVisible, + &item.ChannelType, + &item.Region, + &item.InclusionEffect, + &item.LinkType, + &item.PublishRate, + &item.DeliverySpeed, + &item.LastSyncedAt, + ); err != nil { + return nil, response.ErrInternal(50062, "media_supply_resource_scan_failed", "媒体资源解析失败") + } + _ = json.Unmarshal(costPricesRaw, &item.CostPrices) + if item.CostPrices == nil { + item.CostPrices = map[string]int64{} + } + items = append(items, item) + } + return &ListMediaSupplyResourcesResult{Items: items, Total: total, Page: page, Size: size}, nil +} + +func (s *MediaSupplyService) SetResourcePrice(ctx context.Context, actor *Actor, resourceID int64, input SetMediaSupplyPriceInput) error { + priceType := normalizeOpsMediaSupplyPriceType(input.PriceType) + if input.SellPriceCents < 0 { + return response.ErrBadRequest(40061, "invalid_media_supply_price", "媒体售价不能为负数") + } + cost, err := s.resourceCostForPriceType(ctx, resourceID, priceType) + if err != nil { + return err + } + sellPriceCents := opsMediaSupplyRoundUpToYuan(input.SellPriceCents) + if sellPriceCents < cost { + return response.ErrConflict(40960, "media_supply_price_below_cost", "媒体售价不能低于成本价") + } + if _, err := s.pool.Exec(ctx, ` + INSERT INTO supplier_media_price_overrides (resource_id, price_type, sell_price_cents, enabled, updated_by) + VALUES ($1, $2, $3, $4, NULL) + ON CONFLICT (resource_id, price_type) DO UPDATE SET + sell_price_cents = EXCLUDED.sell_price_cents, + enabled = EXCLUDED.enabled, + updated_by = NULL, + updated_at = NOW() + `, resourceID, priceType, sellPriceCents, input.Enabled); err != nil { + return response.ErrInternal(50064, "media_supply_price_update_failed", "媒体价格更新失败") + } + if s.audits != nil && actor != nil { + _ = s.audits.Append(ctx, actor.audit("media_supply.price_update", "supplier_media_resource", resourceID, map[string]any{ + "price_type": priceType, + "sell_price_cents": sellPriceCents, + "enabled": input.Enabled, + })) + } + return nil +} + +func (s *MediaSupplyService) SetResourceVisibility(ctx context.Context, actor *Actor, resourceID int64, input SetMediaSupplyVisibilityInput) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE supplier_media_resources + SET customer_visible = $3, updated_at = NOW() + WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL + `, resourceID, opsMediaSupplySupplierMeijiequan, input.CustomerVisible) + if err != nil { + return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败") + } + if tag.RowsAffected() == 0 { + return response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架") + } + if s.audits != nil && actor != nil { + action := "media_supply.resource_show" + if !input.CustomerVisible { + action = "media_supply.resource_hide" + } + _ = s.audits.Append(ctx, actor.audit(action, "supplier_media_resource", resourceID, map[string]any{ + "customer_visible": input.CustomerVisible, + })) + } + return nil +} + +func (s *MediaSupplyService) ListWallets(ctx context.Context, input ListMediaSupplyWalletsInput) (*ListMediaSupplyWalletsResult, error) { + page, size := normalizeOpsMediaSupplyPagination(input.Page, input.Size) + args := make([]any, 0) + where := []string{"tm.deleted_at IS NULL"} + if input.TenantID > 0 { + args = append(args, input.TenantID) + where = append(where, fmt.Sprintf("tm.tenant_id = $%d", len(args))) + } + if keyword := strings.TrimSpace(input.Keyword); keyword != "" { + args = append(args, "%"+keyword+"%") + where = append(where, fmt.Sprintf("(u.phone ILIKE $%d OR COALESCE(u.email, '') ILIKE $%d OR COALESCE(u.name, '') ILIKE $%d OR COALESCE(t.name, '') ILIKE $%d)", len(args), len(args), len(args), len(args))) + } + whereSQL := strings.Join(where, " AND ") + var total int64 + if err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM tenant_memberships tm + JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL + LEFT JOIN tenants t ON t.id = tm.tenant_id AND t.deleted_at IS NULL + WHERE `+whereSQL, args...).Scan(&total); err != nil { + return nil, response.ErrInternal(50088, "media_supply_wallet_count_failed", "媒体投稿余额统计失败") + } + args = append(args, size, (page-1)*size) + rows, err := s.pool.Query(ctx, ` + SELECT tm.tenant_id, t.name, u.id, u.name, u.phone, COALESCE(w.balance_cents, 0), + COALESCE(w.updated_at, u.updated_at), COUNT(l.id), MAX(l.created_at) + FROM tenant_memberships tm + JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL + LEFT JOIN tenants t ON t.id = tm.tenant_id AND t.deleted_at IS NULL + LEFT JOIN media_supply_user_wallets w ON w.tenant_id = tm.tenant_id AND w.user_id = tm.user_id + LEFT JOIN media_supply_wallet_ledgers l ON l.tenant_id = tm.tenant_id AND l.user_id = tm.user_id + WHERE `+whereSQL+` + GROUP BY tm.tenant_id, t.name, u.id, u.name, u.phone, w.balance_cents, w.updated_at, u.updated_at + ORDER BY COALESCE(MAX(l.created_at), COALESCE(w.updated_at, u.updated_at)) DESC, u.id DESC + LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args))+` + `, args...) + if err != nil { + return nil, response.ErrInternal(50089, "media_supply_wallet_query_failed", "媒体投稿余额读取失败") + } + defer rows.Close() + items := make([]MediaSupplyWalletStatus, 0, size) + for rows.Next() { + var item MediaSupplyWalletStatus + var tenantName sql.NullString + var name sql.NullString + var phone sql.NullString + var lastLedgerAt sql.NullTime + if err := rows.Scan( + &item.TenantID, + &tenantName, + &item.UserID, + &name, + &phone, + &item.BalanceCents, + &item.UpdatedAt, + &item.LedgerEntries, + &lastLedgerAt, + ); err != nil { + return nil, response.ErrInternal(50090, "media_supply_wallet_scan_failed", "媒体投稿余额解析失败") + } + if name.Valid { + item.UserName = &name.String + } + if tenantName.Valid { + item.TenantName = &tenantName.String + } + if phone.Valid { + item.UserPhone = &phone.String + } + if lastLedgerAt.Valid { + item.LastLedgerAt = &lastLedgerAt.Time + } + items = append(items, item) + } + return &ListMediaSupplyWalletsResult{Items: items, Total: total, Page: page, Size: size}, nil +} + +func (s *MediaSupplyService) QueueSync(ctx context.Context, actor *Actor, modelID int) (int64, error) { + if s == nil || s.pool == nil { + return 0, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用") + } + if modelID <= 0 { + modelID = 1 + } + if !isKnownOpsMeijiequanModel(modelID) { + return 0, response.ErrBadRequest(40060, "invalid_media_supply_model", "不支持的媒体资源类型") + } + var id int64 + if err := s.pool.QueryRow(ctx, ` + WITH existing AS ( + SELECT id + FROM media_supply_sync_jobs + WHERE supplier = $1 + AND status IN ($3::varchar, $4::varchar) + ORDER BY created_at ASC + LIMIT 1 + ), inserted AS ( + INSERT INTO media_supply_sync_jobs (supplier, model_id, requested_by) + SELECT $1, $2, NULL + WHERE NOT EXISTS (SELECT 1 FROM existing) + RETURNING id + ) + SELECT id FROM inserted + UNION ALL + SELECT id FROM existing + LIMIT 1 + `, opsMediaSupplySupplierMeijiequan, modelID, "queued", "running").Scan(&id); err != nil { + return 0, response.ErrInternal(50063, "media_supply_sync_queue_failed", "媒体资源同步排队失败") + } + if s.audits != nil && actor != nil { + _ = s.audits.Append(ctx, actor.audit("media_supply.sync_queue", "media_supply_sync_job", id, map[string]any{ + "model_id": modelID, + })) + } + return id, nil +} + +func (s *MediaSupplyService) AdjustWallet(ctx context.Context, actor *Actor, input AdjustMediaSupplyWalletInput) (*MediaSupplyWalletStatus, error) { + if input.TenantID <= 0 || input.UserID <= 0 { + return nil, response.ErrBadRequest(40067, "invalid_media_supply_wallet_user", "租户或用户参数不正确") + } + if input.DeltaCents == 0 { + return nil, response.ErrBadRequest(40068, "invalid_media_supply_wallet_delta", "调整金额不能为 0") + } + if err := s.ensureWalletUserInTenant(ctx, input.TenantID, input.UserID); err != nil { + return nil, err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, response.ErrInternal(50082, "media_supply_wallet_begin_failed", "媒体投稿余额调整失败") + } + defer tx.Rollback(ctx) + balance, err := s.lockWallet(ctx, tx, input.TenantID, input.UserID) + if err != nil { + return nil, err + } + balanceAfter := balance + input.DeltaCents + if balanceAfter < 0 { + return nil, response.ErrConflict(40965, "media_supply_balance_insufficient", "媒体投稿余额不足") + } + if _, err := tx.Exec(ctx, ` + UPDATE media_supply_user_wallets + SET balance_cents = $3, updated_at = NOW() + WHERE tenant_id = $1 AND user_id = $2 + `, input.TenantID, input.UserID, balanceAfter); err != nil { + return nil, response.ErrInternal(50083, "media_supply_wallet_update_failed", "媒体投稿余额更新失败") + } + reason := "adjustment" + if input.DeltaCents > 0 { + reason = "recharge" + } + note := strings.TrimSpace(input.Note) + if tag := strings.TrimSpace(input.OperatorTag); tag != "" { + if note == "" { + note = tag + } else { + note = tag + ";" + note + } + } + if _, err := tx.Exec(ctx, ` + INSERT INTO media_supply_wallet_ledgers ( + tenant_id, user_id, delta_cents, balance_after_cents, reason, note, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, NULL) + `, input.TenantID, input.UserID, input.DeltaCents, balanceAfter, reason, nullableOpsMediaSupplyString(note)); err != nil { + return nil, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败") + } + if err := tx.Commit(ctx); err != nil { + return nil, response.ErrInternal(50085, "media_supply_wallet_commit_failed", "媒体投稿余额调整失败") + } + if s.audits != nil && actor != nil { + _ = s.audits.Append(ctx, actor.audit("media_supply.wallet_adjust", "media_supply_wallet", input.UserID, map[string]any{ + "tenant_id": input.TenantID, + "user_id": input.UserID, + "delta_cents": input.DeltaCents, + "note": input.Note, + })) + } + return &MediaSupplyWalletStatus{ + TenantID: input.TenantID, + UserID: input.UserID, + BalanceCents: balanceAfter, + UpdatedAt: time.Now().UTC(), + }, nil +} + +type opsMediaSupplyWalletExecutor interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +} + +func (s *MediaSupplyService) ensureWalletRow(ctx context.Context, q opsMediaSupplyWalletExecutor, tenantID, userID int64) error { + _, err := q.Exec(ctx, ` + INSERT INTO media_supply_user_wallets (tenant_id, user_id, balance_cents) + VALUES ($1, $2, 0) + ON CONFLICT (tenant_id, user_id) DO NOTHING + `, tenantID, userID) + return err +} + +func (s *MediaSupplyService) ensureWalletUserInTenant(ctx context.Context, tenantID, userID int64) error { + var exists bool + if err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM tenant_memberships + WHERE tenant_id = $1 AND user_id = $2 AND deleted_at IS NULL + ) + `, tenantID, userID).Scan(&exists); err != nil { + return response.ErrInternal(50086, "media_supply_wallet_user_lookup_failed", "媒体投稿余额用户校验失败") + } + if !exists { + return response.ErrNotFound(40463, "media_supply_wallet_user_not_found", "用户不存在或不属于当前租户") + } + return nil +} + +func (s *MediaSupplyService) lockWallet(ctx context.Context, tx pgx.Tx, tenantID, userID int64) (int64, error) { + if err := s.ensureWalletRow(ctx, tx, tenantID, userID); err != nil { + return 0, response.ErrInternal(50077, "media_supply_wallet_prepare_failed", "媒体投稿余额初始化失败") + } + var balance int64 + if err := tx.QueryRow(ctx, ` + SELECT balance_cents + FROM media_supply_user_wallets + WHERE tenant_id = $1 AND user_id = $2 + FOR UPDATE + `, tenantID, userID).Scan(&balance); err != nil { + return 0, response.ErrInternal(50078, "media_supply_wallet_lookup_failed", "媒体投稿余额读取失败") + } + return balance, nil +} + +func (s *MediaSupplyService) resourceCostForPriceType(ctx context.Context, resourceID int64, priceType string) (int64, error) { + var costPrice int64 + var costPricesRaw []byte + if err := s.pool.QueryRow(ctx, ` + SELECT cost_price_cents, cost_prices_json + FROM supplier_media_resources + WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL + `, resourceID, opsMediaSupplySupplierMeijiequan).Scan(&costPrice, &costPricesRaw); err != nil { + if err == pgx.ErrNoRows { + return 0, response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架") + } + return 0, response.ErrInternal(50071, "media_supply_resource_lookup_failed", "媒体资源读取失败") + } + var costPrices map[string]int64 + _ = json.Unmarshal(costPricesRaw, &costPrices) + if priceTypeCost := costPrices[priceType]; priceTypeCost > 0 { + return priceTypeCost, nil + } + return costPrice, nil +} + +func (s *MediaSupplyService) mediaSupplyConfig() sharedconfig.MediaSupplyConfig { + if s != nil && s.cfgFunc != nil { + cfg := s.cfgFunc() + sharedconfig.NormalizeMediaSupplyConfig(&cfg) + return cfg + } + var cfg sharedconfig.MediaSupplyConfig + sharedconfig.NormalizeMediaSupplyConfig(&cfg) + return cfg +} + +func normalizeOpsMediaSupplyPagination(page, size int) (int, int) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size +} + +func normalizeOpsMediaSupplyPriceType(priceType string) string { + priceType = strings.ToLower(strings.TrimSpace(priceType)) + if priceType == "" { + return "price" + } + return priceType +} + +func nullableOpsMediaSupplyString(value string) *string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} + +func opsMediaSupplySellPrice(costCents, overrideCents int64, overrideEnabled bool, markupPercent float64, minimumMarkupCents int64) int64 { + floor := costCents + if markupPercent > 0 { + floor = int64(math.Ceil(float64(costCents) * (1 + markupPercent/100))) + } + if minimumMarkupCents > 0 && costCents+minimumMarkupCents > floor { + floor = costCents + minimumMarkupCents + } + if overrideEnabled && overrideCents >= costCents { + return opsMediaSupplyRoundUpToYuan(overrideCents) + } + return opsMediaSupplyRoundUpToYuan(floor) +} + +func opsMediaSupplyRoundUpToYuan(cents int64) int64 { + if cents <= 0 { + return 0 + } + return ((cents + 99) / 100) * 100 +} + +func opsMediaSupplySellPriceSQL(cfg sharedconfig.MediaSupplyConfig) string { + floorSQL := "r.cost_price_cents" + if cfg.DefaultMarkupPercent > 0 { + multiplier := 1 + cfg.DefaultMarkupPercent/100 + floorSQL = fmt.Sprintf("CEIL(r.cost_price_cents * %.8f)::BIGINT", multiplier) + } + if cfg.MinimumMarkupCents > 0 { + floorSQL = fmt.Sprintf("GREATEST(%s, r.cost_price_cents + %d)", floorSQL, cfg.MinimumMarkupCents) + } + rawPriceSQL := fmt.Sprintf("CASE WHEN COALESCE(o.enabled, false) AND COALESCE(o.sell_price_cents, 0) >= r.cost_price_cents THEN o.sell_price_cents ELSE (%s) END", floorSQL) + return fmt.Sprintf("CEIL((%s) / 100.0)::BIGINT * 100", rawPriceSQL) +} + +func isKnownOpsMeijiequanModel(modelID int) bool { + switch modelID { + case 1, 2, 10, 3, 4, 7, 5, 6, 9, 11, 13: + return true + default: + return false + } +} diff --git a/server/internal/ops/config/config.go b/server/internal/ops/config/config.go index 7323351..889eaad 100644 --- a/server/internal/ops/config/config.go +++ b/server/internal/ops/config/config.go @@ -28,6 +28,7 @@ type Config struct { RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"` ObjectStorage sharedconfig.ObjectStorageConfig `mapstructure:"object_storage"` Log sharedconfig.LogConfig `mapstructure:"log"` + MediaSupply sharedconfig.MediaSupplyConfig `mapstructure:"media_supply"` JWT JWTConfig `mapstructure:"jwt"` Auth sharedconfig.AuthConfig `mapstructure:"auth"` DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"` @@ -244,6 +245,12 @@ func Diff(previous, current *Config) []FieldChange { if previous.Log != current.Log { add("log", false) } + if previous.MediaSupply.Worker.PollInterval != current.MediaSupply.Worker.PollInterval || + previous.MediaSupply.Worker.BatchSize != current.MediaSupply.Worker.BatchSize { + add("media_supply.worker", false) + } else if !reflect.DeepEqual(previous.MediaSupply, current.MediaSupply) { + add("media_supply", true) + } if previous.IPRegion != current.IPRegion { add("ip_region", false) } @@ -360,6 +367,7 @@ func normalizeConfig(cfg *Config) { } sharedconfig.NormalizeCacheConfig(&cfg.Cache) normalizeOpsRabbitMQConfig(&cfg.RabbitMQ) + sharedconfig.NormalizeMediaSupplyConfig(&cfg.MediaSupply) if strings.TrimSpace(cfg.Log.Level) == "" { cfg.Log.Level = "info" } @@ -395,6 +403,21 @@ func defaultSettings() map[string]any { "driver": "redis", }, "rabbitmq": map[string]any{}, + "media_supply": map[string]any{ + "enabled": true, + "default_markup_percent": 50, + "minimum_markup_cents": 0, + "worker": map[string]any{ + "enabled": true, + "poll_interval": 5 * time.Second, + "batch_size": 1, + "order_max_attempts": 3, + "sync_max_attempts": 2, + }, + "meijiequan": map[string]any{ + "base_url": "http://www.meijiequan.com", + }, + }, "log": map[string]any{ "level": "info", "format": "json", diff --git a/server/internal/ops/transport/media_supply_handler.go b/server/internal/ops/transport/media_supply_handler.go new file mode 100644 index 0000000..325c888 --- /dev/null +++ b/server/internal/ops/transport/media_supply_handler.go @@ -0,0 +1,201 @@ +package transport + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/ops/app" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type setMediaSupplyPriceRequest struct { + PriceType string `json:"price_type"` + SellPriceCents int64 `json:"sell_price_cents"` + Enabled *bool `json:"enabled,omitempty"` +} + +type setMediaSupplyVisibilityRequest struct { + CustomerVisible bool `json:"customer_visible"` +} + +type adjustMediaSupplyWalletRequest struct { + TenantID int64 `json:"tenant_id" binding:"required"` + UserID int64 `json:"user_id" binding:"required"` + DeltaCents int64 `json:"delta_cents" binding:"required"` + Note string `json:"note"` +} + +func listMediaSupplyResourcesHandler(svc *app.MediaSupplyService) gin.HandlerFunc { + return func(c *gin.Context) { + modelID, err := parseOptionalOpsIntQuery(c, "model_id") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字")) + return + } + minPriceCents, err := parseOptionalOpsInt64Query(c, "min_price_cents") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_min_price_cents", "最低价格参数必须是数字")) + return + } + maxPriceCents, err := parseOptionalOpsInt64Query(c, "max_price_cents") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_max_price_cents", "最高价格参数必须是数字")) + return + } + page, _ := parseOptionalOpsIntQuery(c, "page") + size, _ := parseOptionalOpsIntQuery(c, "page_size") + result, err := svc.ListResources(c.Request.Context(), app.ListMediaSupplyResourcesInput{ + ModelID: modelID, + Keyword: c.Query("keyword"), + RemarkKeyword: c.Query("remark_keyword"), + ChannelType: c.Query("channel_type"), + Region: c.Query("region"), + InclusionEffect: c.Query("inclusion_effect"), + LinkType: c.Query("link_type"), + MinPriceCents: minPriceCents, + MaxPriceCents: maxPriceCents, + Visibility: c.Query("visibility"), + Page: page, + Size: size, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func setMediaSupplyResourcePriceHandler(svc *app.MediaSupplyService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body setMediaSupplyPriceRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_payload", "价格参数不正确")) + return + } + enabled := true + if body.Enabled != nil { + enabled = *body.Enabled + } + if err := svc.SetResourcePrice(c.Request.Context(), actorFromGin(c), id, app.SetMediaSupplyPriceInput{ + PriceType: body.PriceType, + SellPriceCents: body.SellPriceCents, + Enabled: enabled, + }); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"updated": true}) + } +} + +func setMediaSupplyResourceVisibilityHandler(svc *app.MediaSupplyService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body setMediaSupplyVisibilityRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_payload", "展示状态参数不正确")) + return + } + if err := svc.SetResourceVisibility(c.Request.Context(), actorFromGin(c), id, app.SetMediaSupplyVisibilityInput{ + CustomerVisible: body.CustomerVisible, + }); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"updated": true}) + } +} + +func queueMediaSupplySyncHandler(svc *app.MediaSupplyService) gin.HandlerFunc { + return func(c *gin.Context) { + var body struct { + ModelID int `json:"model_id"` + } + if c.Request.Body != nil { + _ = c.ShouldBindJSON(&body) + } + jobID, err := svc.QueueSync(c.Request.Context(), actorFromGin(c), body.ModelID) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"job_id": jobID}) + } +} + +func listMediaSupplyWalletsHandler(svc *app.MediaSupplyService) gin.HandlerFunc { + return func(c *gin.Context) { + tenantID, err := parseOptionalOpsInt64Query(c, "tenant_id") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_tenant_id", "租户参数必须是数字")) + return + } + page, _ := parseOptionalOpsIntQuery(c, "page") + size, _ := parseOptionalOpsIntQuery(c, "page_size") + result, err := svc.ListWallets(c.Request.Context(), app.ListMediaSupplyWalletsInput{ + Keyword: c.Query("keyword"), + TenantID: tenantID, + Page: page, + Size: size, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func adjustMediaSupplyWalletHandler(svc *app.MediaSupplyService) gin.HandlerFunc { + return func(c *gin.Context) { + var body adjustMediaSupplyWalletRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_payload", "余额调整参数不正确")) + return + } + actor := actorFromGin(c) + operatorTag := "ops manual adjustment" + if actor != nil && actor.DisplayName != "" { + operatorTag = "ops " + actor.DisplayName + } + result, err := svc.AdjustWallet(c.Request.Context(), actor, app.AdjustMediaSupplyWalletInput{ + TenantID: body.TenantID, + UserID: body.UserID, + DeltaCents: body.DeltaCents, + Note: body.Note, + OperatorTag: operatorTag, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func parseOptionalOpsIntQuery(c *gin.Context, key string) (int, error) { + raw := c.Query(key) + if raw == "" { + return 0, nil + } + return strconv.Atoi(raw) +} + +func parseOptionalOpsInt64Query(c *gin.Context, key string) (int64, error) { + raw := c.Query(key) + if raw == "" { + return 0, nil + } + return strconv.ParseInt(raw, 10, 64) +} diff --git a/server/internal/ops/transport/router.go b/server/internal/ops/transport/router.go index b1557d9..e348d22 100644 --- a/server/internal/ops/transport/router.go +++ b/server/internal/ops/transport/router.go @@ -30,6 +30,7 @@ type Deps struct { ObjectStorage *app.ObjectStorageService Compliance *app.ComplianceService Scheduler *app.SchedulerService + MediaSupply *app.MediaSupplyService } func (d Deps) ServerAllowedOrigins() []string { @@ -123,6 +124,13 @@ func RegisterRoutes(d Deps) { authed.POST("/jobs/:source/:id/retry", retryJobHandler(d.Jobs)) authed.POST("/jobs/:source/:id/cancel", cancelJobHandler(d.Jobs)) + authed.GET("/media-supply/resources", listMediaSupplyResourcesHandler(d.MediaSupply)) + authed.PUT("/media-supply/resources/:id/price", setMediaSupplyResourcePriceHandler(d.MediaSupply)) + authed.PUT("/media-supply/resources/:id/visibility", setMediaSupplyResourceVisibilityHandler(d.MediaSupply)) + authed.POST("/media-supply/sync-jobs", queueMediaSupplySyncHandler(d.MediaSupply)) + authed.GET("/media-supply/wallets", listMediaSupplyWalletsHandler(d.MediaSupply)) + authed.POST("/media-supply/wallets/adjustments", adjustMediaSupplyWalletHandler(d.MediaSupply)) + authed.GET("/scheduler/jobs", listSchedulerJobsHandler(d.Scheduler)) authed.GET("/scheduler/jobs/:key", getSchedulerJobHandler(d.Scheduler)) authed.PATCH("/scheduler/jobs/:key", updateSchedulerJobHandler(d.Scheduler)) diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go index 6723cfc..4f47e89 100644 --- a/server/internal/shared/config/config.go +++ b/server/internal/shared/config/config.go @@ -41,6 +41,7 @@ type Config struct { Retrieval RetrievalConfig `mapstructure:"retrieval"` Generation GenerationConfig `mapstructure:"generation"` BrowserFetch BrowserFetchConfig `mapstructure:"browser_fetch"` + MediaSupply MediaSupplyConfig `mapstructure:"media_supply"` } type ServerConfig struct { @@ -409,6 +410,41 @@ type BrowserFetchConfig struct { TriggerStatus []int `mapstructure:"trigger_status"` } +type MediaSupplyConfig struct { + Enabled bool `mapstructure:"enabled"` + DefaultMarkupPercent float64 `mapstructure:"default_markup_percent"` + MinimumMarkupCents int64 `mapstructure:"minimum_markup_cents"` + Worker MediaSupplyWorkerConfig `mapstructure:"worker"` + Meijiequan MeijiequanConfig `mapstructure:"meijiequan"` +} + +type MediaSupplyWorkerConfig struct { + Enabled bool `mapstructure:"enabled"` + PollInterval time.Duration `mapstructure:"poll_interval"` + BatchSize int `mapstructure:"batch_size"` + OrderMaxAttempts int `mapstructure:"order_max_attempts"` + SyncMaxAttempts int `mapstructure:"sync_max_attempts"` + BacklinkSyncInterval time.Duration `mapstructure:"backlink_sync_interval"` + BacklinkBatchSize int `mapstructure:"backlink_batch_size"` +} + +type MeijiequanConfig struct { + BaseURL string `mapstructure:"base_url"` + UserID string `mapstructure:"user_id"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + SessionTTL time.Duration `mapstructure:"session_ttl"` + RequestTimeout time.Duration `mapstructure:"request_timeout"` + SyncPageDelay time.Duration `mapstructure:"sync_page_delay"` + SyncPageSize int `mapstructure:"sync_page_size"` + MaxSyncPages int `mapstructure:"max_sync_pages"` + PublishedPageSize int `mapstructure:"published_page_size"` + PublishedMaxPages int `mapstructure:"published_max_pages"` + SearchOptionsTTL time.Duration `mapstructure:"search_options_ttl"` + UpstreamLockTTL time.Duration `mapstructure:"upstream_lock_ttl"` + UpstreamMinInterval time.Duration `mapstructure:"upstream_min_interval"` +} + func Load(configPath string) (*Config, error) { cfg, _, err := loadWithFiles(configPath) return cfg, err @@ -444,6 +480,7 @@ func loadWithFiles(configPath string) (*Config, []string, error) { NormalizeComplianceConfig(&cfg.Compliance) NormalizeGenerationConfig(&cfg.Generation) NormalizeBrowserFetchConfig(&cfg.BrowserFetch) + NormalizeMediaSupplyConfig(&cfg.MediaSupply) files := []string{configFile} if localConfigFile != "" { @@ -781,6 +818,88 @@ func NormalizeBrowserFetchConfig(cfg *BrowserFetchConfig) { } } +func NormalizeMediaSupplyConfig(cfg *MediaSupplyConfig) { + if cfg == nil { + return + } + if cfg.DefaultMarkupPercent < 0 { + cfg.DefaultMarkupPercent = 0 + } + if cfg.MinimumMarkupCents < 0 { + cfg.MinimumMarkupCents = 0 + } + if cfg.Worker.PollInterval <= 0 { + cfg.Worker.PollInterval = 5 * time.Second + } + if cfg.Worker.BatchSize <= 0 { + cfg.Worker.BatchSize = 1 + } + if cfg.Worker.BatchSize > 10 { + cfg.Worker.BatchSize = 10 + } + if cfg.Worker.OrderMaxAttempts <= 0 { + cfg.Worker.OrderMaxAttempts = 3 + } + if cfg.Worker.SyncMaxAttempts <= 0 { + cfg.Worker.SyncMaxAttempts = 2 + } + if cfg.Worker.BacklinkSyncInterval <= 0 { + cfg.Worker.BacklinkSyncInterval = 10 * time.Minute + } + if cfg.Worker.BacklinkBatchSize <= 0 { + cfg.Worker.BacklinkBatchSize = 20 + } + if cfg.Worker.BacklinkBatchSize > 100 { + cfg.Worker.BacklinkBatchSize = 100 + } + cfg.Meijiequan.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.Meijiequan.BaseURL), "/") + if cfg.Meijiequan.BaseURL == "" { + cfg.Meijiequan.BaseURL = "http://www.meijiequan.com" + } + cfg.Meijiequan.UserID = strings.TrimSpace(cfg.Meijiequan.UserID) + cfg.Meijiequan.Username = strings.TrimSpace(cfg.Meijiequan.Username) + cfg.Meijiequan.Password = strings.TrimSpace(cfg.Meijiequan.Password) + if cfg.Meijiequan.SessionTTL <= 0 { + cfg.Meijiequan.SessionTTL = 4 * time.Hour + } + if cfg.Meijiequan.RequestTimeout <= 0 { + cfg.Meijiequan.RequestTimeout = 30 * time.Second + } + if cfg.Meijiequan.SyncPageDelay <= 0 { + cfg.Meijiequan.SyncPageDelay = 800 * time.Millisecond + } + if cfg.Meijiequan.SyncPageSize <= 0 { + cfg.Meijiequan.SyncPageSize = 100 + } + if cfg.Meijiequan.SyncPageSize > 500 { + cfg.Meijiequan.SyncPageSize = 500 + } + if cfg.Meijiequan.MaxSyncPages <= 0 { + cfg.Meijiequan.MaxSyncPages = 200 + } + if cfg.Meijiequan.PublishedPageSize <= 0 { + cfg.Meijiequan.PublishedPageSize = 20 + } + if cfg.Meijiequan.PublishedPageSize > 100 { + cfg.Meijiequan.PublishedPageSize = 100 + } + if cfg.Meijiequan.PublishedMaxPages <= 0 { + cfg.Meijiequan.PublishedMaxPages = 3 + } + if cfg.Meijiequan.PublishedMaxPages > 20 { + cfg.Meijiequan.PublishedMaxPages = 20 + } + if cfg.Meijiequan.SearchOptionsTTL <= 0 { + cfg.Meijiequan.SearchOptionsTTL = 12 * time.Hour + } + if cfg.Meijiequan.UpstreamLockTTL <= 0 { + cfg.Meijiequan.UpstreamLockTTL = 2 * time.Minute + } + if cfg.Meijiequan.UpstreamMinInterval <= 0 { + cfg.Meijiequan.UpstreamMinInterval = cfg.Meijiequan.SyncPageDelay + } +} + func NormalizeComplianceConfig(cfg *ComplianceConfig) { if cfg == nil { return @@ -930,6 +1049,78 @@ func applyEnvOverrides(cfg *Config) { if statuses, ok := lookupNonEmptyEnv("BROWSER_FETCH_TRIGGER_STATUS"); ok { cfg.BrowserFetch.TriggerStatus = parseIntList(statuses) } + if enabled, ok := lookupBoolEnv("MEDIA_SUPPLY_ENABLED"); ok { + cfg.MediaSupply.Enabled = enabled + } + if markup, ok := lookupFloatEnv("MEDIA_SUPPLY_DEFAULT_MARKUP_PERCENT"); ok { + cfg.MediaSupply.DefaultMarkupPercent = markup + } + if cents, ok := lookupInt64Env("MEDIA_SUPPLY_MINIMUM_MARKUP_CENTS"); ok { + cfg.MediaSupply.MinimumMarkupCents = cents + } + if enabled, ok := lookupBoolEnv("MEDIA_SUPPLY_WORKER_ENABLED"); ok { + cfg.MediaSupply.Worker.Enabled = enabled + } + if interval, ok := lookupDurationEnv("MEDIA_SUPPLY_WORKER_POLL_INTERVAL"); ok { + cfg.MediaSupply.Worker.PollInterval = interval + } + if n, ok := lookupIntEnv("MEDIA_SUPPLY_WORKER_BATCH_SIZE"); ok { + cfg.MediaSupply.Worker.BatchSize = n + } + if n, ok := lookupIntEnv("MEDIA_SUPPLY_ORDER_MAX_ATTEMPTS"); ok { + cfg.MediaSupply.Worker.OrderMaxAttempts = n + } + if n, ok := lookupIntEnv("MEDIA_SUPPLY_SYNC_MAX_ATTEMPTS"); ok { + cfg.MediaSupply.Worker.SyncMaxAttempts = n + } + if interval, ok := lookupDurationEnv("MEDIA_SUPPLY_BACKLINK_SYNC_INTERVAL"); ok { + cfg.MediaSupply.Worker.BacklinkSyncInterval = interval + } + if n, ok := lookupIntEnv("MEDIA_SUPPLY_BACKLINK_BATCH_SIZE"); ok { + cfg.MediaSupply.Worker.BacklinkBatchSize = n + } + if baseURL, ok := lookupNonEmptyEnv("MEIJIEQUAN_BASE_URL"); ok { + cfg.MediaSupply.Meijiequan.BaseURL = baseURL + } + if userID, ok := lookupNonEmptyEnv("MEIJIEQUAN_USER_ID"); ok { + cfg.MediaSupply.Meijiequan.UserID = userID + } + if username, ok := lookupNonEmptyEnv("MEIJIEQUAN_USERNAME"); ok { + cfg.MediaSupply.Meijiequan.Username = username + } + if password, ok := lookupNonEmptyEnv("MEIJIEQUAN_PASSWORD"); ok { + cfg.MediaSupply.Meijiequan.Password = password + } + if ttl, ok := lookupDurationEnv("MEIJIEQUAN_SESSION_TTL"); ok { + cfg.MediaSupply.Meijiequan.SessionTTL = ttl + } + if timeout, ok := lookupDurationEnv("MEIJIEQUAN_REQUEST_TIMEOUT"); ok { + cfg.MediaSupply.Meijiequan.RequestTimeout = timeout + } + if delay, ok := lookupDurationEnv("MEIJIEQUAN_SYNC_PAGE_DELAY"); ok { + cfg.MediaSupply.Meijiequan.SyncPageDelay = delay + } + if n, ok := lookupIntEnv("MEIJIEQUAN_SYNC_PAGE_SIZE"); ok { + cfg.MediaSupply.Meijiequan.SyncPageSize = n + } + if n, ok := lookupIntEnv("MEIJIEQUAN_MAX_SYNC_PAGES"); ok { + cfg.MediaSupply.Meijiequan.MaxSyncPages = n + } + if n, ok := lookupIntEnv("MEIJIEQUAN_PUBLISHED_PAGE_SIZE"); ok { + cfg.MediaSupply.Meijiequan.PublishedPageSize = n + } + if n, ok := lookupIntEnv("MEIJIEQUAN_PUBLISHED_MAX_PAGES"); ok { + cfg.MediaSupply.Meijiequan.PublishedMaxPages = n + } + if ttl, ok := lookupDurationEnv("MEIJIEQUAN_SEARCH_OPTIONS_TTL"); ok { + cfg.MediaSupply.Meijiequan.SearchOptionsTTL = ttl + } + if ttl, ok := lookupDurationEnv("MEIJIEQUAN_UPSTREAM_LOCK_TTL"); ok { + cfg.MediaSupply.Meijiequan.UpstreamLockTTL = ttl + } + if interval, ok := lookupDurationEnv("MEIJIEQUAN_UPSTREAM_MIN_INTERVAL"); ok { + cfg.MediaSupply.Meijiequan.UpstreamMinInterval = interval + } if apiKey, ok := lookupNonEmptyEnv("QDRANT_API_KEY"); ok { cfg.Qdrant.APIKey = apiKey } @@ -1468,6 +1659,32 @@ func lookupIntEnv(key string) (int, bool) { return number, true } +func lookupInt64Env(key string) (int64, bool) { + value, ok := lookupNonEmptyEnv(key) + if !ok { + return 0, false + } + + number, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0, false + } + return number, true +} + +func lookupFloatEnv(key string) (float64, bool) { + value, ok := lookupNonEmptyEnv(key) + if !ok { + return 0, false + } + + number, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, false + } + return number, true +} + func maxInt(value, fallback int) int { if value < fallback { return fallback diff --git a/server/internal/shared/config/config_test.go b/server/internal/shared/config/config_test.go index 62c43c9..570dba7 100644 --- a/server/internal/shared/config/config_test.go +++ b/server/internal/shared/config/config_test.go @@ -368,6 +368,46 @@ cache: } } +func TestLoadAppliesMediaSupplyMeijiequanAccountOverrides(t *testing.T) { + t.Setenv("MEIJIEQUAN_USERNAME", " 17788409108 ") + t.Setenv("MEIJIEQUAN_PASSWORD", " env-password ") + t.Setenv("MEIJIEQUAN_SYNC_PAGE_DELAY", "1500ms") + t.Setenv("MEIJIEQUAN_SEARCH_OPTIONS_TTL", "2h") + t.Setenv("MEIJIEQUAN_UPSTREAM_MIN_INTERVAL", "3s") + t.Setenv("MEDIA_SUPPLY_BACKLINK_SYNC_INTERVAL", "15m") + t.Setenv("MEDIA_SUPPLY_BACKLINK_BATCH_SIZE", "12") + t.Setenv("MEIJIEQUAN_PUBLISHED_PAGE_SIZE", "30") + t.Setenv("MEIJIEQUAN_PUBLISHED_MAX_PAGES", "4") + + configPath := writeTestConfig(t, ` +media_supply: + meijiequan: + base_url: "http://www.meijiequan.com/" + user_id: " 962 " + username: config-user + password: config-password +`) + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.MediaSupply.Meijiequan.BaseURL != "http://www.meijiequan.com" || + cfg.MediaSupply.Meijiequan.UserID != "962" || + cfg.MediaSupply.Meijiequan.Username != "17788409108" || + cfg.MediaSupply.Meijiequan.Password != "env-password" || + cfg.MediaSupply.Meijiequan.SyncPageDelay != 1500*time.Millisecond || + cfg.MediaSupply.Meijiequan.SearchOptionsTTL != 2*time.Hour || + cfg.MediaSupply.Meijiequan.UpstreamMinInterval != 3*time.Second || + cfg.MediaSupply.Worker.BacklinkSyncInterval != 15*time.Minute || + cfg.MediaSupply.Worker.BacklinkBatchSize != 12 || + cfg.MediaSupply.Meijiequan.PublishedPageSize != 30 || + cfg.MediaSupply.Meijiequan.PublishedMaxPages != 4 { + t.Fatalf("unexpected meijiequan config: %#v", cfg.MediaSupply.Meijiequan) + } +} + func TestLoadPrefersEnvSchedulerMetricsSettings(t *testing.T) { t.Setenv("SCHEDULER_HTTP_HOST", "0.0.0.0") t.Setenv("SCHEDULER_INTERNAL_METRICS_TOKEN", "env-metrics-token") diff --git a/server/internal/shared/config/reload.go b/server/internal/shared/config/reload.go index 95ef5ed..51a7a4e 100644 --- a/server/internal/shared/config/reload.go +++ b/server/internal/shared/config/reload.go @@ -92,6 +92,12 @@ func Diff(previous, current *Config) []FieldChange { } else if !reflect.DeepEqual(previous.BrowserFetch, current.BrowserFetch) { addChange("browser_fetch", true) } + if previous.MediaSupply.Worker.PollInterval != current.MediaSupply.Worker.PollInterval || + previous.MediaSupply.Worker.BatchSize != current.MediaSupply.Worker.BatchSize { + addChange("media_supply.worker", false) + } else if !reflect.DeepEqual(previous.MediaSupply, current.MediaSupply) { + addChange("media_supply", true) + } if previous.Generation.QueueSize != current.Generation.QueueSize || previous.Generation.WorkerConcurrency != current.Generation.WorkerConcurrency { addChange("generation.worker", false) diff --git a/server/internal/shared/digitocr/cmd/recognize/main.go b/server/internal/shared/digitocr/cmd/recognize/main.go new file mode 100644 index 0000000..c5ee727 --- /dev/null +++ b/server/internal/shared/digitocr/cmd/recognize/main.go @@ -0,0 +1,61 @@ +// 本地识别 CLI:读取一张或多张已保存的验证码 PNG,输出识别结果。 +// +// 用法: +// +// go run ./cmd/recognize path/to/captcha.png [more.png ...] +// +// 想批量评估准确率时,把文件命名为"真值.png"(如 4930.png),脚本会自动对比。 +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/geo-platform/tenant-api/internal/shared/digitocr" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: recognize [png ...]") + os.Exit(2) + } + + var total, correct int + for _, path := range os.Args[1:] { + got, err := digitocr.Recognize(path, digitocr.Options{}) + if err != nil { + fmt.Printf("%s\tERROR: %v\n", path, err) + continue + } + truth := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + if isAllDigits(truth) && len(truth) == len(got) { + total++ + mark := "OK" + if got == truth { + correct++ + } else { + mark = "MISS" + } + fmt.Printf("%s\t%s\t%s (truth=%s)\n", path, got, mark, truth) + } else { + fmt.Printf("%s\t%s\n", path, got) + } + } + if total > 0 { + fmt.Printf("\naccuracy: %d/%d = %.1f%%\n", correct, total, float64(correct)*100/float64(total)) + } +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/server/internal/shared/digitocr/cmd/train/main.go b/server/internal/shared/digitocr/cmd/train/main.go new file mode 100644 index 0000000..8708140 --- /dev/null +++ b/server/internal/shared/digitocr/cmd/train/main.go @@ -0,0 +1,171 @@ +// 训练工具:读取 samples/0.png ~ samples/9.png,生成 templates.go。 +// +// 用法: +// +// cd server/internal/shared/digitocr +// # 准备 samples/0.png ... 9.png,每张是同字体的单个数字 +// # 也可以是含多位的整图,命名 NNNN.png(如 4930.png),脚本会按位切分 +// go run ./cmd/train +package main + +import ( + "fmt" + "image" + _ "image/png" + "os" + "path/filepath" + "strings" + + "github.com/geo-platform/tenant-api/internal/shared/digitocr" +) + +func main() { + groups, err := collectSamples("samples") + if err != nil { + exit(err) + } + missing := missingDigits(groups) + if len(missing) > 0 { + exit(fmt.Errorf("missing samples for digits: %v", missing)) + } + + templates := [10]digitocr.Bitmap{} + for d := 0; d < 10; d++ { + templates[d] = voteTemplate(groups[d]) + } + + var sb strings.Builder + sb.WriteString("package digitocr\n\n") + sb.WriteString("// Code generated by cmd/train. DO NOT EDIT.\n\n") + sb.WriteString("var Templates = [10]Bitmap{\n") + for d := 0; d < 10; d++ { + sb.WriteString("\t{") + for i, v := range templates[d] { + if i > 0 { + sb.WriteByte(',') + } + if v == 0 { + sb.WriteByte('0') + } else { + sb.WriteByte('1') + } + } + fmt.Fprintf(&sb, "}, // d=%d, n=%d\n", d, len(groups[d])) + } + sb.WriteString("}\n") + + if err := os.WriteFile("templates.go", []byte(sb.String()), 0o644); err != nil { + exit(err) + } + fmt.Printf("wrote templates.go (samples per digit: ") + for d := 0; d < 10; d++ { + fmt.Printf("%d=%d ", d, len(groups[d])) + } + fmt.Println(")") +} + +// voteTemplate 对一组同数字样本做按位多数投票。 +func voteTemplate(bms []digitocr.Bitmap) digitocr.Bitmap { + var out digitocr.Bitmap + if len(bms) == 0 { + return out + } + counts := make([]int, digitocr.GridW*digitocr.GridH) + for _, bm := range bms { + for i, v := range bm { + if v == 1 { + counts[i]++ + } + } + } + threshold := len(bms) / 2 + for i, c := range counts { + if c > threshold { + out[i] = 1 + } + } + return out +} + +// collectSamples 从 dir 下读取样本。支持两种命名: +// - 0.png ~ 9.png:单字符样本,整张图就是一个数字 +// - NNNN.png(如 4930.png):多位样本,按位切分,按文件名映射到对应数字 +// +// 返回 map[digit][]Bitmap,每个数字累积所有样本以便投票。 +func collectSamples(dir string) (map[int][]digitocr.Bitmap, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + out := map[int][]digitocr.Bitmap{} + for _, e := range entries { + if e.IsDir() { + continue + } + name := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) + if !allDigits(name) { + continue + } + bms, err := extractBitmaps(filepath.Join(dir, e.Name()), len(name)) + if err != nil { + return nil, fmt.Errorf("%s: %w", e.Name(), err) + } + for i, ch := range name { + d := int(ch - '0') + out[d] = append(out[d], bms[i]) + } + } + return out, nil +} + +func extractBitmaps(path string, digits int) ([]digitocr.Bitmap, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + return nil, err + } + bin, bbox := digitocr.Binarize(img, digitocr.IsOrange) + if bbox.Empty() { + return nil, fmt.Errorf("no foreground pixels") + } + cells := digitocr.SegmentDigits(bin, bbox, digits) + if len(cells) != digits { + return nil, fmt.Errorf("segmented %d cells, expected %d", len(cells), digits) + } + bms := make([]digitocr.Bitmap, digits) + for i, cell := range cells { + bms[i] = digitocr.Normalize(bin, cell) + } + return bms, nil +} + +func allDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func missingDigits(m map[int][]digitocr.Bitmap) []int { + var miss []int + for d := 0; d < 10; d++ { + if len(m[d]) == 0 { + miss = append(miss, d) + } + } + return miss +} + +func exit(err error) { + fmt.Fprintln(os.Stderr, "train:", err) + os.Exit(1) +} diff --git a/server/internal/shared/digitocr/digitocr.go b/server/internal/shared/digitocr/digitocr.go new file mode 100644 index 0000000..4fbbf14 --- /dev/null +++ b/server/internal/shared/digitocr/digitocr.go @@ -0,0 +1,358 @@ +// Package digitocr 识别固定字体、干净背景的多位数字图(如 4 位橙色数码字验证码)。 +// 思路:橙色阈值二值化 → 找前景 bbox → 等分 N 列 → 每块归一到固定网格 → 与 0-9 模板做 Hamming 距离。 +package digitocr + +import ( + "errors" + "fmt" + "image" + "image/color" + _ "image/png" + "io" + "os" + "sort" +) + +const ( + GridW = 10 // 单数字画布宽(覆盖最宽字符 + 余量) + GridH = 18 // 单数字画布高 +) + +// Bitmap 单个数字归一后的位图(按行展开)。 +type Bitmap [GridW * GridH]uint8 + +// Options 控制识别行为。零值即默认 4 位、橙色前景。 +type Options struct { + Digits int // 期望位数,默认 4 + IsForeground func(color.Color) bool // 自定义前景判定,nil 时用 IsOrange +} + +// Recognize 从文件路径读图并识别。 +func Recognize(path string, opts Options) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + return RecognizeReader(f, opts) +} + +// RecognizeReader 从 io.Reader 读图并识别。 +func RecognizeReader(r io.Reader, opts Options) (string, error) { + img, _, err := image.Decode(r) + if err != nil { + return "", err + } + return RecognizeImage(img, opts) +} + +// RecognizeImage 对已解码的 image.Image 做识别。 +func RecognizeImage(img image.Image, opts Options) (string, error) { + if opts.Digits == 0 { + opts.Digits = 4 + } + if opts.IsForeground == nil { + opts.IsForeground = IsOrange + } + + bin, bbox := Binarize(img, opts.IsForeground) + if bbox.Empty() { + return "", errors.New("digitocr: no foreground pixels") + } + + cells := SegmentDigits(bin, bbox, opts.Digits) + out := make([]byte, len(cells)) + for i, cell := range cells { + bm := Normalize(bin, cell) + out[i] = '0' + byte(MatchDigit(bm)) + } + if len(out) != opts.Digits { + return string(out), fmt.Errorf("digitocr: expected %d digits, segmented %d", opts.Digits, len(out)) + } + return string(out), nil +} + +// SegmentDigits 优先用 8 连通分量切分,每个数字应是一个 CC。 +// CC 数不匹配时退回列投影找零列;再不匹配退回等宽切分。 +func SegmentDigits(bin [][]bool, bbox image.Rectangle, want int) []image.Rectangle { + if rects := ConnectedComponents(bin); len(rects) == want { + return rects + } + if rects := projectionSplit(bin, bbox); len(rects) == want { + return rects + } + // 兜底:等宽切分 + out := make([]image.Rectangle, want) + cellW := bbox.Dx() / want + for i := 0; i < want; i++ { + out[i] = image.Rect( + bbox.Min.X+i*cellW, bbox.Min.Y, + bbox.Min.X+(i+1)*cellW, bbox.Max.Y, + ) + if i == want-1 { + out[i].Max.X = bbox.Max.X + } + } + return out +} + +// ConnectedComponents 返回所有 8 连通前景分量的 bbox,按左上 x 升序排列。 +// 小于 minPixels 的噪点分量会被丢弃(默认 2)。 +func ConnectedComponents(bin [][]bool) []image.Rectangle { + const minPixels = 2 + h := len(bin) + if h == 0 { + return nil + } + w := len(bin[0]) + visited := make([][]bool, h) + for i := range visited { + visited[i] = make([]bool, w) + } + var rects []image.Rectangle + stack := make([][2]int, 0, 64) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + if !bin[y][x] || visited[y][x] { + continue + } + minX, minY := x, y + maxX, maxY := x, y + pixCount := 0 + stack = append(stack[:0], [2]int{x, y}) + visited[y][x] = true + for len(stack) > 0 { + p := stack[len(stack)-1] + stack = stack[:len(stack)-1] + pixCount++ + if p[0] < minX { + minX = p[0] + } + if p[1] < minY { + minY = p[1] + } + if p[0] > maxX { + maxX = p[0] + } + if p[1] > maxY { + maxY = p[1] + } + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + if dx == 0 && dy == 0 { + continue + } + nx, ny := p[0]+dx, p[1]+dy + if nx >= 0 && nx < w && ny >= 0 && ny < h && bin[ny][nx] && !visited[ny][nx] { + visited[ny][nx] = true + stack = append(stack, [2]int{nx, ny}) + } + } + } + } + if pixCount >= minPixels { + rects = append(rects, image.Rect(minX, minY, maxX+1, maxY+1)) + } + } + } + sort.Slice(rects, func(i, j int) bool { return rects[i].Min.X < rects[j].Min.X }) + return rects +} + +func projectionSplit(bin [][]bool, bbox image.Rectangle) []image.Rectangle { + cols := make([]int, bbox.Dx()) + for y := bbox.Min.Y; y < bbox.Max.Y; y++ { + for x := bbox.Min.X; x < bbox.Max.X; x++ { + if bin[y][x] { + cols[x-bbox.Min.X]++ + } + } + } + var rects []image.Rectangle + inRun, runStart := false, 0 + for i := 0; i <= len(cols); i++ { + present := i < len(cols) && cols[i] > 0 + if present && !inRun { + inRun = true + runStart = i + } else if !present && inRun { + inRun = false + rects = append(rects, image.Rect( + bbox.Min.X+runStart, bbox.Min.Y, + bbox.Min.X+i, bbox.Max.Y, + )) + } + } + return rects +} + +// IsOrange 经验阈值:识别图中那种橙色前景像素。 +// 如背景色调差异较大,可换 HSV 判 H∈[10°,30°]。 +func IsOrange(c color.Color) bool { + r, g, b, _ := c.RGBA() + r8, g8, b8 := r>>8, g>>8, b>>8 + return r8 > 200 && g8 > 80 && g8 < 180 && b8 < 100 +} + +// Binarize 把图像转成 bool 矩阵 + 所有前景像素的边界框。 +// 默认会通过 StripFrame 去掉与图像边缘相连的"外框"前景(如圆角矩形装饰边)。 +func Binarize(img image.Image, fg func(color.Color) bool) ([][]bool, image.Rectangle) { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + bin := make([][]bool, h) + for y := 0; y < h; y++ { + bin[y] = make([]bool, w) + for x := 0; x < w; x++ { + if fg(img.At(b.Min.X+x, b.Min.Y+y)) { + bin[y][x] = true + } + } + } + StripFrame(bin) + return bin, computeBBox(bin) +} + +// StripFrame 用 8 邻接 flood fill 从图像四边把任何相连的前景"吃掉"。 +// 适用于验证码常见的圆角矩形装饰边——只要数字本身和外框不相连,外框就会被清干净。 +func StripFrame(bin [][]bool) { + h := len(bin) + if h == 0 { + return + } + w := len(bin[0]) + type pt struct{ x, y int } + var stack []pt + push := func(x, y int) { + if x >= 0 && x < w && y >= 0 && y < h && bin[y][x] { + bin[y][x] = false + stack = append(stack, pt{x, y}) + } + } + for x := 0; x < w; x++ { + push(x, 0) + push(x, h-1) + } + for y := 0; y < h; y++ { + push(0, y) + push(w-1, y) + } + for len(stack) > 0 { + p := stack[len(stack)-1] + stack = stack[:len(stack)-1] + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + if dx == 0 && dy == 0 { + continue + } + push(p.x+dx, p.y+dy) + } + } + } +} + +func computeBBox(bin [][]bool) image.Rectangle { + h := len(bin) + if h == 0 { + return image.Rectangle{} + } + w := len(bin[0]) + minX, minY := w, h + maxX, maxY := -1, -1 + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + if bin[y][x] { + if x < minX { + minX = x + } + if y < minY { + minY = y + } + if x > maxX { + maxX = x + } + if y > maxY { + maxY = y + } + } + } + } + if maxX < 0 { + return image.Rectangle{} + } + return image.Rect(minX, minY, maxX+1, maxY+1) +} + +// Normalize 把 cell 内的前景按原尺寸贴到 GridW x GridH 画布的左上角。 +// 字体固定时不做缩放采样,模板就是真实像素,区分度最高。 +// 若数字尺寸超出画布,多出的右/下部分被截断(GridW/GridH 应当设大于最大字符)。 +func Normalize(bin [][]bool, cell image.Rectangle) Bitmap { + minX, minY := cell.Max.X, cell.Max.Y + maxX, maxY := cell.Min.X-1, cell.Min.Y-1 + for y := cell.Min.Y; y < cell.Max.Y; y++ { + if y < 0 || y >= len(bin) { + continue + } + for x := cell.Min.X; x < cell.Max.X; x++ { + if x < 0 || x >= len(bin[y]) { + continue + } + if bin[y][x] { + if x < minX { + minX = x + } + if y < minY { + minY = y + } + if x > maxX { + maxX = x + } + if y > maxY { + maxY = y + } + } + } + } + var bm Bitmap + if minX > maxX { + return bm + } + for y := minY; y <= maxY; y++ { + gy := y - minY + if gy >= GridH { + break + } + for x := minX; x <= maxX; x++ { + gx := x - minX + if gx >= GridW { + break + } + if bin[y][x] { + bm[gy*GridW+gx] = 1 + } + } + } + return bm +} + +// MatchDigit 与 10 个模板比 Hamming 距离,返回最像的数字。 +func MatchDigit(bm Bitmap) int { + best, bestD := 0, 1<<30 + for d := 0; d < 10; d++ { + dist := hamming(bm, Templates[d]) + if dist < bestD { + bestD = dist + best = d + } + } + return best +} + +func hamming(a, b Bitmap) int { + n := 0 + for i := range a { + if a[i] != b[i] { + n++ + } + } + return n +} diff --git a/server/internal/shared/digitocr/digitocr_test.go b/server/internal/shared/digitocr/digitocr_test.go new file mode 100644 index 0000000..ebc9a73 --- /dev/null +++ b/server/internal/shared/digitocr/digitocr_test.go @@ -0,0 +1,30 @@ +package digitocr + +import ( + "image" + "image/color" + "testing" +) + +// 用一张内存图测试链路:4 个矩形色块,颜色满足 IsOrange,等距排布。 +// 训练前 Templates 全 0,所以匹配结果都是 0,但能验证切分和归一化不 panic。 +func TestRecognizeImage_SmokeTest(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 50, 25)) + orange := color.RGBA{R: 240, G: 130, B: 40, A: 255} + // 4 个 8x16 的实心块,模拟 4 个数字 + for d := 0; d < 4; d++ { + x0 := 5 + d*10 + for y := 5; y < 21; y++ { + for x := x0; x < x0+8; x++ { + img.Set(x, y, orange) + } + } + } + got, err := RecognizeImage(img, Options{}) + if err != nil { + t.Fatalf("RecognizeImage: %v", err) + } + if len(got) != 4 { + t.Fatalf("want 4 digits, got %q", got) + } +} diff --git a/server/internal/shared/digitocr/samples/0150.png b/server/internal/shared/digitocr/samples/0150.png new file mode 100644 index 0000000..924fb69 Binary files /dev/null and b/server/internal/shared/digitocr/samples/0150.png differ diff --git a/server/internal/shared/digitocr/samples/0186.png b/server/internal/shared/digitocr/samples/0186.png new file mode 100644 index 0000000..b35b276 Binary files /dev/null and b/server/internal/shared/digitocr/samples/0186.png differ diff --git a/server/internal/shared/digitocr/samples/0222.png b/server/internal/shared/digitocr/samples/0222.png new file mode 100644 index 0000000..ac20243 Binary files /dev/null and b/server/internal/shared/digitocr/samples/0222.png differ diff --git a/server/internal/shared/digitocr/samples/0317.png b/server/internal/shared/digitocr/samples/0317.png new file mode 100644 index 0000000..d5cb0c0 Binary files /dev/null and b/server/internal/shared/digitocr/samples/0317.png differ diff --git a/server/internal/shared/digitocr/samples/0475.png b/server/internal/shared/digitocr/samples/0475.png new file mode 100644 index 0000000..55c53f3 Binary files /dev/null and b/server/internal/shared/digitocr/samples/0475.png differ diff --git a/server/internal/shared/digitocr/samples/0499.png b/server/internal/shared/digitocr/samples/0499.png new file mode 100644 index 0000000..b970d89 Binary files /dev/null and b/server/internal/shared/digitocr/samples/0499.png differ diff --git a/server/internal/shared/digitocr/samples/0659.png b/server/internal/shared/digitocr/samples/0659.png new file mode 100644 index 0000000..af7a640 Binary files /dev/null and b/server/internal/shared/digitocr/samples/0659.png differ diff --git a/server/internal/shared/digitocr/samples/0860.png b/server/internal/shared/digitocr/samples/0860.png new file mode 100644 index 0000000..417497f Binary files /dev/null and b/server/internal/shared/digitocr/samples/0860.png differ diff --git a/server/internal/shared/digitocr/samples/1003.png b/server/internal/shared/digitocr/samples/1003.png new file mode 100644 index 0000000..ec6f39c Binary files /dev/null and b/server/internal/shared/digitocr/samples/1003.png differ diff --git a/server/internal/shared/digitocr/samples/1316.png b/server/internal/shared/digitocr/samples/1316.png new file mode 100644 index 0000000..6eff85f Binary files /dev/null and b/server/internal/shared/digitocr/samples/1316.png differ diff --git a/server/internal/shared/digitocr/samples/1608.png b/server/internal/shared/digitocr/samples/1608.png new file mode 100644 index 0000000..cf5f334 Binary files /dev/null and b/server/internal/shared/digitocr/samples/1608.png differ diff --git a/server/internal/shared/digitocr/samples/1823.png b/server/internal/shared/digitocr/samples/1823.png new file mode 100644 index 0000000..d272fac Binary files /dev/null and b/server/internal/shared/digitocr/samples/1823.png differ diff --git a/server/internal/shared/digitocr/samples/2111.png b/server/internal/shared/digitocr/samples/2111.png new file mode 100644 index 0000000..2f96be9 Binary files /dev/null and b/server/internal/shared/digitocr/samples/2111.png differ diff --git a/server/internal/shared/digitocr/samples/2146.png b/server/internal/shared/digitocr/samples/2146.png new file mode 100644 index 0000000..a1f3124 Binary files /dev/null and b/server/internal/shared/digitocr/samples/2146.png differ diff --git a/server/internal/shared/digitocr/samples/2272.png b/server/internal/shared/digitocr/samples/2272.png new file mode 100644 index 0000000..4bd4af4 Binary files /dev/null and b/server/internal/shared/digitocr/samples/2272.png differ diff --git a/server/internal/shared/digitocr/samples/2416.png b/server/internal/shared/digitocr/samples/2416.png new file mode 100644 index 0000000..5512d64 Binary files /dev/null and b/server/internal/shared/digitocr/samples/2416.png differ diff --git a/server/internal/shared/digitocr/samples/2481.png b/server/internal/shared/digitocr/samples/2481.png new file mode 100644 index 0000000..75a3de3 Binary files /dev/null and b/server/internal/shared/digitocr/samples/2481.png differ diff --git a/server/internal/shared/digitocr/samples/2562.png b/server/internal/shared/digitocr/samples/2562.png new file mode 100644 index 0000000..de66374 Binary files /dev/null and b/server/internal/shared/digitocr/samples/2562.png differ diff --git a/server/internal/shared/digitocr/samples/2659.png b/server/internal/shared/digitocr/samples/2659.png new file mode 100644 index 0000000..8557421 Binary files /dev/null and b/server/internal/shared/digitocr/samples/2659.png differ diff --git a/server/internal/shared/digitocr/samples/2818.png b/server/internal/shared/digitocr/samples/2818.png new file mode 100644 index 0000000..a5cce4b Binary files /dev/null and b/server/internal/shared/digitocr/samples/2818.png differ diff --git a/server/internal/shared/digitocr/samples/2893.png b/server/internal/shared/digitocr/samples/2893.png new file mode 100644 index 0000000..8c80a59 Binary files /dev/null and b/server/internal/shared/digitocr/samples/2893.png differ diff --git a/server/internal/shared/digitocr/samples/3000.png b/server/internal/shared/digitocr/samples/3000.png new file mode 100644 index 0000000..d17ec41 Binary files /dev/null and b/server/internal/shared/digitocr/samples/3000.png differ diff --git a/server/internal/shared/digitocr/samples/3027.png b/server/internal/shared/digitocr/samples/3027.png new file mode 100644 index 0000000..0cff5bc Binary files /dev/null and b/server/internal/shared/digitocr/samples/3027.png differ diff --git a/server/internal/shared/digitocr/samples/3517.png b/server/internal/shared/digitocr/samples/3517.png new file mode 100644 index 0000000..10d47cc Binary files /dev/null and b/server/internal/shared/digitocr/samples/3517.png differ diff --git a/server/internal/shared/digitocr/samples/3655.png b/server/internal/shared/digitocr/samples/3655.png new file mode 100644 index 0000000..c56eae4 Binary files /dev/null and b/server/internal/shared/digitocr/samples/3655.png differ diff --git a/server/internal/shared/digitocr/samples/3710.png b/server/internal/shared/digitocr/samples/3710.png new file mode 100644 index 0000000..d30d619 Binary files /dev/null and b/server/internal/shared/digitocr/samples/3710.png differ diff --git a/server/internal/shared/digitocr/samples/3894.png b/server/internal/shared/digitocr/samples/3894.png new file mode 100644 index 0000000..a32861e Binary files /dev/null and b/server/internal/shared/digitocr/samples/3894.png differ diff --git a/server/internal/shared/digitocr/samples/4139.png b/server/internal/shared/digitocr/samples/4139.png new file mode 100644 index 0000000..c31ab06 Binary files /dev/null and b/server/internal/shared/digitocr/samples/4139.png differ diff --git a/server/internal/shared/digitocr/samples/4185.png b/server/internal/shared/digitocr/samples/4185.png new file mode 100644 index 0000000..6e34979 Binary files /dev/null and b/server/internal/shared/digitocr/samples/4185.png differ diff --git a/server/internal/shared/digitocr/samples/4261.png b/server/internal/shared/digitocr/samples/4261.png new file mode 100644 index 0000000..15d4d0c Binary files /dev/null and b/server/internal/shared/digitocr/samples/4261.png differ diff --git a/server/internal/shared/digitocr/samples/4597.png b/server/internal/shared/digitocr/samples/4597.png new file mode 100644 index 0000000..db1394c Binary files /dev/null and b/server/internal/shared/digitocr/samples/4597.png differ diff --git a/server/internal/shared/digitocr/samples/4702.png b/server/internal/shared/digitocr/samples/4702.png new file mode 100644 index 0000000..f84600f Binary files /dev/null and b/server/internal/shared/digitocr/samples/4702.png differ diff --git a/server/internal/shared/digitocr/samples/5032.png b/server/internal/shared/digitocr/samples/5032.png new file mode 100644 index 0000000..caa9091 Binary files /dev/null and b/server/internal/shared/digitocr/samples/5032.png differ diff --git a/server/internal/shared/digitocr/samples/5102.png b/server/internal/shared/digitocr/samples/5102.png new file mode 100644 index 0000000..f1c6748 Binary files /dev/null and b/server/internal/shared/digitocr/samples/5102.png differ diff --git a/server/internal/shared/digitocr/samples/5392.png b/server/internal/shared/digitocr/samples/5392.png new file mode 100644 index 0000000..9d0fc75 Binary files /dev/null and b/server/internal/shared/digitocr/samples/5392.png differ diff --git a/server/internal/shared/digitocr/samples/6272.png b/server/internal/shared/digitocr/samples/6272.png new file mode 100644 index 0000000..566f7bc Binary files /dev/null and b/server/internal/shared/digitocr/samples/6272.png differ diff --git a/server/internal/shared/digitocr/samples/6280.png b/server/internal/shared/digitocr/samples/6280.png new file mode 100644 index 0000000..c51ede7 Binary files /dev/null and b/server/internal/shared/digitocr/samples/6280.png differ diff --git a/server/internal/shared/digitocr/samples/6416.png b/server/internal/shared/digitocr/samples/6416.png new file mode 100644 index 0000000..565f0c0 Binary files /dev/null and b/server/internal/shared/digitocr/samples/6416.png differ diff --git a/server/internal/shared/digitocr/samples/6463.png b/server/internal/shared/digitocr/samples/6463.png new file mode 100644 index 0000000..0e8cb3e Binary files /dev/null and b/server/internal/shared/digitocr/samples/6463.png differ diff --git a/server/internal/shared/digitocr/samples/6667.png b/server/internal/shared/digitocr/samples/6667.png new file mode 100644 index 0000000..afcc4f2 Binary files /dev/null and b/server/internal/shared/digitocr/samples/6667.png differ diff --git a/server/internal/shared/digitocr/samples/6909.png b/server/internal/shared/digitocr/samples/6909.png new file mode 100644 index 0000000..58301d3 Binary files /dev/null and b/server/internal/shared/digitocr/samples/6909.png differ diff --git a/server/internal/shared/digitocr/samples/7017.png b/server/internal/shared/digitocr/samples/7017.png new file mode 100644 index 0000000..5ceb7ed Binary files /dev/null and b/server/internal/shared/digitocr/samples/7017.png differ diff --git a/server/internal/shared/digitocr/samples/7220.png b/server/internal/shared/digitocr/samples/7220.png new file mode 100644 index 0000000..499baac Binary files /dev/null and b/server/internal/shared/digitocr/samples/7220.png differ diff --git a/server/internal/shared/digitocr/samples/7439.png b/server/internal/shared/digitocr/samples/7439.png new file mode 100644 index 0000000..e25a26c Binary files /dev/null and b/server/internal/shared/digitocr/samples/7439.png differ diff --git a/server/internal/shared/digitocr/samples/7751.png b/server/internal/shared/digitocr/samples/7751.png new file mode 100644 index 0000000..df6abf4 Binary files /dev/null and b/server/internal/shared/digitocr/samples/7751.png differ diff --git a/server/internal/shared/digitocr/samples/7771.png b/server/internal/shared/digitocr/samples/7771.png new file mode 100644 index 0000000..16db03e Binary files /dev/null and b/server/internal/shared/digitocr/samples/7771.png differ diff --git a/server/internal/shared/digitocr/samples/7785.png b/server/internal/shared/digitocr/samples/7785.png new file mode 100644 index 0000000..281ee5e Binary files /dev/null and b/server/internal/shared/digitocr/samples/7785.png differ diff --git a/server/internal/shared/digitocr/samples/7968.png b/server/internal/shared/digitocr/samples/7968.png new file mode 100644 index 0000000..cbbb657 Binary files /dev/null and b/server/internal/shared/digitocr/samples/7968.png differ diff --git a/server/internal/shared/digitocr/samples/8104.png b/server/internal/shared/digitocr/samples/8104.png new file mode 100644 index 0000000..800de84 Binary files /dev/null and b/server/internal/shared/digitocr/samples/8104.png differ diff --git a/server/internal/shared/digitocr/samples/8287.png b/server/internal/shared/digitocr/samples/8287.png new file mode 100644 index 0000000..6248c40 Binary files /dev/null and b/server/internal/shared/digitocr/samples/8287.png differ diff --git a/server/internal/shared/digitocr/samples/8883.png b/server/internal/shared/digitocr/samples/8883.png new file mode 100644 index 0000000..bc4b04a Binary files /dev/null and b/server/internal/shared/digitocr/samples/8883.png differ diff --git a/server/internal/shared/digitocr/samples/9353.png b/server/internal/shared/digitocr/samples/9353.png new file mode 100644 index 0000000..61141c4 Binary files /dev/null and b/server/internal/shared/digitocr/samples/9353.png differ diff --git a/server/internal/shared/digitocr/samples/9569.png b/server/internal/shared/digitocr/samples/9569.png new file mode 100644 index 0000000..c8604e9 Binary files /dev/null and b/server/internal/shared/digitocr/samples/9569.png differ diff --git a/server/internal/shared/digitocr/samples/9578.png b/server/internal/shared/digitocr/samples/9578.png new file mode 100644 index 0000000..fde6dab Binary files /dev/null and b/server/internal/shared/digitocr/samples/9578.png differ diff --git a/server/internal/shared/digitocr/samples/9603.png b/server/internal/shared/digitocr/samples/9603.png new file mode 100644 index 0000000..99c4cd8 Binary files /dev/null and b/server/internal/shared/digitocr/samples/9603.png differ diff --git a/server/internal/shared/digitocr/samples/9626.png b/server/internal/shared/digitocr/samples/9626.png new file mode 100644 index 0000000..692ba8b Binary files /dev/null and b/server/internal/shared/digitocr/samples/9626.png differ diff --git a/server/internal/shared/digitocr/samples/9754.png b/server/internal/shared/digitocr/samples/9754.png new file mode 100644 index 0000000..09573a6 Binary files /dev/null and b/server/internal/shared/digitocr/samples/9754.png differ diff --git a/server/internal/shared/digitocr/samples/9767.png b/server/internal/shared/digitocr/samples/9767.png new file mode 100644 index 0000000..6c23310 Binary files /dev/null and b/server/internal/shared/digitocr/samples/9767.png differ diff --git a/server/internal/shared/digitocr/templates.go b/server/internal/shared/digitocr/templates.go new file mode 100644 index 0000000..be18a59 --- /dev/null +++ b/server/internal/shared/digitocr/templates.go @@ -0,0 +1,16 @@ +package digitocr + +// Code generated by cmd/train. DO NOT EDIT. + +var Templates = [10]Bitmap{ + {0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=0, n=27 + {0,0,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=1, n=26 + {0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=2, n=29 + {0,1,1,1,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=3, n=20 + {0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,1,1,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=4, n=16 + {1,1,1,1,1,1,1,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,1,1,1,0,0,0,0,1,1,1,0,0,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=5, n=19 + {0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,1,1,1,0,0,0,0,1,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=6, n=27 + {1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=7, n=27 + {0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=8, n=20 + {0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,1,0,0,0,0,1,1,1,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=9, n=21 +} diff --git a/server/internal/tenant/app/desktop_task_service.go b/server/internal/tenant/app/desktop_task_service.go index 2b1f5f5..dd662e7 100644 --- a/server/internal/tenant/app/desktop_task_service.go +++ b/server/internal/tenant/app/desktop_task_service.go @@ -874,11 +874,7 @@ func (s *DesktopTaskService) Reconcile(ctx context.Context, actor auth.Actor, de } } - eventType := "task_reconciled" - if task.Kind == "monitor" && req.Status == "retry" { - eventType = "task_available" - } - s.publishTaskEvent(ctx, task, eventType) + s.publishTaskEvent(ctx, task, reconcileDesktopTaskEventType(task.Kind, req.Status)) view := buildDesktopTaskView(task) return &view, nil @@ -1785,6 +1781,13 @@ func (s *DesktopTaskService) publishTaskEvent(ctx context.Context, task *reposit publishDesktopTaskLifecycleEvent(ctx, s.messaging, s.logger, task, eventType) } +func reconcileDesktopTaskEventType(_ string, status string) string { + if status == "retry" { + return "task_available" + } + return "task_reconciled" +} + func (s *DesktopTaskService) logWarn(message string, err error, fields ...zap.Field) { if s.logger == nil || err == nil { return diff --git a/server/internal/tenant/app/desktop_task_service_test.go b/server/internal/tenant/app/desktop_task_service_test.go index e8fc142..621aec4 100644 --- a/server/internal/tenant/app/desktop_task_service_test.go +++ b/server/internal/tenant/app/desktop_task_service_test.go @@ -45,6 +45,24 @@ func TestDesktopTaskRecoverySelectQueryLeaseExpiryFiltersExpiredLeases(t *testin } } +func TestReconcileRetryPublishesTaskAvailableForAllDesktopTaskKinds(t *testing.T) { + t.Parallel() + + for _, kind := range []string{"publish", "monitor"} { + if got := reconcileDesktopTaskEventType(kind, "retry"); got != "task_available" { + t.Fatalf("reconcileDesktopTaskEventType(%q, retry) = %q, want task_available", kind, got) + } + } +} + +func TestReconcileNonRetryPublishesTaskReconciled(t *testing.T) { + t.Parallel() + + if got := reconcileDesktopTaskEventType("publish", "failed"); got != "task_reconciled" { + t.Fatalf("reconcileDesktopTaskEventType(publish, failed) = %q, want task_reconciled", got) + } +} + func recoverDesktopTaskSelectColumns(query string) []string { re := regexp.MustCompile(`(?is)select\s+(.*?)\s+from\s+desktop_tasks`) match := re.FindStringSubmatch(query) diff --git a/server/internal/tenant/app/media_supply_public.go b/server/internal/tenant/app/media_supply_public.go new file mode 100644 index 0000000..3ad6d87 --- /dev/null +++ b/server/internal/tenant/app/media_supply_public.go @@ -0,0 +1,98 @@ +package app + +import ( + "errors" + "strings" +) + +const mediaSupplyPublicSupplierCode = "authority_news" + +var errMediaSupplyAccountUserIDMissing = errors.New("media supply account user_id is not configured") + +func publicMediaSupplySupplierCode(supplier string) string { + supplier = strings.TrimSpace(supplier) + if supplier == "" { + return "" + } + if strings.EqualFold(supplier, mediaSupplySupplierMeijiequan) { + return mediaSupplyPublicSupplierCode + } + return "media_supply" +} + +func publicMediaSupplyArticleURL(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if isLikelyPublishedArticleURL(value) { + return value + } + return "" +} + +func mediaSupplyPublicErrorMessage(err error) string { + if err == nil { + return "" + } + switch { + case errors.Is(err, errMeijiequanCredentialsMissing): + return "媒体供应商账号未配置,请联系管理员处理" + case errors.Is(err, errMeijiequanSessionMissing), errors.Is(err, errMeijiequanSessionExpired), errors.Is(err, errMeijiequanAuthRequired): + return "媒体供应商登录状态已失效,请联系管理员处理" + case errors.Is(err, errMeijiequanChallengeRequired): + return "媒体供应商登录验证失败,请联系管理员处理" + case errors.Is(err, errMeijiequanOrderRejected): + return "媒体供应商拒绝投稿,请检查文章内容或联系管理员处理" + case errors.Is(err, errMediaSupplySelectedResourcesMissing): + return "媒体资源价格刷新失败,请稍后重试或重新选择媒体" + case errors.Is(err, errMediaSupplyLockedSellBelowCost): + return "媒体成本价已变化,当前锁定价格低于成本,请重新提交" + case errors.Is(err, errMediaSupplyAccountUserIDMissing): + return "媒体供应商账号信息不完整,请联系管理员处理" + default: + return mediaSupplyPublicGenericErrorMessage(err.Error()) + } +} + +func mediaSupplyPublicGenericErrorMessage(message string) string { + message = strings.TrimSpace(message) + if message == "" { + return "媒体投稿失败,请稍后重试或联系管理员" + } + lower := strings.ToLower(message) + switch { + case strings.Contains(lower, "failed to refresh supplier price"): + return "媒体资源价格刷新失败,请稍后重试或重新选择媒体" + case strings.Contains(lower, "supplier cost increased above locked sell price"): + return "媒体成本价已变化,当前锁定价格低于成本,请重新提交" + case strings.Contains(lower, "username") || strings.Contains(lower, "password") || strings.Contains(lower, "credentials"): + return "媒体供应商账号未配置,请联系管理员处理" + case strings.Contains(lower, "session") || strings.Contains(lower, "authentication") || strings.Contains(lower, "auth required"): + return "媒体供应商登录状态已失效,请联系管理员处理" + case strings.Contains(lower, "challenge") || strings.Contains(lower, "captcha"): + return "媒体供应商登录验证失败,请联系管理员处理" + case strings.Contains(lower, "user_id"): + return "媒体供应商账号信息不完整,请联系管理员处理" + case strings.Contains(lower, "meijiequan"): + return "媒体供应商请求失败,请稍后重试或联系管理员" + case strings.Contains(lower, "supplier"): + return "媒体投稿失败,请稍后重试或联系管理员" + case strings.Contains(lower, "failed to"): + return "媒体投稿失败,请稍后重试或联系管理员" + default: + if hasCJKText(message) { + return message + } + return "媒体投稿失败,请稍后重试或联系管理员" + } +} + +func hasCJKText(message string) bool { + for _, r := range message { + if r >= '\u4e00' && r <= '\u9fff' { + return true + } + } + return false +} diff --git a/server/internal/tenant/app/media_supply_service.go b/server/internal/tenant/app/media_supply_service.go new file mode 100644 index 0000000..df746e4 --- /dev/null +++ b/server/internal/tenant/app/media_supply_service.go @@ -0,0 +1,1339 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + goredis "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/config" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type MediaSupplyService struct { + pool *pgxpool.Pool + logger *zap.Logger + cfg config.Provider + client *MeijiequanClient +} + +func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *zap.Logger, cfg config.Provider) *MediaSupplyService { + current := config.Config{} + if cfg != nil && cfg.Current() != nil { + current = *cfg.Current() + } + client := NewMeijiequanClient(redis, current.MediaSupply.Meijiequan) + return &MediaSupplyService{ + pool: pool, + logger: logger, + cfg: cfg, + client: client, + } +} + +func (s *MediaSupplyService) mediaSupplyConfig() config.MediaSupplyConfig { + if s != nil && s.cfg != nil && s.cfg.Current() != nil { + cfg := s.cfg.Current().MediaSupply + if s.client != nil { + s.client.SetConfig(cfg.Meijiequan) + } + return cfg + } + var cfg config.MediaSupplyConfig + config.NormalizeMediaSupplyConfig(&cfg) + if s != nil && s.client != nil { + s.client.SetConfig(cfg.Meijiequan) + } + return cfg +} + +func (s *MediaSupplyService) ListResources(ctx context.Context, req ListSupplierMediaResourcesRequest) (*ListSupplierMediaResourcesResponse, error) { + if s == nil || s.pool == nil { + return nil, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用") + } + page, pageSize := normalizeMediaSupplyPagination(req.Page, req.PageSize) + args := []any{mediaSupplySupplierMeijiequan} + where := []string{"r.supplier = $1", "r.deleted_at IS NULL"} + if !req.IncludeHidden { + where = append(where, "r.customer_visible = TRUE") + } + cfg := s.mediaSupplyConfig() + sellPriceSQL := mediaSupplySellPriceSQL(cfg) + if req.ModelID > 0 { + args = append(args, req.ModelID) + where = append(where, fmt.Sprintf("r.model_id = $%d", len(args))) + } + if keyword := strings.TrimSpace(req.Keyword); keyword != "" { + args = append(args, "%"+keyword+"%") + where = append(where, fmt.Sprintf("r.name ILIKE $%d", len(args))) + } + if keyword := strings.TrimSpace(req.RemarkKeyword); keyword != "" { + args = append(args, "%"+keyword+"%") + where = append(where, fmt.Sprintf("r.resource_remark ILIKE $%d", len(args))) + } + if value := strings.TrimSpace(req.ChannelType); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.channel_type = $%d", len(args))) + } + if value := strings.TrimSpace(req.Portal); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.raw_json->>'zonghemenhu' = $%d", len(args))) + } + if value := strings.TrimSpace(req.Region); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.region = $%d", len(args))) + } + if value := strings.TrimSpace(req.InclusionEffect); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.inclusion_effect = $%d", len(args))) + } + if value := strings.TrimSpace(req.SpecialIndustry); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.raw_json->>'tebiehangye' = $%d", len(args))) + } + if value := strings.TrimSpace(req.LinkType); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.link_type = $%d", len(args))) + } + if value := strings.TrimSpace(req.DeliverySpeed); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.delivery_speed = $%d", len(args))) + } + if value := strings.TrimSpace(req.AIInclude); value != "" && value != "全部" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.raw_json->>'aiInclude' = $%d", len(args))) + } + if value := strings.TrimSpace(req.OtherOption); value != "" { + args = append(args, value) + where = append(where, fmt.Sprintf("r.raw_json->>'otherOption' = $%d", len(args))) + } + if req.MinPriceCents > 0 { + args = append(args, req.MinPriceCents) + where = append(where, fmt.Sprintf("(%s) >= $%d", sellPriceSQL, len(args))) + } + if req.MaxPriceCents > 0 { + args = append(args, req.MaxPriceCents) + where = append(where, fmt.Sprintf("(%s) <= $%d", sellPriceSQL, len(args))) + } + whereSQL := strings.Join(where, " AND ") + orderSQL := mediaSupplyResourceOrderSQL(req.Sort, sellPriceSQL) + var total int64 + if err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM supplier_media_resources r + LEFT JOIN supplier_media_price_overrides o + ON o.resource_id = r.id AND o.price_type = 'price' + WHERE `+whereSQL, args...).Scan(&total); err != nil { + return nil, response.ErrInternal(50060, "media_supply_resource_count_failed", "媒体资源统计失败") + } + args = append(args, pageSize, (page-1)*pageSize) + rows, err := s.pool.Query(ctx, ` + SELECT r.id, r.supplier, r.supplier_resource_id, r.model_id, r.name, r.status, + r.cost_price_cents, r.cost_prices_json, r.sale_price_label, + r.resource_url, r.baidu_weight, r.resource_remark, r.customer_visible, r.channel_type, + r.region, r.inclusion_effect, r.link_type, r.publish_rate, r.delivery_speed, + r.supplier_updated_at, r.last_synced_at, r.raw_json, + COALESCE(o.sell_price_cents, 0), COALESCE(o.enabled, false) + FROM supplier_media_resources r + LEFT JOIN supplier_media_price_overrides o + ON o.resource_id = r.id AND o.price_type = 'price' + WHERE `+whereSQL+` + ORDER BY `+orderSQL+` + LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args))+` + `, args...) + if err != nil { + return nil, response.ErrInternal(50061, "media_supply_resource_query_failed", "媒体资源读取失败") + } + defer rows.Close() + items := make([]SupplierMediaResource, 0, pageSize) + for rows.Next() { + var item SupplierMediaResource + var costPricesRaw []byte + var raw []byte + var overrideCents int64 + var overrideEnabled bool + if err := rows.Scan( + &item.ID, + &item.Supplier, + &item.SupplierResourceID, + &item.ModelID, + &item.Name, + &item.Status, + &item.CostPriceCents, + &costPricesRaw, + &item.SalePriceLabel, + &item.ResourceURL, + &item.BaiduWeight, + &item.ResourceRemark, + &item.CustomerVisible, + &item.ChannelType, + &item.Region, + &item.InclusionEffect, + &item.LinkType, + &item.PublishRate, + &item.DeliverySpeed, + &item.SupplierUpdatedAt, + &item.LastSyncedAt, + &raw, + &overrideCents, + &overrideEnabled, + ); err != nil { + return nil, response.ErrInternal(50062, "media_supply_resource_scan_failed", "媒体资源解析失败") + } + _ = json.Unmarshal(costPricesRaw, &item.CostPrices) + if item.CostPrices == nil { + item.CostPrices = map[string]int64{} + } + item.SellPriceCents = mediaSupplySellPrice(item.CostPriceCents, overrideCents, overrideEnabled, cfg.DefaultMarkupPercent, cfg.MinimumMarkupCents) + item.Raw = json.RawMessage(raw) + items = append(items, item) + } + return &ListSupplierMediaResourcesResponse{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (s *MediaSupplyService) ListCustomerResources(ctx context.Context, req ListSupplierMediaResourcesRequest) (*ListCustomerSupplierMediaResourcesResponse, error) { + req.IncludeHidden = false + data, err := s.ListResources(ctx, req) + if err != nil { + return nil, err + } + items := make([]CustomerSupplierMediaResource, 0, len(data.Items)) + for _, item := range data.Items { + items = append(items, CustomerSupplierMediaResource{ + ID: item.ID, + SupplierResourceID: item.SupplierResourceID, + ModelID: item.ModelID, + Name: item.Name, + Status: item.Status, + SellPriceCents: item.SellPriceCents, + ResourceURL: item.ResourceURL, + BaiduWeight: item.BaiduWeight, + ResourceRemark: item.ResourceRemark, + ChannelType: item.ChannelType, + Region: item.Region, + InclusionEffect: item.InclusionEffect, + LinkType: item.LinkType, + PublishRate: item.PublishRate, + DeliverySpeed: item.DeliverySpeed, + LastSyncedAt: item.LastSyncedAt, + }) + } + return &ListCustomerSupplierMediaResourcesResponse{ + Items: items, + Total: data.Total, + Page: data.Page, + PageSize: data.PageSize, + }, nil +} + +func (s *MediaSupplyService) ListCustomerResourcesByIDs(ctx context.Context, ids []int64, modelID int) (*ListCustomerSupplierMediaResourcesResponse, error) { + if s == nil || s.pool == nil { + return nil, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用") + } + ids = normalizeMediaSupplyResourceIDs(ids) + if len(ids) == 0 { + return &ListCustomerSupplierMediaResourcesResponse{ + Items: []CustomerSupplierMediaResource{}, + Total: 0, + Page: 1, + PageSize: 0, + }, nil + } + + args := []any{mediaSupplySupplierMeijiequan, ids} + where := []string{ + "r.supplier = $1", + "r.id = ANY($2::BIGINT[])", + "r.deleted_at IS NULL", + "r.customer_visible = TRUE", + } + if modelID > 0 { + args = append(args, modelID) + where = append(where, fmt.Sprintf("r.model_id = $%d", len(args))) + } + + cfg := s.mediaSupplyConfig() + rows, err := s.pool.Query(ctx, ` + SELECT r.id, r.supplier_resource_id, r.model_id, r.name, r.status, + `+mediaSupplySellPriceSQL(cfg)+`, + r.resource_url, r.baidu_weight, r.resource_remark, r.channel_type, + r.region, r.inclusion_effect, r.link_type, r.publish_rate, r.delivery_speed, + r.last_synced_at + FROM supplier_media_resources r + LEFT JOIN supplier_media_price_overrides o + ON o.resource_id = r.id AND o.price_type = 'price' + WHERE `+strings.Join(where, " AND ")+` + ORDER BY array_position($2::BIGINT[], r.id) + `, args...) + if err != nil { + return nil, response.ErrInternal(50061, "media_supply_resource_query_failed", "媒体资源读取失败") + } + defer rows.Close() + + items := make([]CustomerSupplierMediaResource, 0, len(ids)) + for rows.Next() { + var item CustomerSupplierMediaResource + if err := rows.Scan( + &item.ID, + &item.SupplierResourceID, + &item.ModelID, + &item.Name, + &item.Status, + &item.SellPriceCents, + &item.ResourceURL, + &item.BaiduWeight, + &item.ResourceRemark, + &item.ChannelType, + &item.Region, + &item.InclusionEffect, + &item.LinkType, + &item.PublishRate, + &item.DeliverySpeed, + &item.LastSyncedAt, + ); err != nil { + return nil, response.ErrInternal(50062, "media_supply_resource_scan_failed", "媒体资源解析失败") + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, response.ErrInternal(50062, "media_supply_resource_scan_failed", "媒体资源解析失败") + } + return &ListCustomerSupplierMediaResourcesResponse{ + Items: items, + Total: int64(len(items)), + Page: 1, + PageSize: len(items), + }, nil +} + +func (s *MediaSupplyService) QueueSync(ctx context.Context, modelID int) (int64, error) { + actor := auth.MustActor(ctx) + if !isTenantAdmin(actor) { + return 0, response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限") + } + if modelID <= 0 { + modelID = 1 + } + if !isKnownMeijiequanModel(modelID) { + return 0, response.ErrBadRequest(40060, "invalid_media_supply_model", "不支持的媒体资源类型") + } + var id int64 + if err := s.pool.QueryRow(ctx, ` + WITH existing AS ( + SELECT id + FROM media_supply_sync_jobs + WHERE supplier = $1 + AND status IN ($4::varchar, $5::varchar) + ORDER BY created_at ASC + LIMIT 1 + ), inserted AS ( + INSERT INTO media_supply_sync_jobs (supplier, model_id, requested_by) + SELECT $1, $2, $3 + WHERE NOT EXISTS (SELECT 1 FROM existing) + RETURNING id + ) + SELECT id FROM inserted + UNION ALL + SELECT id FROM existing + LIMIT 1 + `, mediaSupplySupplierMeijiequan, modelID, actor.UserID, mediaSupplySyncStatusQueued, mediaSupplySyncStatusRunning).Scan(&id); err != nil { + return 0, response.ErrInternal(50063, "media_supply_sync_queue_failed", "媒体资源同步排队失败") + } + return id, nil +} + +func (s *MediaSupplyService) SearchOptions(ctx context.Context, modelID int) (*MediaSupplySearchOptionsResponse, error) { + if modelID <= 0 { + modelID = 1 + } + if !isKnownMeijiequanModel(modelID) { + return nil, response.ErrBadRequest(40060, "invalid_media_supply_model", "不支持的媒体资源类型") + } + _ = s.mediaSupplyConfig() + data, err := s.client.SearchOptions(ctx, modelID) + if err != nil { + return nil, mediaSupplyClientError(err) + } + return data, nil +} + +func (s *MediaSupplyService) ImportSession(ctx context.Context, req ImportMeijiequanSessionRequest) (*MeijiequanSessionStatus, error) { + if !isTenantAdmin(auth.MustActor(ctx)) { + return nil, response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限") + } + _ = s.mediaSupplyConfig() + status, err := s.client.ImportSession(ctx, req) + if err != nil { + return nil, mediaSupplyClientError(err) + } + return status, nil +} + +func (s *MediaSupplyService) LoginConfiguredAccount(ctx context.Context) (*MeijiequanSessionStatus, error) { + if !isTenantAdmin(auth.MustActor(ctx)) { + return nil, response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限") + } + _ = s.mediaSupplyConfig() + status, err := s.client.LoginWithConfiguredAccount(ctx) + if err != nil { + return nil, mediaSupplyClientError(err) + } + return status, nil +} + +func (s *MediaSupplyService) SessionStatus(ctx context.Context) (*MeijiequanSessionStatus, error) { + if !isTenantAdmin(auth.MustActor(ctx)) { + return nil, response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限") + } + _ = s.mediaSupplyConfig() + status, err := s.client.SessionStatus(ctx) + if err != nil { + return nil, mediaSupplyClientError(err) + } + 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 { + if actor, ok := auth.ActorFromCtx(ctx); ok && !isTenantAdmin(actor) { + return response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限") + } + priceType := normalizeMediaSupplyPriceType(req.PriceType) + if req.SellPriceCents < 0 { + return response.ErrBadRequest(40061, "invalid_media_supply_price", "媒体售价不能为负数") + } + cost, err := s.resourceCostForPriceType(ctx, resourceID, priceType) + if err != nil { + return err + } + sellPriceCents := mediaSupplyRoundUpToYuan(req.SellPriceCents) + if sellPriceCents < cost { + return response.ErrConflict(40960, "media_supply_price_below_cost", "媒体售价不能低于成本价") + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + var updatedBy any + if actor, ok := auth.ActorFromCtx(ctx); ok { + updatedBy = actor.UserID + } + if _, err := s.pool.Exec(ctx, ` + INSERT INTO supplier_media_price_overrides (resource_id, price_type, sell_price_cents, enabled, updated_by) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (resource_id, price_type) DO UPDATE SET + sell_price_cents = EXCLUDED.sell_price_cents, + enabled = EXCLUDED.enabled, + updated_by = EXCLUDED.updated_by, + updated_at = NOW() + `, resourceID, priceType, sellPriceCents, enabled, updatedBy); err != nil { + return response.ErrInternal(50064, "media_supply_price_update_failed", "媒体价格更新失败") + } + return nil +} + +func (s *MediaSupplyService) SetResourceVisibility(ctx context.Context, resourceID int64, req SetSupplierMediaVisibilityRequest) error { + if actor, ok := auth.ActorFromCtx(ctx); ok && !isTenantAdmin(actor) { + return response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限") + } + tag, err := s.pool.Exec(ctx, ` + UPDATE supplier_media_resources + SET customer_visible = $3, updated_at = NOW() + WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL + `, resourceID, mediaSupplySupplierMeijiequan, req.CustomerVisible) + if err != nil { + return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败") + } + if tag.RowsAffected() == 0 { + return response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架") + } + return nil +} + +func (s *MediaSupplyService) CreateOrder(ctx context.Context, req CreateMediaSupplyOrderRequest) (*CreateMediaSupplyOrderResponse, error) { + actor := auth.MustActor(ctx) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return nil, err + } + if req.ModelID <= 0 || !isKnownMeijiequanModel(req.ModelID) { + return nil, response.ErrBadRequest(40060, "invalid_media_supply_model", "不支持的媒体资源类型") + } + if len(req.Items) == 0 { + return nil, response.ErrBadRequest(40062, "media_supply_order_items_required", "请至少选择一个媒体资源") + } + if len(req.Items) > 100 { + return nil, response.ErrBadRequest(40063, "media_supply_order_items_too_many", "一次投稿选择的媒体资源过多") + } + title := strings.TrimSpace(req.Title) + content := strings.TrimSpace(req.Content) + if req.ArticleID != nil && *req.ArticleID > 0 { + articleTitle, articleContent, loadErr := s.loadArticleContent(ctx, actor.TenantID, brandID, *req.ArticleID) + if loadErr != nil { + return nil, loadErr + } + if title == "" { + title = articleTitle + } + if content == "" { + content = articleContent + } + } + if title == "" { + return nil, response.ErrBadRequest(40064, "media_supply_order_title_required", "请输入投稿标题") + } + if content == "" { + return nil, response.ErrBadRequest(40065, "media_supply_order_content_required", "请输入投稿内容") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, response.ErrInternal(50065, "media_supply_order_begin_failed", "媒体投稿创建失败") + } + defer tx.Rollback(ctx) + + cfg := s.mediaSupplyConfig() + seen := make(map[int64]string, len(req.Items)) + orderItems := make([]MediaSupplyOrderItem, 0, len(req.Items)) + var costTotal, sellTotal int64 + for _, requested := range req.Items { + if requested.ResourceID <= 0 { + return nil, response.ErrBadRequest(40066, "invalid_media_supply_resource", "媒体资源参数不正确") + } + priceType := normalizeMediaSupplyPriceType(requested.PriceType) + if existing, exists := seen[requested.ResourceID]; exists && existing == priceType { + continue + } + seen[requested.ResourceID] = priceType + resource, overrideCents, overrideEnabled, loadErr := s.loadResourceForOrder(ctx, tx, requested.ResourceID, req.ModelID, priceType) + if loadErr != nil { + return nil, loadErr + } + cost := costForResourcePriceType(resource, priceType) + if cost <= 0 { + return nil, response.ErrConflict(40961, "media_supply_cost_missing", "部分媒体资源成本价缺失,请联系管理员处理") + } + sell := mediaSupplySellPrice(cost, overrideCents, overrideEnabled, cfg.DefaultMarkupPercent, cfg.MinimumMarkupCents) + if sell < cost { + return nil, response.ErrConflict(40960, "media_supply_price_below_cost", "媒体售价不能低于成本价") + } + costTotal += cost + sellTotal += sell + orderItems = append(orderItems, MediaSupplyOrderItem{ + ResourceID: resource.ID, + SupplierResourceID: resource.SupplierResourceID, + PriceType: priceType, + ResourceNameSnapshot: resource.Name, + LockedCostPriceCents: cost, + LockedSellPriceCents: sell, + }) + } + if len(orderItems) == 0 { + return nil, response.ErrBadRequest(40062, "media_supply_order_items_required", "请至少选择一个媒体资源") + } + requestPayload, _ := json.Marshal(map[string]any{ + "article_id": req.ArticleID, + "model_id": req.ModelID, + "title": title, + "remark": strings.TrimSpace(req.Remark), + "order_brand": strings.TrimSpace(req.OrderBrand), + "items": req.Items, + "extra": req.Extra, + }) + var orderID int64 + if err := tx.QueryRow(ctx, ` + INSERT INTO media_supply_orders ( + tenant_id, workspace_id, brand_id, user_id, article_id, supplier, model_id, status, + title, content_snapshot, remark, order_brand, cost_total_cents, sell_total_cents, + wallet_debit_cents, wallet_debited_at, request_payload_json + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), $16) + RETURNING id + `, actor.TenantID, actor.PrimaryWorkspaceID, brandID, actor.UserID, nullableInt64Ptr(req.ArticleID), + mediaSupplySupplierMeijiequan, req.ModelID, mediaSupplyOrderStatusQueued, title, content, + nullableTrimmedString(req.Remark), nullableTrimmedString(req.OrderBrand), costTotal, sellTotal, sellTotal, requestPayload, + ).Scan(&orderID); err != nil { + return nil, response.ErrInternal(50066, "media_supply_order_create_failed", "媒体投稿创建失败") + } + balanceAfter, err := s.debitMediaSupplyWallet(ctx, tx, actor.TenantID, actor.UserID, sellTotal, orderID, title, actor.UserID) + if err != nil { + return nil, err + } + for _, item := range orderItems { + if _, err := tx.Exec(ctx, ` + INSERT INTO media_supply_order_items ( + order_id, resource_id, supplier_resource_id, price_type, resource_name_snapshot, + locked_cost_price_cents, locked_sell_price_cents + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, orderID, item.ResourceID, item.SupplierResourceID, item.PriceType, item.ResourceNameSnapshot, + item.LockedCostPriceCents, item.LockedSellPriceCents); err != nil { + return nil, response.ErrInternal(50067, "media_supply_order_item_create_failed", "媒体投稿明细创建失败") + } + } + if err := tx.Commit(ctx); err != nil { + return nil, response.ErrInternal(50068, "media_supply_order_commit_failed", "媒体投稿创建失败") + } + return &CreateMediaSupplyOrderResponse{ + OrderID: orderID, + Status: mediaSupplyOrderStatusQueued, + CostTotalCents: costTotal, + SellTotalCents: sellTotal, + BalanceAfterCents: balanceAfter, + }, nil +} + +func (s *MediaSupplyService) WalletStatus(ctx context.Context) (*MediaSupplyWalletStatus, error) { + actor := auth.MustActor(ctx) + if s == nil || s.pool == nil { + return nil, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用") + } + if err := s.ensureWalletRow(ctx, s.pool, actor.TenantID, actor.UserID); err != nil { + return nil, response.ErrInternal(50077, "media_supply_wallet_prepare_failed", "媒体投稿余额初始化失败") + } + var status MediaSupplyWalletStatus + var ledgerTotal sql.NullInt64 + var lastLedgerAt sql.NullTime + if err := s.pool.QueryRow(ctx, ` + SELECT w.tenant_id, w.user_id, w.balance_cents, w.updated_at, + COUNT(l.id), MAX(l.created_at) + FROM media_supply_user_wallets w + LEFT JOIN media_supply_wallet_ledgers l + ON l.tenant_id = w.tenant_id AND l.user_id = w.user_id + WHERE w.tenant_id = $1 AND w.user_id = $2 + GROUP BY w.tenant_id, w.user_id, w.balance_cents, w.updated_at + `, actor.TenantID, actor.UserID).Scan( + &status.TenantID, + &status.UserID, + &status.BalanceCents, + &status.UpdatedAt, + &ledgerTotal, + &lastLedgerAt, + ); err != nil { + return nil, response.ErrInternal(50078, "media_supply_wallet_lookup_failed", "媒体投稿余额读取失败") + } + if ledgerTotal.Valid { + status.LedgerTotal = &ledgerTotal.Int64 + } + if lastLedgerAt.Valid { + status.LastLedgerAt = &lastLedgerAt.Time + } + return &status, nil +} + +func (s *MediaSupplyService) ListWalletLedgers(ctx context.Context, req ListMediaSupplyWalletLedgersRequest) (*ListMediaSupplyWalletLedgersResponse, error) { + actor := auth.MustActor(ctx) + page, pageSize := normalizeMediaSupplyPagination(req.Page, req.PageSize) + userID := actor.UserID + if req.UserID > 0 && req.UserID != actor.UserID { + if !isTenantAdmin(actor) { + return nil, response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限") + } + if err := s.ensureWalletUserInTenant(ctx, actor.TenantID, req.UserID); err != nil { + return nil, err + } + userID = req.UserID + } + args := []any{actor.TenantID, userID} + where := []string{"l.tenant_id = $1", "l.user_id = $2"} + if req.CreatedFrom != nil { + args = append(args, *req.CreatedFrom) + where = append(where, fmt.Sprintf("l.created_at >= $%d", len(args))) + } + if req.CreatedTo != nil { + args = append(args, *req.CreatedTo) + where = append(where, fmt.Sprintf("l.created_at < $%d", len(args))) + } + whereSQL := strings.Join(where, " AND ") + var total int64 + if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM media_supply_wallet_ledgers l WHERE `+whereSQL, args...).Scan(&total); err != nil { + return nil, response.ErrInternal(50079, "media_supply_wallet_ledger_count_failed", "媒体投稿账单统计失败") + } + args = append(args, pageSize, (page-1)*pageSize) + rows, err := s.pool.Query(ctx, ` + SELECT l.id, l.tenant_id, l.user_id, l.order_id, o.title, l.delta_cents, + l.balance_after_cents, l.reason, l.note, l.created_by, l.created_at + FROM media_supply_wallet_ledgers l + LEFT JOIN media_supply_orders o ON o.id = l.order_id + WHERE `+whereSQL+` + ORDER BY l.created_at DESC, l.id DESC + LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args))+` + `, args...) + if err != nil { + return nil, response.ErrInternal(50080, "media_supply_wallet_ledger_query_failed", "媒体投稿账单读取失败") + } + defer rows.Close() + items := make([]MediaSupplyWalletLedger, 0, pageSize) + for rows.Next() { + var item MediaSupplyWalletLedger + var orderID sql.NullInt64 + var orderTitle sql.NullString + var note sql.NullString + var createdBy sql.NullInt64 + if err := rows.Scan( + &item.ID, + &item.TenantID, + &item.UserID, + &orderID, + &orderTitle, + &item.DeltaCents, + &item.BalanceAfterCents, + &item.Reason, + ¬e, + &createdBy, + &item.CreatedAt, + ); err != nil { + return nil, response.ErrInternal(50081, "media_supply_wallet_ledger_scan_failed", "媒体投稿账单解析失败") + } + if orderID.Valid { + item.OrderID = &orderID.Int64 + } + if orderTitle.Valid { + item.OrderTitle = &orderTitle.String + } + if note.Valid { + noteText := mediaSupplyPublicGenericErrorMessage(note.String) + item.Note = ¬eText + } + if createdBy.Valid { + item.CreatedBy = &createdBy.Int64 + } + items = append(items, item) + } + return &ListMediaSupplyWalletLedgersResponse{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (s *MediaSupplyService) AdjustWallet(ctx context.Context, req AdjustMediaSupplyWalletRequest) (*MediaSupplyWalletStatus, error) { + actor := auth.MustActor(ctx) + if !isTenantAdmin(actor) { + return nil, response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限") + } + if req.UserID <= 0 { + return nil, response.ErrBadRequest(40067, "invalid_media_supply_wallet_user", "用户参数不正确") + } + if req.DeltaCents == 0 { + return nil, response.ErrBadRequest(40068, "invalid_media_supply_wallet_delta", "调整金额不能为 0") + } + if err := s.ensureWalletUserInTenant(ctx, actor.TenantID, req.UserID); err != nil { + return nil, err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, response.ErrInternal(50082, "media_supply_wallet_begin_failed", "媒体投稿余额调整失败") + } + defer tx.Rollback(ctx) + balance, err := s.lockMediaSupplyWallet(ctx, tx, actor.TenantID, req.UserID) + if err != nil { + return nil, err + } + balanceAfter := balance + req.DeltaCents + if balanceAfter < 0 { + return nil, response.ErrConflict(40965, "media_supply_balance_insufficient", "媒体投稿余额不足") + } + if _, err := tx.Exec(ctx, ` + UPDATE media_supply_user_wallets + SET balance_cents = $3, updated_at = NOW() + WHERE tenant_id = $1 AND user_id = $2 + `, actor.TenantID, req.UserID, balanceAfter); err != nil { + return nil, response.ErrInternal(50083, "media_supply_wallet_update_failed", "媒体投稿余额更新失败") + } + reason := "adjustment" + if req.DeltaCents > 0 { + reason = "recharge" + } + if _, err := tx.Exec(ctx, ` + INSERT INTO media_supply_wallet_ledgers ( + tenant_id, user_id, delta_cents, balance_after_cents, reason, note, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, actor.TenantID, req.UserID, req.DeltaCents, balanceAfter, reason, nullableTrimmedString(req.Note), actor.UserID); err != nil { + return nil, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败") + } + if err := tx.Commit(ctx); err != nil { + return nil, response.ErrInternal(50085, "media_supply_wallet_commit_failed", "媒体投稿余额调整失败") + } + return &MediaSupplyWalletStatus{ + TenantID: actor.TenantID, + UserID: req.UserID, + BalanceCents: balanceAfter, + UpdatedAt: time.Now().UTC(), + }, nil +} + +type mediaSupplyWalletExecutor interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +} + +func (s *MediaSupplyService) ensureWalletRow(ctx context.Context, q mediaSupplyWalletExecutor, tenantID, userID int64) error { + _, err := q.Exec(ctx, ` + INSERT INTO media_supply_user_wallets (tenant_id, user_id, balance_cents) + VALUES ($1, $2, 0) + ON CONFLICT (tenant_id, user_id) DO NOTHING + `, tenantID, userID) + return err +} + +func (s *MediaSupplyService) ensureWalletUserInTenant(ctx context.Context, tenantID, userID int64) error { + var exists bool + if err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM tenant_memberships + WHERE tenant_id = $1 AND user_id = $2 AND deleted_at IS NULL + ) + `, tenantID, userID).Scan(&exists); err != nil { + return response.ErrInternal(50086, "media_supply_wallet_user_lookup_failed", "媒体投稿余额用户校验失败") + } + if !exists { + return response.ErrNotFound(40463, "media_supply_wallet_user_not_found", "用户不存在或不属于当前租户") + } + return nil +} + +func (s *MediaSupplyService) lockMediaSupplyWallet(ctx context.Context, tx pgx.Tx, tenantID, userID int64) (int64, error) { + if err := s.ensureWalletRow(ctx, tx, tenantID, userID); err != nil { + return 0, response.ErrInternal(50077, "media_supply_wallet_prepare_failed", "媒体投稿余额初始化失败") + } + var balance int64 + if err := tx.QueryRow(ctx, ` + SELECT balance_cents + FROM media_supply_user_wallets + WHERE tenant_id = $1 AND user_id = $2 + FOR UPDATE + `, tenantID, userID).Scan(&balance); err != nil { + return 0, response.ErrInternal(50078, "media_supply_wallet_lookup_failed", "媒体投稿余额读取失败") + } + return balance, nil +} + +func (s *MediaSupplyService) debitMediaSupplyWallet(ctx context.Context, tx pgx.Tx, tenantID, userID, amountCents, orderID int64, orderTitle string, createdBy int64) (int64, error) { + if amountCents <= 0 { + balance, err := s.lockMediaSupplyWallet(ctx, tx, tenantID, userID) + return balance, err + } + balance, err := s.lockMediaSupplyWallet(ctx, tx, tenantID, userID) + if err != nil { + return 0, err + } + if balance < amountCents { + return 0, response.ErrConflict(40965, "media_supply_balance_insufficient", "媒体投稿余额不足") + } + balanceAfter := balance - amountCents + if _, err := tx.Exec(ctx, ` + UPDATE media_supply_user_wallets + SET balance_cents = $3, updated_at = NOW() + WHERE tenant_id = $1 AND user_id = $2 + `, tenantID, userID, balanceAfter); err != nil { + return 0, response.ErrInternal(50083, "media_supply_wallet_update_failed", "媒体投稿余额更新失败") + } + if _, err := tx.Exec(ctx, ` + INSERT INTO media_supply_wallet_ledgers ( + tenant_id, user_id, order_id, delta_cents, balance_after_cents, reason, note, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, tenantID, userID, orderID, -amountCents, balanceAfter, "order_debit", nullableTrimmedString(orderTitle), createdBy); err != nil { + return 0, response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败") + } + return balanceAfter, nil +} + +func (s *MediaSupplyService) refundMediaSupplyWalletForOrder(ctx context.Context, tx pgx.Tx, orderID int64, note string) error { + var tenantID, userID, debitCents int64 + var refundedAt sql.NullTime + var title string + if err := tx.QueryRow(ctx, ` + SELECT tenant_id, user_id, wallet_debit_cents, wallet_refunded_at, title + FROM media_supply_orders + WHERE id = $1 + FOR UPDATE + `, orderID).Scan(&tenantID, &userID, &debitCents, &refundedAt, &title); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return response.ErrNotFound(40462, "media_supply_order_not_found", "媒体投稿订单不存在") + } + return response.ErrInternal(50076, "media_supply_order_scan_failed", "媒体投稿订单解析失败") + } + if refundedAt.Valid || debitCents <= 0 { + return nil + } + balance, err := s.lockMediaSupplyWallet(ctx, tx, tenantID, userID) + if err != nil { + return err + } + balanceAfter := balance + debitCents + if _, err := tx.Exec(ctx, ` + UPDATE media_supply_user_wallets + SET balance_cents = $3, updated_at = NOW() + WHERE tenant_id = $1 AND user_id = $2 + `, tenantID, userID, balanceAfter); err != nil { + return response.ErrInternal(50083, "media_supply_wallet_update_failed", "媒体投稿余额更新失败") + } + if _, err := tx.Exec(ctx, ` + UPDATE media_supply_orders + SET wallet_refunded_at = NOW(), updated_at = NOW() + WHERE id = $1 AND wallet_refunded_at IS NULL + `, orderID); err != nil { + return response.ErrInternal(50066, "media_supply_order_create_failed", "媒体投稿退款状态更新失败") + } + ledgerNote := strings.TrimSpace(note) + if ledgerNote == "" { + ledgerNote = title + } + if _, err := tx.Exec(ctx, ` + INSERT INTO media_supply_wallet_ledgers ( + tenant_id, user_id, order_id, delta_cents, balance_after_cents, reason, note, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, tenantID, userID, orderID, debitCents, balanceAfter, "order_refund", nullableTrimmedString(ledgerNote), userID); err != nil { + return response.ErrInternal(50084, "media_supply_wallet_ledger_create_failed", "媒体投稿账单创建失败") + } + return nil +} + +func (s *MediaSupplyService) ListOrders(ctx context.Context, req ListMediaSupplyOrdersRequest) (*ListMediaSupplyOrdersResponse, error) { + actor := auth.MustActor(ctx) + page, pageSize := normalizeMediaSupplyPagination(req.Page, req.PageSize) + args := []any{actor.TenantID, actor.UserID} + where := []string{"tenant_id = $1", "user_id = $2", "deleted_at IS NULL"} + if status := strings.TrimSpace(req.Status); status != "" { + args = append(args, status) + where = append(where, fmt.Sprintf("status = $%d", len(args))) + } + if req.ArticleID != nil && *req.ArticleID > 0 { + args = append(args, *req.ArticleID) + where = append(where, fmt.Sprintf("article_id = $%d", len(args))) + } + whereSQL := strings.Join(where, " AND ") + var total int64 + if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM media_supply_orders WHERE `+whereSQL, args...).Scan(&total); err != nil { + return nil, response.ErrInternal(50069, "media_supply_order_count_failed", "媒体投稿订单统计失败") + } + args = append(args, pageSize, (page-1)*pageSize) + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, workspace_id, brand_id, user_id, article_id, supplier, model_id, status, + title, remark, order_brand, external_order_id, external_order_code, supplier_status, + cost_total_cents, sell_total_cents, wallet_debit_cents, wallet_debited_at, + wallet_refunded_at, error_message, attempt_count, queued_at, + submitted_at, completed_at, created_at, updated_at + FROM media_supply_orders + WHERE `+whereSQL+` + ORDER BY created_at DESC, id DESC + LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args))+` + `, args...) + if err != nil { + return nil, response.ErrInternal(50070, "media_supply_order_query_failed", "媒体投稿订单读取失败") + } + defer rows.Close() + items := make([]MediaSupplyOrderDetail, 0, pageSize) + for rows.Next() { + item, scanErr := scanMediaSupplyOrder(rows) + if scanErr != nil { + return nil, scanErr + } + items = append(items, item) + } + if err := s.attachOrderItems(ctx, items); err != nil { + return nil, err + } + return &ListMediaSupplyOrdersResponse{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (s *MediaSupplyService) GetOrder(ctx context.Context, orderID int64) (*MediaSupplyOrderDetail, error) { + actor := auth.MustActor(ctx) + row := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, workspace_id, brand_id, user_id, article_id, supplier, model_id, status, + title, remark, order_brand, external_order_id, external_order_code, supplier_status, + cost_total_cents, sell_total_cents, wallet_debit_cents, wallet_debited_at, + wallet_refunded_at, error_message, attempt_count, queued_at, + submitted_at, completed_at, created_at, updated_at + FROM media_supply_orders + WHERE id = $1 AND tenant_id = $2 AND user_id = $3 AND deleted_at IS NULL + `, orderID, actor.TenantID, actor.UserID) + item, err := scanMediaSupplyOrder(row) + if err != nil { + return nil, err + } + children, err := s.listOrderItems(ctx, orderID) + if err != nil { + return nil, err + } + item.Items = children + return &item, nil +} + +func (s *MediaSupplyService) UpsertSyncedResources(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (int, error) { + if len(resources) == 0 { + return 0, nil + } + count := 0 + for _, resource := range resources { + raw := compactJSON(resource.Raw) + costPrices, _ := json.Marshal(resource.CostPrices) + if len(costPrices) == 0 { + costPrices = []byte(`{}`) + } + if _, err := s.pool.Exec(ctx, ` + INSERT INTO supplier_media_resources ( + supplier, supplier_resource_id, model_id, name, status, cost_price_cents, + cost_prices_json, sale_price_label, resource_url, baidu_weight, resource_remark, + 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) + ON CONFLICT (supplier, supplier_resource_id) DO UPDATE SET + model_id = EXCLUDED.model_id, + name = EXCLUDED.name, + status = EXCLUDED.status, + cost_price_cents = EXCLUDED.cost_price_cents, + cost_prices_json = EXCLUDED.cost_prices_json, + sale_price_label = EXCLUDED.sale_price_label, + resource_url = EXCLUDED.resource_url, + baidu_weight = EXCLUDED.baidu_weight, + resource_remark = EXCLUDED.resource_remark, + channel_type = EXCLUDED.channel_type, + region = EXCLUDED.region, + inclusion_effect = EXCLUDED.inclusion_effect, + link_type = EXCLUDED.link_type, + publish_rate = EXCLUDED.publish_rate, + delivery_speed = EXCLUDED.delivery_speed, + supplier_updated_at = EXCLUDED.supplier_updated_at, + last_synced_at = NOW(), + raw_json = EXCLUDED.raw_json, + updated_at = NOW(), + deleted_at = NULL + `, resource.Supplier, resource.SupplierResourceID, resource.ModelID, resource.Name, resource.Status, + resource.CostPriceCents, costPrices, nullableTrimmedString(resource.SalePriceLabel), + nullableTrimmedString(resource.ResourceURL), resource.BaiduWeight, nullableTrimmedString(resource.ResourceRemark), + nullableTrimmedString(resource.ChannelType), nullableTrimmedString(resource.Region), + nullableTrimmedString(resource.InclusionEffect), nullableTrimmedString(resource.LinkType), + nullableTrimmedString(resource.PublishRate), nullableTrimmedString(resource.DeliverySpeed), + resource.SupplierUpdatedAt, raw); err != nil { + return count, err + } + count++ + } + return count, nil +} + +func (s *MediaSupplyService) resourceCostForPriceType(ctx context.Context, resourceID int64, priceType string) (int64, error) { + var costPrice int64 + var costPricesRaw []byte + if err := s.pool.QueryRow(ctx, ` + SELECT cost_price_cents, cost_prices_json + FROM supplier_media_resources + WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL + `, resourceID, mediaSupplySupplierMeijiequan).Scan(&costPrice, &costPricesRaw); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架") + } + return 0, response.ErrInternal(50071, "media_supply_resource_lookup_failed", "媒体资源读取失败") + } + var costPrices map[string]int64 + _ = json.Unmarshal(costPricesRaw, &costPrices) + if priceTypeCost := costPrices[priceType]; priceTypeCost > 0 { + return priceTypeCost, nil + } + return costPrice, nil +} + +func (s *MediaSupplyService) loadArticleContent(ctx context.Context, tenantID, brandID, articleID int64) (string, string, error) { + var title sql.NullString + var html sql.NullString + var markdown sql.NullString + if err := s.pool.QueryRow(ctx, ` + SELECT av.title, av.html_content, av.markdown_content + FROM articles a + JOIN article_versions av ON av.id = a.current_version_id + WHERE a.id = $1 AND a.tenant_id = $2 AND a.brand_id = $3 AND a.deleted_at IS NULL + `, articleID, tenantID, brandID).Scan(&title, &html, &markdown); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", "", response.ErrNotFound(40461, "article_not_found", "文章不存在或已删除") + } + return "", "", response.ErrInternal(50072, "article_lookup_failed", "文章读取失败") + } + content := strings.TrimSpace(markdown.String) + if content == "" { + content = strings.TrimSpace(html.String) + } + return strings.TrimSpace(title.String), content, nil +} + +func (s *MediaSupplyService) loadResourceForOrder(ctx context.Context, q interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, resourceID int64, modelID int, priceType string) (SupplierMediaResource, int64, bool, error) { + var item SupplierMediaResource + var costPricesRaw []byte + var overrideCents int64 + var overrideEnabled bool + if err := q.QueryRow(ctx, ` + SELECT r.id, r.supplier, r.supplier_resource_id, r.model_id, r.name, r.status, + r.cost_price_cents, r.cost_prices_json, COALESCE(o.sell_price_cents, 0), COALESCE(o.enabled, false) + FROM supplier_media_resources r + LEFT JOIN supplier_media_price_overrides o ON o.resource_id = r.id AND o.price_type = $4 + WHERE r.id = $1 AND r.supplier = $2 AND r.model_id = $3 AND r.deleted_at IS NULL AND r.customer_visible = TRUE + FOR UPDATE OF r + `, resourceID, mediaSupplySupplierMeijiequan, modelID, priceType).Scan( + &item.ID, &item.Supplier, &item.SupplierResourceID, &item.ModelID, &item.Name, &item.Status, + &item.CostPriceCents, &costPricesRaw, &overrideCents, &overrideEnabled, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return item, 0, false, response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架") + } + return item, 0, false, response.ErrInternal(50071, "media_supply_resource_lookup_failed", "媒体资源读取失败") + } + _ = json.Unmarshal(costPricesRaw, &item.CostPrices) + if item.CostPrices == nil { + item.CostPrices = map[string]int64{} + } + return item, overrideCents, overrideEnabled, nil +} + +func (s *MediaSupplyService) listOrderItems(ctx context.Context, orderID int64) ([]MediaSupplyOrderItem, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, order_id, resource_id, supplier_resource_id, price_type, resource_name_snapshot, + locked_cost_price_cents, locked_sell_price_cents, status, external_article_url + FROM media_supply_order_items + WHERE order_id = $1 + ORDER BY id ASC + `, orderID) + if err != nil { + return nil, response.ErrInternal(50073, "media_supply_order_item_query_failed", "媒体投稿明细读取失败") + } + defer rows.Close() + items := make([]MediaSupplyOrderItem, 0) + for rows.Next() { + var item MediaSupplyOrderItem + if err := rows.Scan( + &item.ID, + &item.OrderID, + &item.ResourceID, + &item.SupplierResourceID, + &item.PriceType, + &item.ResourceNameSnapshot, + &item.LockedCostPriceCents, + &item.LockedSellPriceCents, + &item.Status, + &item.ExternalArticleURL, + ); err != nil { + return nil, response.ErrInternal(50074, "media_supply_order_item_scan_failed", "媒体投稿明细解析失败") + } + if item.ExternalArticleURL != nil { + if cleaned := publicMediaSupplyArticleURL(*item.ExternalArticleURL); cleaned != "" { + item.ExternalArticleURL = &cleaned + } else { + item.ExternalArticleURL = nil + } + } + items = append(items, item) + } + return items, nil +} + +func (s *MediaSupplyService) attachOrderItems(ctx context.Context, orders []MediaSupplyOrderDetail) error { + if len(orders) == 0 { + return nil + } + orderIDs := make([]int64, 0, len(orders)) + index := make(map[int64]int, len(orders)) + for idx := range orders { + orderIDs = append(orderIDs, orders[idx].ID) + index[orders[idx].ID] = idx + } + rows, err := s.pool.Query(ctx, ` + SELECT id, order_id, resource_id, supplier_resource_id, price_type, resource_name_snapshot, + locked_cost_price_cents, locked_sell_price_cents, status, external_article_url + FROM media_supply_order_items + WHERE order_id = ANY($1::bigint[]) + ORDER BY order_id ASC, id ASC + `, orderIDs) + if err != nil { + return response.ErrInternal(50073, "media_supply_order_item_query_failed", "媒体投稿明细读取失败") + } + defer rows.Close() + for rows.Next() { + var item MediaSupplyOrderItem + if err := rows.Scan( + &item.ID, + &item.OrderID, + &item.ResourceID, + &item.SupplierResourceID, + &item.PriceType, + &item.ResourceNameSnapshot, + &item.LockedCostPriceCents, + &item.LockedSellPriceCents, + &item.Status, + &item.ExternalArticleURL, + ); err != nil { + return response.ErrInternal(50074, "media_supply_order_item_scan_failed", "媒体投稿明细解析失败") + } + if item.ExternalArticleURL != nil { + if cleaned := publicMediaSupplyArticleURL(*item.ExternalArticleURL); cleaned != "" { + item.ExternalArticleURL = &cleaned + } else { + item.ExternalArticleURL = nil + } + } + if idx, ok := index[item.OrderID]; ok { + orders[idx].Items = append(orders[idx].Items, item) + } + } + if err := rows.Err(); err != nil { + return response.ErrInternal(50074, "media_supply_order_item_scan_failed", "媒体投稿明细解析失败") + } + return nil +} + +func isKnownMeijiequanModel(modelID int) bool { + for _, known := range meijiequanModelIDs { + if modelID == known { + return true + } + } + return false +} + +func isTenantAdmin(actor auth.Actor) bool { + return strings.EqualFold(strings.TrimSpace(actor.Role), "tenant_admin") +} + +func nullableInt(value int) any { + if value <= 0 { + return nil + } + return value +} + +func nullableInt64Ptr(value *int64) any { + if value == nil || *value <= 0 { + return nil + } + return *value +} + +func nullableTrimmedString(value string) any { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return value +} + +func costForResourcePriceType(resource SupplierMediaResource, priceType string) int64 { + if resource.CostPrices != nil { + if value := resource.CostPrices[priceType]; value > 0 { + return value + } + if priceType == "price" { + if value := resource.CostPrices["sale_price"]; value > 0 { + return value + } + } + } + return resource.CostPriceCents +} + +func mediaSupplyClientError(err error) error { + switch { + case errors.Is(err, errMeijiequanCredentialsMissing): + return response.ErrConflict(40964, "media_supply_credentials_required", "媒体供应商账号未配置,请联系管理员处理") + case errors.Is(err, errMeijiequanSessionMissing), errors.Is(err, errMeijiequanSessionExpired), errors.Is(err, errMeijiequanAuthRequired): + return response.ErrConflict(40962, "media_supply_session_required", "媒体供应商登录状态已失效,请联系管理员处理") + case errors.Is(err, errMeijiequanChallengeRequired): + return response.ErrConflict(40963, "media_supply_challenge_required", "媒体供应商登录验证失败,请联系管理员处理") + default: + return response.ErrInternal(50075, "media_supply_request_failed", mediaSupplyPublicErrorMessage(err)) + } +} + +func mediaSupplySellPriceSQL(cfg config.MediaSupplyConfig) string { + floorSQL := "r.cost_price_cents" + if cfg.DefaultMarkupPercent > 0 { + multiplier := 1 + cfg.DefaultMarkupPercent/100 + floorSQL = fmt.Sprintf("CEIL(r.cost_price_cents * %.8f)::BIGINT", multiplier) + } + if cfg.MinimumMarkupCents > 0 { + floorSQL = fmt.Sprintf("GREATEST(%s, r.cost_price_cents + %d)", floorSQL, cfg.MinimumMarkupCents) + } + rawPriceSQL := fmt.Sprintf("CASE WHEN COALESCE(o.enabled, false) AND COALESCE(o.sell_price_cents, 0) >= r.cost_price_cents THEN o.sell_price_cents ELSE (%s) END", floorSQL) + return fmt.Sprintf("CEIL((%s) / 100.0)::BIGINT * 100", rawPriceSQL) +} + +func mediaSupplyResourceOrderSQL(sort string, sellPriceSQL string) string { + switch strings.ToLower(strings.TrimSpace(sort)) { + case "price": + return fmt.Sprintf("(%s) ASC, r.id DESC", sellPriceSQL) + case "weight": + return "COALESCE(r.baidu_weight, 0) DESC, r.id DESC" + default: + return "r.updated_at DESC, r.id DESC" + } +} + +type mediaSupplyOrderScanner interface { + Scan(...any) error +} + +func scanMediaSupplyOrder(row mediaSupplyOrderScanner) (MediaSupplyOrderDetail, error) { + var item MediaSupplyOrderDetail + if err := row.Scan( + &item.ID, + &item.TenantID, + &item.WorkspaceID, + &item.BrandID, + &item.UserID, + &item.ArticleID, + &item.Supplier, + &item.ModelID, + &item.Status, + &item.Title, + &item.Remark, + &item.OrderBrand, + &item.ExternalOrderID, + &item.ExternalOrderCode, + &item.SupplierStatus, + &item.CostTotalCents, + &item.SellTotalCents, + &item.WalletDebitCents, + &item.WalletDebitedAt, + &item.WalletRefundedAt, + &item.ErrorMessage, + &item.AttemptCount, + &item.QueuedAt, + &item.SubmittedAt, + &item.CompletedAt, + &item.CreatedAt, + &item.UpdatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return item, response.ErrNotFound(40462, "media_supply_order_not_found", "媒体投稿订单不存在") + } + return item, response.ErrInternal(50076, "media_supply_order_scan_failed", "媒体投稿订单解析失败") + } + item.Supplier = publicMediaSupplySupplierCode(item.Supplier) + if item.ErrorMessage != nil { + message := mediaSupplyPublicGenericErrorMessage(*item.ErrorMessage) + item.ErrorMessage = &message + } + return item, nil +} diff --git a/server/internal/tenant/app/media_supply_test.go b/server/internal/tenant/app/media_supply_test.go new file mode 100644 index 0000000..b163231 --- /dev/null +++ b/server/internal/tenant/app/media_supply_test.go @@ -0,0 +1,574 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + goredis "github.com/redis/go-redis/v9" + + "github.com/geo-platform/tenant-api/internal/shared/config" +) + +type mediaSupplyOrderScanStub struct { + values []any +} + +func (s mediaSupplyOrderScanStub) Scan(dest ...any) error { + for i, value := range s.values { + switch target := dest[i].(type) { + case *int: + *target = value.(int) + case *int64: + *target = value.(int64) + case *string: + *target = value.(string) + case **string: + *target = value.(*string) + case **int64: + *target = value.(*int64) + case *time.Time: + *target = value.(time.Time) + case **time.Time: + *target = value.(*time.Time) + default: + panic("unsupported scan target") + } + } + return nil +} + +func TestMediaSupplySellPriceNeverBelowCost(t *testing.T) { + got := mediaSupplySellPrice(1000, 900, true, 0, 0) + if got != 1000 { + t.Fatalf("expected cost floor 1000, got %d", got) + } + + got = mediaSupplySellPrice(1000, 1100, true, 20, 0) + if got != 1100 { + t.Fatalf("expected explicit override above cost 1100, got %d", got) + } + + got = mediaSupplySellPrice(1000, 1400, true, 20, 0) + if got != 1400 { + t.Fatalf("expected override above floor 1400, got %d", got) + } +} + +func TestMediaSupplySellPricePrefersManualOverride(t *testing.T) { + got := mediaSupplySellPrice(1000, 1100, true, 50, 0) + if got != 1100 { + t.Fatalf("expected manual override 1100 to win over 50%% markup floor, got %d", got) + } + + got = mediaSupplySellPrice(1000, 1101, true, 50, 0) + if got != 1200 { + t.Fatalf("expected manual override 1101 to round up to 1200, got %d", got) + } +} + +func TestMediaSupplySellPriceRoundsUpToWholeYuan(t *testing.T) { + got := mediaSupplySellPrice(8040, 0, false, 0, 0) + if got != 8100 { + t.Fatalf("expected 8040 cents to round up to 8100, got %d", got) + } + + got = mediaSupplySellPrice(7001, 0, false, 0, 0) + if got != 7100 { + t.Fatalf("expected 7001 cents to round up to 7100, got %d", got) + } + + got = mediaSupplySellPrice(7000, 0, false, 0, 0) + if got != 7000 { + t.Fatalf("expected whole yuan price to stay 7000, got %d", got) + } +} + +func TestMediaSupplySellPriceAppliesMarkupBeforeRoundUp(t *testing.T) { + got := mediaSupplySellPrice(300, 0, false, 50, 0) + if got != 500 { + t.Fatalf("expected 300 cents with 50%% markup to round up to 500, got %d", got) + } + + got = mediaSupplySellPrice(800, 0, false, 50, 0) + if got != 1200 { + t.Fatalf("expected 800 cents with 50%% markup to be 1200, got %d", got) + } + + got = mediaSupplySellPrice(8040, 0, false, 50, 0) + if got != 12100 { + t.Fatalf("expected 8040 cents with 50%% markup to round up to 12100, got %d", got) + } +} + +func TestMediaSupplyResourceOrderSQL(t *testing.T) { + priceSQL := "sell_price_expr" + if got := mediaSupplyResourceOrderSQL("price", priceSQL); got != "(sell_price_expr) ASC, r.id DESC" { + t.Fatalf("unexpected price order SQL: %s", got) + } + if got := mediaSupplyResourceOrderSQL("weight", priceSQL); got != "COALESCE(r.baidu_weight, 0) DESC, r.id DESC" { + t.Fatalf("unexpected weight order SQL: %s", got) + } + if got := mediaSupplyResourceOrderSQL("unknown", priceSQL); got != "r.updated_at DESC, r.id DESC" { + t.Fatalf("unexpected default order SQL: %s", got) + } +} + +func TestFormatMeijiequanResourceID(t *testing.T) { + item := MediaSupplyOrderItem{SupplierResourceID: "36112", PriceType: "price"} + if got := formatMeijiequanResourceID(item); got != "36112_price_0" { + t.Fatalf("unexpected default price resource id: %s", got) + } + + item.PriceType = "priceyc" + if got := formatMeijiequanResourceID(item); got != "36112_priceyc_0" { + t.Fatalf("unexpected variant resource id: %s", got) + } +} + +func TestRetryableMediaSupplyErrorTreatsBusinessFailuresAsTerminal(t *testing.T) { + terminalErrors := []error{ + errMeijiequanSessionMissing, + errMeijiequanSessionExpired, + errMeijiequanAuthRequired, + errMeijiequanOrderRejected, + errMediaSupplySelectedResourcesMissing, + errMediaSupplyLockedSellBelowCost, + } + for _, err := range terminalErrors { + if retryableMediaSupplyError(err) { + t.Fatalf("expected terminal media supply error to be non-retryable: %v", err) + } + } + + if !retryableMediaSupplyError(errors.New("temporary network failure")) { + t.Fatal("expected generic temporary error to remain retryable") + } +} + +func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) { + now := time.Now().UTC() + errorMessage := "failed to refresh supplier price for 1 selected resources" + item, err := scanMediaSupplyOrder(mediaSupplyOrderScanStub{values: []any{ + int64(1), + int64(2), + int64(3), + int64(4), + int64(5), + (*int64)(nil), + mediaSupplySupplierMeijiequan, + 1, + mediaSupplyOrderStatusFailed, + "测试标题", + (*string)(nil), + (*string)(nil), + (*string)(nil), + (*string)(nil), + (*string)(nil), + int64(100), + int64(120), + int64(120), + (*time.Time)(nil), + (*time.Time)(nil), + &errorMessage, + 1, + now, + (*time.Time)(nil), + (*time.Time)(nil), + now, + now, + }}) + if err != nil { + t.Fatalf("scan failed: %v", err) + } + if strings.Contains(strings.ToLower(item.Supplier), "meijiequan") { + t.Fatalf("supplier was not redacted: %s", item.Supplier) + } + if item.ErrorMessage == nil || *item.ErrorMessage != "媒体资源价格刷新失败,请稍后重试或重新选择媒体" { + t.Fatalf("unexpected public error message: %#v", item.ErrorMessage) + } +} + +func TestMeijiequanCreateOrderFormMatchesUpstreamShape(t *testing.T) { + form := meijiequanCreateOrderForm(MeijiequanCreateOrderRequest{ + UID: "962", + ModelID: 1, + Title: "2026合肥全屋定制推荐 6家选型参考", + Content: "# 如何选择\n\n![封面](http://example.test/a.png \"178.png\")\n\n- 正文内容\n- **重点**", + ResourceIDArr: []string{"36122_price_0"}, + PointTime: "2026-06-06 11:00:00", + PointPriceRange: "1", + Extra: map[string]any{ + "source": "平台稿件", + "description": "稿件描述", + }, + }) + + if got := form.Get("uid"); got != "962" { + t.Fatalf("unexpected uid: %s", got) + } + if got := form.Get("prop[biaoti]"); got != "2026合肥全屋定制推荐 6家选型参考" { + t.Fatalf("unexpected title: %s", got) + } + if got := form.Get("prop[laiyuan]"); got != "平台稿件" { + t.Fatalf("unexpected source: %s", got) + } + if got := form.Get("prop[miaoshu]"); got != "稿件描述" { + t.Fatalf("unexpected description: %s", got) + } + content := form.Get("prop[neirong]") + if !strings.Contains(content, "

如何选择

") || + !strings.Contains(content, `src="http://example.test/a.png"`) || + !strings.Contains(content, `title="178.png"`) || + !strings.Contains(content, `_src="http://example.test/a.png"`) || + !strings.Contains(content, "
  • 正文内容
  • ") || + !strings.Contains(content, "重点") { + t.Fatalf("unexpected html content: %s", content) + } + if got := form.Get("model_id"); got != "1" { + t.Fatalf("unexpected model_id: %s", got) + } + if got := form.Get("voucher_id"); got != "0" { + t.Fatalf("unexpected voucher_id: %s", got) + } + if got := form.Get("resource_id_arr"); got != `["36122_price_0"]` { + t.Fatalf("unexpected resource_id_arr: %s", got) + } + if got := form.Get("resource_spare_arr"); got != `[]` { + t.Fatalf("unexpected resource_spare_arr: %s", got) + } + if got := form.Get("point_time"); got != "2026-06-06 11:00:00" { + t.Fatalf("unexpected point_time: %s", got) + } + if got := form.Get("point_price_range"); got != "1" { + t.Fatalf("unexpected point_price_range: %s", got) + } +} + +func TestMeijiequanArticleHTMLKeepsExistingHTML(t *testing.T) { + input := `

    a

    正文

    ` + if got := meijiequanArticleHTML(input); got != input { + t.Fatalf("expected html passthrough, got %s", got) + } +} + +func TestMediaSupplySubmitOrderExtraExtractsNestedPayload(t *testing.T) { + extra := mediaSupplySubmitOrderExtra([]byte(`{ + "title": "ignored", + "extra": { + "source": "来源", + "point_time": "2026-06-06 11:00:00" + }, + "point_price_range": "1" + }`)) + if got := meijiequanAnyString(extra["source"]); got != "来源" { + t.Fatalf("unexpected source: %s", got) + } + if got := meijiequanAnyString(extra["point_time"]); got != "2026-06-06 11:00:00" { + t.Fatalf("unexpected point_time: %s", got) + } + if got := meijiequanAnyString(extra["point_price_range"]); got != "1" { + t.Fatalf("unexpected point_price_range: %s", got) + } +} + +func TestManualMeijiequanChallengeResolverRequiresManualHandling(t *testing.T) { + _, err := ManualMeijiequanChallengeCodeResolver{}.ResolveCode(context.Background(), MeijiequanChallenge{}) + if !errors.Is(err, errMeijiequanChallengeRequired) { + t.Fatalf("expected challenge required error, got %v", err) + } +} + +func TestMeijiequanHiddenInputs(t *testing.T) { + fields := parseMeijiequanHiddenInputs(strings.NewReader(` +
    + + + +
    `)) + if fields["_token"] != "token-value" || fields["sk"] != "sk-value" { + t.Fatalf("unexpected hidden fields: %#v", fields) + } +} + +func TestDecodeMeijiequanMediaListAcceptsStringNumbers(t *testing.T) { + parsed, err := decodeMeijiequanMediaList([]byte(`{ + "status": "1", + "info": "ok", + "page": "2", + "all_page": "7", + "all_num": "121", + "user_id": "8899", + "data": [{"id":"1","name":"测试媒体","sale_price":"12.5"}] + }`)) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if parsed.Status != 1 || parsed.Page != 2 || parsed.AllPage != 7 || parsed.AllNum != 121 || parsed.UserID != 8899 { + t.Fatalf("unexpected parsed page metadata: %#v", parsed) + } + if len(parsed.Data) != 1 { + t.Fatalf("unexpected item count: %d", len(parsed.Data)) + } +} + +func TestDecodeMeijiequanSearchOptions(t *testing.T) { + groups, err := decodeMeijiequanSearchOptions([]byte(`{ + "pindaoleixing": {"name": "频道类型", "list": ["IT科技", "财经金融"]}, + "quyu": {"name": "所在地区", "list": ["全国", "北京"]} + }`)) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if len(groups) != 2 { + t.Fatalf("unexpected group count: %d", len(groups)) + } + if groups[0].Key != "pindaoleixing" || groups[0].Name != "频道类型" || groups[0].List[1] != "财经金融" { + t.Fatalf("unexpected first group: %#v", groups[0]) + } +} + +func TestDecodeMeijiequanPublishedArticleJSON(t *testing.T) { + page, err := decodeMeijiequanPublishedArticlePage([]byte(`{ + "status": "1", + "page": "1", + "all_page": "2", + "all_num": "21", + "data": [{ + "id": "8891", + "order_code": "A20260529001", + "biaoti": "2026合肥品牌推荐", + "resource_id": "36122_price_0", + "media_name": "测试新闻网", + "article_url": "https://news.example.test/a.html", + "status_text": "已发表", + "publish_time": "2026-05-29 16:00:00" + }] + }`)) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if page.Page != 1 || page.AllPage != 2 || page.AllNum != 21 || len(page.Items) != 1 { + t.Fatalf("unexpected page: %#v", page) + } + item := page.Items[0] + if item.ResourceID != "36122" || + item.ExternalArticleURL != "https://news.example.test/a.html" || + item.Title != "2026合肥品牌推荐" || + item.Status != "已发表" || + item.PublishedAt == nil { + t.Fatalf("unexpected article item: %#v", item) + } +} + +func TestDecodeMeijiequanPublishedArticleHTML(t *testing.T) { + page, err := decodeMeijiequanPublishedArticlePage([]byte(` + + + + + + + + + + +
    2026合肥品牌推荐测试新闻网WM118996查看链接已发表
    `)) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if len(page.Items) != 1 { + t.Fatalf("unexpected article count: %d", len(page.Items)) + } + item := page.Items[0] + if item.OrderID != "8891" || + item.OrderCode != "WM118996" || + item.ResourceID != "36122" || + item.ExternalArticleURL != "https://news.example.test/a.html" || + item.Title != "2026合肥品牌推荐" { + t.Fatalf("unexpected html article item: %#v", item) + } +} + +func TestDecodeMeijiequanPublishedArticleHTMLUsesCopiedBacklinkNotShowURL(t *testing.T) { + page, err := decodeMeijiequanPublishedArticlePage([]byte(` + + + + + + + + + + +
    WM1189962026合肥品牌推荐金融树洞网 + 查看回链 + + 已发布
    `)) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if len(page.Items) != 1 { + t.Fatalf("unexpected article count: %d", len(page.Items)) + } + item := page.Items[0] + if item.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" { + t.Fatalf("expected copied real backlink, got %#v", item) + } +} + +func TestDecodeMeijiequanAllArticleHTMLKeepsStatusAndRejectReasonColumns(t *testing.T) { + page, err := decodeMeijiequanPublishedArticlePage([]byte(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    订单号标题资源名称价格状态回链标签创建时间发稿时间退稿原因退稿时间操作
    WM119065合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议博客园(GEO排名可发)3.00退稿申请中05-29 20:45不想发布了05-29 20:54标签 | 再次发稿
    WM118996全屋定制品牌哪家强?2026 年合肥品牌推荐与排名金融树洞网3.00已发布05-29 17:1605-29 17:20标签 | 再次发稿 | 申请退稿
    `)) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if len(page.Items) != 2 { + t.Fatalf("unexpected article count: %d", len(page.Items)) + } + problem := page.Items[0] + if problem.OrderCode != "WM119065" || + problem.Title != "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议" || + problem.ResourceName != "博客园(GEO排名可发)" || + problem.Status != "退稿申请中" || + problem.RejectReason != "不想发布了" { + t.Fatalf("unexpected problem article: %#v", problem) + } + published := page.Items[1] + if published.OrderCode != "WM118996" || + published.ResourceName != "金融树洞网" || + published.Status != "已发布" || + published.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" { + t.Fatalf("unexpected published article: %#v", published) + } +} + +func TestDecodeMeijiequanPublishedArticleJSONRejectsShowURL(t *testing.T) { + page, err := decodeMeijiequanPublishedArticlePage([]byte(`{ + "status": "1", + "data": [{ + "order_code": "WM118996", + "biaoti": "2026合肥品牌推荐", + "media_name": "金融树洞网", + "article_url": "http://show.meitidaren.top/review/abc" + }] + }`)) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if len(page.Items) != 1 { + t.Fatalf("unexpected article count: %d", len(page.Items)) + } + if page.Items[0].ExternalArticleURL != "" { + t.Fatalf("show url should not be treated as backlink: %#v", page.Items[0]) + } +} + +func TestMatchMeijiequanPublishedArticlesPrefersResourceID(t *testing.T) { + items := []MediaSupplyOrderItem{ + {ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "测试新闻网"}, + {ID: 12, SupplierResourceID: "36123", ResourceNameSnapshot: "另一个媒体"}, + } + matches := matchMeijiequanPublishedArticles(mediaSupplySubmitOrder{Title: "2026合肥品牌推荐"}, items, []MeijiequanPublishedArticle{ + { + Title: "2026合肥品牌推荐", + ResourceID: "36123_price_0", + ResourceName: "另一个媒体", + ExternalArticleURL: "https://news.example.test/b.html", + }, + }) + if len(matches) != 1 { + t.Fatalf("unexpected matches: %#v", matches) + } + if matches[12].ExternalArticleURL != "https://news.example.test/b.html" { + t.Fatalf("unexpected matched item: %#v", matches) + } +} + +func TestMatchMeijiequanProblemArticleReturnsRejectReasonForCustomer(t *testing.T) { + orderCode := "WM119065" + article := matchMeijiequanProblemArticle( + mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议", ExternalOrderCode: &orderCode}, + []MediaSupplyOrderItem{{ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "博客园(GEO排名可发)"}}, + []MeijiequanPublishedArticle{{ + OrderCode: "WM119065", + Title: "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议", + ResourceName: "博客园(GEO排名可发)", + Status: "退稿申请中", + RejectReason: "不想发布了", + }}, + ) + if article == nil { + t.Fatal("expected problem article match") + } + if got := mediaSupplyProblemArticleReason(*article); got != "退稿原因:不想发布了" { + t.Fatalf("unexpected customer reject reason: %s", got) + } +} + +func TestMaskMeijiequanUsername(t *testing.T) { + if got := maskMeijiequanUsername("17788409108"); got != "177******08" { + t.Fatalf("unexpected masked username: %s", got) + } +} + +func TestMeijiequanUpstreamThrottleSerializesRequests(t *testing.T) { + server := miniredis.RunT(t) + redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()}) + defer redis.Close() + + client := NewMeijiequanClient(redis, config.MeijiequanConfig{UpstreamMinInterval: 25 * time.Millisecond}) + ctx := context.Background() + if err := client.waitForUpstreamTurn(ctx, 25*time.Millisecond); err != nil { + t.Fatalf("first wait failed: %v", err) + } + + start := time.Now() + if err := client.waitForUpstreamTurn(ctx, 25*time.Millisecond); err != nil { + t.Fatalf("second wait failed: %v", err) + } + if elapsed := time.Since(start); elapsed < 20*time.Millisecond { + t.Fatalf("expected serialized upstream wait, elapsed %s", elapsed) + } +} diff --git a/server/internal/tenant/app/media_supply_types.go b/server/internal/tenant/app/media_supply_types.go new file mode 100644 index 0000000..4000abf --- /dev/null +++ b/server/internal/tenant/app/media_supply_types.go @@ -0,0 +1,371 @@ +package app + +import ( + "encoding/json" + "math" + "strings" + "time" +) + +const ( + mediaSupplySupplierMeijiequan = "meijiequan" + + mediaSupplyOrderStatusQueued = "queued" + mediaSupplyOrderStatusSubmitting = "submitting" + mediaSupplyOrderStatusSubmitted = "submitted" + mediaSupplyOrderStatusFailed = "failed" + + mediaSupplySyncStatusQueued = "queued" + mediaSupplySyncStatusRunning = "running" + mediaSupplySyncStatusSuccess = "success" + mediaSupplySyncStatusFailed = "failed" +) + +var meijiequanModelIDs = []int{1, 2, 10, 3, 4, 7, 5, 6, 9, 11, 13} + +type SupplierMediaResource struct { + ID int64 `json:"id"` + Supplier string `json:"supplier"` + SupplierResourceID string `json:"supplier_resource_id"` + ModelID int `json:"model_id"` + Name string `json:"name"` + Status string `json:"status"` + CostPriceCents int64 `json:"cost_price_cents"` + CostPrices map[string]int64 `json:"cost_prices"` + SellPriceCents int64 `json:"sell_price_cents"` + SalePriceLabel *string `json:"sale_price_label,omitempty"` + ResourceURL *string `json:"resource_url,omitempty"` + BaiduWeight *int `json:"baidu_weight,omitempty"` + ResourceRemark *string `json:"resource_remark,omitempty"` + CustomerVisible bool `json:"customer_visible"` + ChannelType *string `json:"channel_type,omitempty"` + Region *string `json:"region,omitempty"` + InclusionEffect *string `json:"inclusion_effect,omitempty"` + LinkType *string `json:"link_type,omitempty"` + PublishRate *string `json:"publish_rate,omitempty"` + DeliverySpeed *string `json:"delivery_speed,omitempty"` + SupplierUpdatedAt *time.Time `json:"supplier_updated_at,omitempty"` + LastSyncedAt time.Time `json:"last_synced_at"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +type ListSupplierMediaResourcesRequest struct { + ModelID int + Keyword string + RemarkKeyword string + ChannelType string + Portal string + Region string + InclusionEffect string + SpecialIndustry string + LinkType string + DeliverySpeed string + AIInclude string + OtherOption string + MinPriceCents int64 + MaxPriceCents int64 + Sort string + IncludeHidden bool + CustomerView bool + Page int + PageSize int +} + +type ListSupplierMediaResourcesResponse struct { + Items []SupplierMediaResource `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type MediaSupplySearchOptionGroup struct { + Key string `json:"key"` + Name string `json:"name"` + List []string `json:"list"` +} + +type MediaSupplySearchOptionsResponse struct { + ModelID int `json:"model_id"` + Groups []MediaSupplySearchOptionGroup `json:"groups"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UpsertSupplierMediaResourceInput struct { + Supplier string + SupplierResourceID string + ModelID int + Name string + Status string + CostPriceCents int64 + CostPrices map[string]int64 + SalePriceLabel string + ResourceURL string + BaiduWeight *int + ResourceRemark string + ChannelType string + Region string + InclusionEffect string + LinkType string + PublishRate string + DeliverySpeed string + SupplierUpdatedAt *time.Time + Raw json.RawMessage +} + +type SetSupplierMediaPriceRequest struct { + PriceType string `json:"price_type"` + SellPriceCents int64 `json:"sell_price_cents"` + Enabled *bool `json:"enabled,omitempty"` +} + +type SetSupplierMediaVisibilityRequest struct { + CustomerVisible bool `json:"customer_visible"` +} + +type CustomerSupplierMediaResource struct { + ID int64 `json:"id"` + SupplierResourceID string `json:"supplier_resource_id"` + ModelID int `json:"model_id"` + Name string `json:"name"` + Status string `json:"status"` + SellPriceCents int64 `json:"sell_price_cents"` + ResourceURL *string `json:"resource_url,omitempty"` + BaiduWeight *int `json:"baidu_weight,omitempty"` + ResourceRemark *string `json:"resource_remark,omitempty"` + ChannelType *string `json:"channel_type,omitempty"` + Region *string `json:"region,omitempty"` + InclusionEffect *string `json:"inclusion_effect,omitempty"` + LinkType *string `json:"link_type,omitempty"` + PublishRate *string `json:"publish_rate,omitempty"` + DeliverySpeed *string `json:"delivery_speed,omitempty"` + LastSyncedAt time.Time `json:"last_synced_at"` +} + +type ListCustomerSupplierMediaResourcesResponse struct { + Items []CustomerSupplierMediaResource `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type CreateMediaSupplyOrderRequest struct { + ArticleID *int64 `json:"article_id,omitempty"` + ModelID int `json:"model_id" binding:"required"` + Title string `json:"title"` + Content string `json:"content"` + Remark string `json:"remark"` + OrderBrand string `json:"order_brand"` + Items []CreateMediaSupplyOrderItem `json:"items" binding:"required,min=1"` + Extra map[string]any `json:"extra,omitempty"` +} + +type CreateMediaSupplyOrderItem struct { + ResourceID int64 `json:"resource_id" binding:"required"` + PriceType string `json:"price_type"` +} + +type CreateMediaSupplyOrderResponse struct { + OrderID int64 `json:"order_id"` + Status string `json:"status"` + CostTotalCents int64 `json:"cost_total_cents"` + SellTotalCents int64 `json:"sell_total_cents"` + BalanceAfterCents int64 `json:"balance_after_cents"` +} + +type MediaSupplyOrderDetail struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + WorkspaceID int64 `json:"workspace_id"` + BrandID int64 `json:"brand_id"` + UserID int64 `json:"user_id"` + ArticleID *int64 `json:"article_id,omitempty"` + Supplier string `json:"supplier"` + ModelID int `json:"model_id"` + Status string `json:"status"` + Title string `json:"title"` + Remark *string `json:"remark,omitempty"` + OrderBrand *string `json:"order_brand,omitempty"` + ExternalOrderID *string `json:"external_order_id,omitempty"` + ExternalOrderCode *string `json:"external_order_code,omitempty"` + SupplierStatus *string `json:"supplier_status,omitempty"` + CostTotalCents int64 `json:"cost_total_cents"` + SellTotalCents int64 `json:"sell_total_cents"` + WalletDebitCents int64 `json:"wallet_debit_cents"` + WalletDebitedAt *time.Time `json:"wallet_debited_at,omitempty"` + WalletRefundedAt *time.Time `json:"wallet_refunded_at,omitempty"` + ErrorMessage *string `json:"error_message,omitempty"` + AttemptCount int `json:"attempt_count"` + QueuedAt time.Time `json:"queued_at"` + SubmittedAt *time.Time `json:"submitted_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Items []MediaSupplyOrderItem `json:"items,omitempty"` +} + +type MediaSupplyOrderItem struct { + ID int64 `json:"id"` + OrderID int64 `json:"order_id"` + ResourceID int64 `json:"resource_id"` + SupplierResourceID string `json:"supplier_resource_id"` + PriceType string `json:"price_type"` + ResourceNameSnapshot string `json:"resource_name_snapshot"` + LockedCostPriceCents int64 `json:"locked_cost_price_cents"` + LockedSellPriceCents int64 `json:"locked_sell_price_cents"` + Status string `json:"status"` + ExternalArticleURL *string `json:"external_article_url,omitempty"` +} + +type ListMediaSupplyOrdersRequest struct { + Status string + ArticleID *int64 + Page int + PageSize int +} + +type ListMediaSupplyOrdersResponse struct { + Items []MediaSupplyOrderDetail `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type SyncMediaSupplyBacklinksResponse struct { + PublishedFetched int `json:"published_fetched"` + PendingFetched int `json:"pending_fetched"` + ProblemFetched int `json:"problem_fetched"` + OrdersChecked int `json:"orders_checked"` + OrdersUpdated int `json:"orders_updated"` + OrderCodesUpdated int `json:"order_codes_updated"` + RefundedOrders int `json:"refunded_orders"` + LinksUpdated int `json:"links_updated"` +} + +type MediaSupplyWalletStatus struct { + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + BalanceCents int64 `json:"balance_cents"` + UpdatedAt time.Time `json:"updated_at"` + LedgerTotal *int64 `json:"ledger_total,omitempty"` + LastLedgerAt *time.Time `json:"last_ledger_at,omitempty"` +} + +type MediaSupplyWalletLedger struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + OrderID *int64 `json:"order_id,omitempty"` + OrderTitle *string `json:"order_title,omitempty"` + DeltaCents int64 `json:"delta_cents"` + BalanceAfterCents int64 `json:"balance_after_cents"` + Reason string `json:"reason"` + Note *string `json:"note,omitempty"` + CreatedBy *int64 `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ListMediaSupplyWalletLedgersRequest struct { + Page int + PageSize int + UserID int64 + CreatedFrom *time.Time + CreatedTo *time.Time +} + +type ListMediaSupplyWalletLedgersResponse struct { + Items []MediaSupplyWalletLedger `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type AdjustMediaSupplyWalletRequest struct { + UserID int64 `json:"user_id" binding:"required"` + DeltaCents int64 `json:"delta_cents" binding:"required"` + Note string `json:"note"` +} + +type ImportMeijiequanSessionRequest struct { + Cookies []MeijiequanCookieInput `json:"cookies" binding:"required,min=1"` + CSRFToken string `json:"csrf_token"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +type MeijiequanCookieInput struct { + Name string `json:"name" binding:"required"` + Value string `json:"value" binding:"required"` + Domain string `json:"domain,omitempty"` + Path string `json:"path,omitempty"` + Expires *time.Time `json:"expires,omitempty"` + HTTPOnly bool `json:"http_only,omitempty"` + Secure bool `json:"secure,omitempty"` +} + +type MeijiequanSessionStatus struct { + Available bool `json:"available"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + AccountConfigured bool `json:"account_configured"` + UserID string `json:"-"` +} + +func normalizeMediaSupplyPagination(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func normalizeMediaSupplyResourceIDs(ids []int64) []int64 { + seen := map[int64]struct{}{} + normalized := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + normalized = append(normalized, id) + if len(normalized) >= 100 { + break + } + } + return normalized +} + +func normalizeMediaSupplyPriceType(priceType string) string { + priceType = strings.ToLower(strings.TrimSpace(priceType)) + if priceType == "" { + return "price" + } + return priceType +} + +func mediaSupplySellPrice(costCents, overrideCents int64, overrideEnabled bool, markupPercent float64, minimumMarkupCents int64) int64 { + floor := costCents + if markupPercent > 0 { + floor = int64(math.Ceil(float64(costCents) * (1 + markupPercent/100))) + } + if minimumMarkupCents > 0 && costCents+minimumMarkupCents > floor { + floor = costCents + minimumMarkupCents + } + if overrideEnabled && overrideCents >= costCents { + return mediaSupplyRoundUpToYuan(overrideCents) + } + return mediaSupplyRoundUpToYuan(floor) +} + +func mediaSupplyRoundUpToYuan(cents int64) int64 { + if cents <= 0 { + return 0 + } + return ((cents + 99) / 100) * 100 +} diff --git a/server/internal/tenant/app/media_supply_worker.go b/server/internal/tenant/app/media_supply_worker.go new file mode 100644 index 0000000..789746c --- /dev/null +++ b/server/internal/tenant/app/media_supply_worker.go @@ -0,0 +1,1545 @@ +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" +) + +type MediaSupplyWorker struct { + service *MediaSupplyService + pool *pgxpool.Pool + logger *zap.Logger +} + +type mediaSupplySyncJob struct { + ID int64 + ModelID *int + AttemptCount int +} + +type mediaSupplySubmitOrder struct { + ID int64 + TenantID 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)\b[A-Z]{1,12}\d{3,}\b`) + +func NewMediaSupplyWorker(service *MediaSupplyService, pool *pgxpool.Pool, logger *zap.Logger) *MediaSupplyWorker { + return &MediaSupplyWorker{service: service, pool: pool, logger: logger} +} + +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 + } + 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 + if job.ModelID != nil && *job.ModelID > 0 { + modelID = *job.ModelID + } + totalSynced := 0 + 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 { + return w.failSyncJob(ctx, job, syncErr) + } + totalSynced += modelSynced + stats["models"].(map[int]int)[modelID] = modelSynced + stats["synced"] = totalSynced + 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) { + total := 0 + 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 := 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 { + 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) + } + req := MeijiequanCreateOrderRequest{ + UID: userID, + ModelID: order.ModelID, + Title: order.Title, + Content: order.ContentSnapshot, + 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 fmt.Errorf("%w for %d selected resources", errMediaSupplySelectedResourcesMissing, len(pending)) + } + return nil +} + +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 + ) + ) + AND ( + supplier_status IS DISTINCT FROM $3 + OR updated_at <= NOW() - $4::interval + ) + ) + `, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted, "published", pgInterval(cfg.Worker.BacklinkSyncInterval)).Scan(&due); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return err + } + if !due && cleared == 0 { + return nil + } + return w.syncPublishedBacklinks(ctx) +} + +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 { + _, err := w.syncPublishedBacklinksNow(ctx) + return err +} + +func (w *MediaSupplyWorker) syncPublishedBacklinksNow(ctx context.Context) (*SyncMediaSupplyBacklinksResponse, error) { + result := &SyncMediaSupplyBacklinksResponse{} + if _, err := w.clearInvalidBacklinks(ctx); err != nil { + return result, 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 { + 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 NULLIF(TRIM(COALESCE(external_order_code, '')), '') IS NULL + 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 && strings.TrimSpace(*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 NULLIF(TRIM(COALESCE(external_order_code, '')), '') IS NULL + `, 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 + } + return updatedLinks, tx.Commit(ctx) +} + +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, 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.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, 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] + } + } + } + orderTitle := normalizeMeijiequanMatchText(order.Title) + for idx := range articles { + article := &articles[idx] + if strings.TrimSpace(article.OrderCode) == "" { + continue + } + articleTitle := normalizeMeijiequanMatchText(article.Title) + if orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle)) { + if len(items) <= 1 || articleMatchesAnyOrderItem(*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 + } + } + } + orderTitle := normalizeMeijiequanMatchText(order.Title) + for idx := range articles { + article := &articles[idx] + if !isMeijiequanProblemArticle(*article) { + continue + } + articleTitle := normalizeMeijiequanMatchText(article.Title) + if orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle)) { + if len(items) <= 1 || articleMatchesAnyOrderItem(*article, items) { + return article + } + } + } + return nil +} + +func mediaSupplyOrderCodeValue(order mediaSupplySubmitOrder) string { + if order.ExternalOrderCode != nil { + if value := strings.TrimSpace(*order.ExternalOrderCode); 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 matchMeijiequanPublishedArticleItem(orderTitle string, items map[int64]MediaSupplyOrderItem, article MeijiequanPublishedArticle) int64 { + resourceID := normalizeMeijiequanPublishedResourceID(article.ResourceID) + if resourceID != "" { + for itemID, item := range items { + if strings.EqualFold(strings.TrimSpace(item.SupplierResourceID), resourceID) { + return itemID + } + } + } + articleTitle := normalizeMeijiequanMatchText(article.Title) + if orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle)) { + if len(items) == 1 { + for itemID := range items { + return itemID + } + } + resourceName := normalizeMeijiequanMatchText(article.ResourceName) + if resourceName != "" { + for itemID, item := range items { + itemName := normalizeMeijiequanMatchText(item.ResourceNameSnapshot) + if itemName != "" && (strings.Contains(itemName, resourceName) || strings.Contains(resourceName, itemName)) { + 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 + } + } + 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...) + } +} + diff --git a/server/internal/tenant/app/meijiequan_challenge_resolver.go b/server/internal/tenant/app/meijiequan_challenge_resolver.go new file mode 100644 index 0000000..ab5e4a1 --- /dev/null +++ b/server/internal/tenant/app/meijiequan_challenge_resolver.go @@ -0,0 +1,39 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + + // image/png is registered by the digitocr package; jpeg/gif are registered + // here so a future change of the captcha format still decodes. + _ "image/gif" + _ "image/jpeg" + + "github.com/geo-platform/tenant-api/internal/shared/digitocr" +) + +// meijiequanChallengeDigits is the fixed length of the meijiequan login captcha. +const meijiequanChallengeDigits = 4 + +var errMeijiequanChallengeEmpty = errors.New("meijiequan challenge image is empty") + +// DigitOCRMeijiequanChallengeCodeResolver decodes the meijiequan numeric login +// captcha locally via the digitocr template matcher, enabling unattended login +// without an external OCR service. +type DigitOCRMeijiequanChallengeCodeResolver struct{} + +// ResolveCode recognizes the digits in the captcha image. A decode failure is +// returned as a non-challenge error so the login flow retries with a fresh +// captcha rather than giving up immediately (as the manual resolver does). +func (DigitOCRMeijiequanChallengeCodeResolver) ResolveCode(_ context.Context, challenge MeijiequanChallenge) (string, error) { + if len(challenge.Image) == 0 { + return "", errMeijiequanChallengeEmpty + } + code, err := digitocr.RecognizeReader(bytes.NewReader(challenge.Image), digitocr.Options{Digits: meijiequanChallengeDigits}) + if err != nil { + return "", fmt.Errorf("digitocr resolve meijiequan challenge: %w", err) + } + return code, nil +} diff --git a/server/internal/tenant/app/meijiequan_challenge_resolver_test.go b/server/internal/tenant/app/meijiequan_challenge_resolver_test.go new file mode 100644 index 0000000..2d4b2af --- /dev/null +++ b/server/internal/tenant/app/meijiequan_challenge_resolver_test.go @@ -0,0 +1,66 @@ +package app + +import ( + "bytes" + "context" + "image" + "image/color" + "image/png" + "testing" + + "github.com/geo-platform/tenant-api/internal/shared/config" +) + +func newTestChallengePNG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 50, 25)) + orange := color.RGBA{R: 240, G: 130, B: 40, A: 255} + // Four orange blocks emulate the four-digit captcha; digitocr only needs to + // segment and match them, so exact glyphs are not required for this test. + for d := 0; d < 4; d++ { + x0 := 5 + d*10 + for y := 5; y < 21; y++ { + for x := x0; x < x0+8; x++ { + img.Set(x, y, orange) + } + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +func TestDigitOCRChallengeResolverDecodesImage(t *testing.T) { + resolver := DigitOCRMeijiequanChallengeCodeResolver{} + code, err := resolver.ResolveCode(context.Background(), MeijiequanChallenge{ + Image: newTestChallengePNG(t), + ContentType: "image/png", + }) + if err != nil { + t.Fatalf("resolve code: %v", err) + } + if len(code) != meijiequanChallengeDigits { + t.Fatalf("expected %d digit code, got %q", meijiequanChallengeDigits, code) + } + for _, r := range code { + if r < '0' || r > '9' { + t.Fatalf("expected numeric code, got %q", code) + } + } +} + +func TestDigitOCRChallengeResolverRejectsEmptyImage(t *testing.T) { + resolver := DigitOCRMeijiequanChallengeCodeResolver{} + if _, err := resolver.ResolveCode(context.Background(), MeijiequanChallenge{}); err == nil { + t.Fatal("expected error for empty challenge image") + } +} + +func TestNewMeijiequanClientDefaultsToDigitOCRResolver(t *testing.T) { + client := NewMeijiequanClient(nil, config.MeijiequanConfig{}) + if _, ok := client.challengeResolver().(DigitOCRMeijiequanChallengeCodeResolver); !ok { + t.Fatalf("expected digitocr resolver by default, got %T", client.challengeResolver()) + } +} diff --git a/server/internal/tenant/app/meijiequan_client.go b/server/internal/tenant/app/meijiequan_client.go new file mode 100644 index 0000000..7158a09 --- /dev/null +++ b/server/internal/tenant/app/meijiequan_client.go @@ -0,0 +1,2620 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/http/cookiejar" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" + + goredis "github.com/redis/go-redis/v9" + "github.com/yuin/goldmark" + nethtml "golang.org/x/net/html" + "golang.org/x/net/html/atom" + + "github.com/geo-platform/tenant-api/internal/shared/config" +) + +var ( + errMeijiequanSessionMissing = errors.New("meijiequan session is missing") + errMeijiequanSessionExpired = errors.New("meijiequan session is expired") + errMeijiequanAuthRequired = errors.New("meijiequan authentication required") + errMeijiequanChallengeRequired = errors.New("meijiequan challenge code required") + errMeijiequanCredentialsMissing = errors.New("meijiequan username or password is missing") + errMeijiequanOrderRejected = errors.New("meijiequan create order failed") +) + +const ( + meijiequanSessionKey = "supplier:meijiequan:session" + meijiequanSearchOptionsNS = "supplier:meijiequan:media_search" + meijiequanUpstreamLockKey = "supplier:meijiequan:upstream_lock" + meijiequanUpstreamThrottleKey = "supplier:meijiequan:upstream_next_at" + // meijiequanLoginMaxAttempts bounds how many fresh captchas a single login + // will try before giving up; the OCR resolver may misread occasionally. + meijiequanLoginMaxAttempts = 3 +) + +var meijiequanURLPattern = regexp.MustCompile(`(?i)(https?://[^\s"'<>\\))]+|//[^\s"'<>\\))]+)`) + +type MeijiequanChallenge struct { + Image []byte + ContentType string + FetchedAt time.Time +} + +type MeijiequanChallengeCodeResolver interface { + ResolveCode(ctx context.Context, challenge MeijiequanChallenge) (string, error) +} + +type ManualMeijiequanChallengeCodeResolver struct{} + +func (ManualMeijiequanChallengeCodeResolver) ResolveCode(context.Context, MeijiequanChallenge) (string, error) { + return "", errMeijiequanChallengeRequired +} + +type MeijiequanClient struct { + mu sync.RWMutex + redis *goredis.Client + cfg config.MeijiequanConfig + resolver MeijiequanChallengeCodeResolver +} + +type meijiequanStoredSession struct { + Cookies []meijiequanStoredCookie `json:"cookies"` + CSRFToken string `json:"csrf_token,omitempty"` + UserID string `json:"user_id,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type meijiequanStoredCookie struct { + Name string `json:"name"` + Value string `json:"value"` + Domain string `json:"domain,omitempty"` + Path string `json:"path,omitempty"` + Expires *time.Time `json:"expires,omitempty"` + HTTPOnly bool `json:"http_only,omitempty"` + Secure bool `json:"secure,omitempty"` +} + +type meijiequanMediaListResponse struct { + Status int + Info string + Data []map[string]any + Page int + AllPage int + AllNum int + UserID int64 + Raw map[string]json.RawMessage `json:"-"` +} + +func (r *meijiequanMediaListResponse) UnmarshalJSON(body []byte) error { + var raw struct { + Status any `json:"status"` + Info string `json:"info"` + Data []map[string]any `json:"data"` + Page any `json:"page"` + AllPage any `json:"all_page"` + AllNum any `json:"all_num"` + UserID any `json:"user_id"` + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(&raw); err != nil { + return err + } + r.Status = int(meijiequanFlexibleInt64(raw.Status)) + r.Info = strings.TrimSpace(raw.Info) + r.Data = raw.Data + r.Page = int(meijiequanFlexibleInt64(raw.Page)) + r.AllPage = int(meijiequanFlexibleInt64(raw.AllPage)) + r.AllNum = int(meijiequanFlexibleInt64(raw.AllNum)) + r.UserID = meijiequanFlexibleInt64(raw.UserID) + return nil +} + +type MeijiequanMediaPage struct { + Items []UpsertSupplierMediaResourceInput + Page int + AllPage int + AllNum int +} + +type meijiequanSearchOptionsCache struct { + ModelID int `json:"model_id"` + Groups []MediaSupplySearchOptionGroup `json:"groups"` + UpdatedAt time.Time `json:"updated_at"` +} + +type MeijiequanCreateOrderRequest struct { + UID string + ModelID int + Title string + Content string + Remark string + Source string + Description string + Promotion string + Propaganda string + NewsLink string + BusinessLicense string + EnterpriseLogo string + Coupon string + ReferenceLink string + DailyPrice string + LivePrice string + Stock string + Cover string + OriginalLink string + BrandID string + ExtraField1 string + ExtraField2 string + OrderBrand string + ResourceIDArr []string + ResourceSpareArr []string + BatchResourceIDs string + PointTime string + PointPriceRange string + LimitTimeEnd string + Extra map[string]any +} + +type MeijiequanCreateOrderResponse struct { + Status int `json:"status"` + Info string `json:"info"` + Data json.RawMessage `json:"data,omitempty"` + Raw json.RawMessage `json:"-"` +} + +type MeijiequanPublishedArticlePage struct { + Items []MeijiequanPublishedArticle + Page int + AllPage int + AllNum int +} + +type MeijiequanPublishedArticle struct { + OrderID string + OrderCode string + Title string + ResourceID string + ResourceName string + ExternalArticleURL string + Status string + RejectReason string + PublishedAt *time.Time + Raw json.RawMessage +} + +type meijiequanLoginForm struct { + Username string + Password string + Code string + Token string + SK string +} + +type meijiequanLoginResponse struct { + Status int `json:"status"` + Info string `json:"info"` + Data json.RawMessage `json:"data,omitempty"` + UserID int64 `json:"user_id,omitempty"` + Raw json.RawMessage `json:"-"` +} + +func NewMeijiequanClient(redis *goredis.Client, cfg config.MeijiequanConfig) *MeijiequanClient { + client := &MeijiequanClient{ + redis: redis, + // keep DigitOCRMeijiequanChallengeCodeResolver + resolver: DigitOCRMeijiequanChallengeCodeResolver{}, + } + client.SetConfig(cfg) + return client +} + +func (c *MeijiequanClient) WithChallengeCodeResolver(resolver MeijiequanChallengeCodeResolver) *MeijiequanClient { + if resolver != nil { + c.mu.Lock() + c.resolver = resolver + c.mu.Unlock() + } + return c +} + +func (c *MeijiequanClient) SetConfig(cfg config.MeijiequanConfig) *MeijiequanClient { + if c == nil { + return c + } + wrapper := config.MediaSupplyConfig{Meijiequan: cfg} + config.NormalizeMediaSupplyConfig(&wrapper) + c.mu.Lock() + c.cfg = wrapper.Meijiequan + c.mu.Unlock() + return c +} + +func (c *MeijiequanClient) config() config.MeijiequanConfig { + if c == nil { + var cfg config.MeijiequanConfig + wrapper := config.MediaSupplyConfig{Meijiequan: cfg} + config.NormalizeMediaSupplyConfig(&wrapper) + return wrapper.Meijiequan + } + c.mu.RLock() + defer c.mu.RUnlock() + return c.cfg +} + +func (c *MeijiequanClient) challengeResolver() MeijiequanChallengeCodeResolver { + if c == nil { + return ManualMeijiequanChallengeCodeResolver{} + } + c.mu.RLock() + defer c.mu.RUnlock() + if c.resolver == nil { + return ManualMeijiequanChallengeCodeResolver{} + } + return c.resolver +} + +func (c *MeijiequanClient) LoginWithConfiguredAccount(ctx context.Context) (*MeijiequanSessionStatus, error) { + if c == nil || c.redis == nil { + return nil, errMeijiequanSessionMissing + } + cfg := c.config() + username := strings.TrimSpace(cfg.Username) + password := strings.TrimSpace(cfg.Password) + if username == "" || password == "" { + return nil, errMeijiequanCredentialsMissing + } + + var status *MeijiequanSessionStatus + if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error { + jar, err := cookiejar.New(nil) + if err != nil { + return err + } + client := newMeijiequanHTTPClient(cfg.RequestTimeout, jar) + token, sk, err := c.fetchLoginForm(lockCtx, client, cfg) + if err != nil { + return err + } + loginResp, err := c.resolveAndSubmitLogin(lockCtx, client, cfg, token, sk, username, password) + if err != nil { + return err + } + userID := strings.TrimSpace(cfg.UserID) + if userID == "" { + userID = extractMeijiequanUserID(loginResp) + } + if userID == "" { + userID = c.discoverSessionUserID(lockCtx, client, cfg, token) + } + var saveErr error + status, saveErr = c.saveSessionFromJar(lockCtx, jar, token, userID) + return saveErr + }); err != nil { + return nil, err + } + return status, nil +} + +// resolveAndSubmitLogin fetches a captcha, resolves it through the configured +// resolver, and submits the login form. Because the OCR resolver may misread a +// captcha, it retries with a fresh captcha on challenge failures up to +// meijiequanLoginMaxAttempts. The login form token/sk stay valid across captcha +// refreshes, so only the captcha is re-fetched per attempt. +// +// A resolver that signals errMeijiequanChallengeRequired (e.g. the manual +// resolver) cannot auto-handle the captcha, so the loop returns immediately +// without burning retries. +func (c *MeijiequanClient) resolveAndSubmitLogin(ctx context.Context, client *http.Client, cfg config.MeijiequanConfig, token, sk, username, password string) (*meijiequanLoginResponse, error) { + resolver := c.challengeResolver() + var lastErr error + for attempt := 0; attempt < meijiequanLoginMaxAttempts; attempt++ { + challenge, err := c.fetchChallenge(ctx, client, cfg) + if err != nil { + return nil, err + } + code, err := resolver.ResolveCode(ctx, challenge) + if err != nil { + if errors.Is(err, errMeijiequanChallengeRequired) { + return nil, err + } + lastErr = err + continue + } + code = strings.TrimSpace(code) + if code == "" { + lastErr = errMeijiequanChallengeRequired + continue + } + loginResp, err := c.submitLogin(ctx, client, cfg, meijiequanLoginForm{ + Username: username, + Password: password, + Code: code, + Token: token, + SK: sk, + }) + if err != nil { + if errors.Is(err, errMeijiequanChallengeRequired) { + lastErr = err + continue + } + return nil, err + } + return loginResp, nil + } + if lastErr != nil && !errors.Is(lastErr, errMeijiequanChallengeRequired) { + return nil, fmt.Errorf("%w: %v", errMeijiequanChallengeRequired, lastErr) + } + return nil, errMeijiequanChallengeRequired +} + +func (c *MeijiequanClient) fetchLoginForm(ctx context.Context, client *http.Client, cfg config.MeijiequanConfig) (string, string, error) { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.BaseURL+"/supply/index/index", nil) + if err != nil { + return "", "", err + } + c.applyHeaders(httpReq, "") + resp, err := client.Do(httpReq) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) + if err != nil { + return "", "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", "", fmt.Errorf("meijiequan login form http %d: %s", resp.StatusCode, string(body)) + } + fields := parseMeijiequanHiddenInputs(bytes.NewReader(body)) + return strings.TrimSpace(fields["_token"]), strings.TrimSpace(fields["sk"]), nil +} + +func (c *MeijiequanClient) fetchChallenge(ctx context.Context, client *http.Client, cfg config.MeijiequanConfig) (MeijiequanChallenge, error) { + endpoint := cfg.BaseURL + "/api/login/getCode?t=" + strconv.FormatInt(time.Now().UnixNano(), 10) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return MeijiequanChallenge{}, err + } + c.applyHeaders(httpReq, "") + resp, err := client.Do(httpReq) + if err != nil { + return MeijiequanChallenge{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return MeijiequanChallenge{}, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return MeijiequanChallenge{}, fmt.Errorf("meijiequan challenge http %d: %s", resp.StatusCode, string(body)) + } + return MeijiequanChallenge{ + Image: body, + ContentType: resp.Header.Get("Content-Type"), + FetchedAt: time.Now().UTC(), + }, nil +} + +func (c *MeijiequanClient) submitLogin(ctx context.Context, client *http.Client, cfg config.MeijiequanConfig, form meijiequanLoginForm) (*meijiequanLoginResponse, error) { + values := url.Values{} + values.Set("username", strings.TrimSpace(form.Username)) + values.Set("password", strings.TrimSpace(form.Password)) + values.Set("code", strings.TrimSpace(form.Code)) + if token := strings.TrimSpace(form.Token); token != "" { + values.Set("_token", token) + } + if sk := strings.TrimSpace(form.SK); sk != "" { + values.Set("sk", sk) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.BaseURL+"/api/login/loginyz", strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + c.applyHeaders(httpReq, form.Token) + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + httpReq.Header.Set("Referer", cfg.BaseURL+"/supply/index/index") + resp, err := client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("meijiequan login http %d: %s", resp.StatusCode, string(body)) + } + var parsed meijiequanLoginResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + parsed.Raw = append(parsed.Raw[:0], body...) + if parsed.Status != 1 { + if isMeijiequanChallengeMessage(parsed.Info) { + return nil, errMeijiequanChallengeRequired + } + return nil, fmt.Errorf("meijiequan login failed: %s", parsed.Info) + } + return &parsed, nil +} + +func (c *MeijiequanClient) discoverSessionUserID(ctx context.Context, client *http.Client, cfg config.MeijiequanConfig, csrf string) string { + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.BaseURL+"/advSupply/media/getMediaInfo?model_id=1&page=1&page_size=1", nil) + if err != nil { + return "" + } + c.applyHeaders(httpReq, csrf) + resp, err := client.Do(httpReq) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 || isMeijiequanAuthResponse(resp) { + return "" + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return "" + } + parsed, err := decodeMeijiequanMediaList(body) + if err != nil || parsed.UserID <= 0 { + return "" + } + return strconv.FormatInt(parsed.UserID, 10) +} + +func (c *MeijiequanClient) saveSessionFromJar(ctx context.Context, jar http.CookieJar, csrfToken, userID string) (*MeijiequanSessionStatus, error) { + if c == nil || c.redis == nil { + return nil, errMeijiequanSessionMissing + } + cfg := c.config() + base, err := url.Parse(cfg.BaseURL) + if err != nil { + return nil, err + } + now := time.Now().UTC() + expiresAt := now.Add(cfg.SessionTTL) + session := meijiequanStoredSession{ + Cookies: make([]meijiequanStoredCookie, 0), + CSRFToken: strings.TrimSpace(csrfToken), + UserID: strings.TrimSpace(userID), + ExpiresAt: &expiresAt, + UpdatedAt: now, + } + for _, cookie := range jar.Cookies(base) { + name := strings.TrimSpace(cookie.Name) + value := strings.TrimSpace(cookie.Value) + if name == "" || value == "" { + continue + } + domain := strings.TrimSpace(cookie.Domain) + if domain == "" { + domain = base.Hostname() + } + path := strings.TrimSpace(cookie.Path) + if path == "" { + path = "/" + } + var cookieExpires *time.Time + if !cookie.Expires.IsZero() { + value := cookie.Expires.UTC() + cookieExpires = &value + } + session.Cookies = append(session.Cookies, meijiequanStoredCookie{ + Name: name, + Value: value, + Domain: domain, + Path: path, + Expires: cookieExpires, + HTTPOnly: cookie.HttpOnly, + Secure: cookie.Secure, + }) + } + if len(session.Cookies) == 0 { + return nil, errMeijiequanSessionMissing + } + raw, err := json.Marshal(session) + if err != nil { + return nil, err + } + if err := c.redis.Set(ctx, meijiequanSessionKey, raw, cfg.SessionTTL).Err(); err != nil { + return nil, err + } + return &MeijiequanSessionStatus{ + Available: true, + ExpiresAt: &expiresAt, + UpdatedAt: &now, + AccountConfigured: true, + UserID: strings.TrimSpace(userID), + }, nil +} + +func (c *MeijiequanClient) ImportSession(ctx context.Context, req ImportMeijiequanSessionRequest) (*MeijiequanSessionStatus, error) { + if c == nil || c.redis == nil { + return nil, errMeijiequanSessionMissing + } + cfg := c.config() + now := time.Now().UTC() + expiresAt := req.ExpiresAt + if expiresAt == nil { + value := now.Add(cfg.SessionTTL) + expiresAt = &value + } + session := meijiequanStoredSession{ + Cookies: make([]meijiequanStoredCookie, 0, len(req.Cookies)), + CSRFToken: strings.TrimSpace(req.CSRFToken), + UserID: strings.TrimSpace(cfg.UserID), + ExpiresAt: expiresAt, + UpdatedAt: now, + } + for _, cookie := range req.Cookies { + name := strings.TrimSpace(cookie.Name) + value := strings.TrimSpace(cookie.Value) + if name == "" || value == "" { + continue + } + domain := strings.TrimSpace(cookie.Domain) + if domain == "" { + domain = "www.meijiequan.com" + } + path := strings.TrimSpace(cookie.Path) + if path == "" { + path = "/" + } + session.Cookies = append(session.Cookies, meijiequanStoredCookie{ + Name: name, + Value: value, + Domain: domain, + Path: path, + Expires: cookie.Expires, + HTTPOnly: cookie.HTTPOnly, + Secure: cookie.Secure, + }) + } + if len(session.Cookies) == 0 { + return nil, errMeijiequanSessionMissing + } + ttl := time.Until(*expiresAt) + if ttl <= 0 { + return nil, errMeijiequanSessionExpired + } + raw, err := json.Marshal(session) + if err != nil { + return nil, err + } + if err := c.redis.Set(ctx, meijiequanSessionKey, raw, ttl).Err(); err != nil { + return nil, err + } + return c.sessionStatusFromStoredSession(&session, true), nil +} + +func (c *MeijiequanClient) SessionStatus(ctx context.Context) (*MeijiequanSessionStatus, error) { + session, err := c.loadSession(ctx) + if err != nil { + if errors.Is(err, errMeijiequanSessionMissing) || errors.Is(err, errMeijiequanSessionExpired) { + cfg := c.config() + return &MeijiequanSessionStatus{ + Available: false, + AccountConfigured: strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) != "", + UserID: strings.TrimSpace(cfg.UserID), + }, nil + } + return nil, err + } + return c.sessionStatusFromStoredSession(session, true), nil +} + +func (c *MeijiequanClient) ListMedia(ctx context.Context, modelID, page, pageSize int) (MeijiequanMediaPage, error) { + var result MeijiequanMediaPage + if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error { + cfg := c.config() + client, csrf, err := c.httpClient(lockCtx) + if err != nil { + return err + } + endpoint, err := url.Parse(cfg.BaseURL + "/advSupply/media/getMediaInfo") + if err != nil { + return err + } + q := endpoint.Query() + q.Set("model_id", strconv.Itoa(modelID)) + q.Set("page", strconv.Itoa(page)) + q.Set("page_size", strconv.Itoa(pageSize)) + endpoint.RawQuery = q.Encode() + httpReq, err := http.NewRequestWithContext(lockCtx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return err + } + c.applyHeaders(httpReq, csrf) + resp, err := client.Do(httpReq) + if err != nil { + return err + } + defer resp.Body.Close() + if isMeijiequanAuthResponse(resp) { + return errMeijiequanAuthRequired + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("meijiequan list media http %d: %s", resp.StatusCode, string(body)) + } + parsed, err := decodeMeijiequanMediaList(body) + if err != nil { + return err + } + if parsed.Status == 0 && strings.TrimSpace(parsed.Info) != "" { + return fmt.Errorf("meijiequan list media failed: %s", parsed.Info) + } + result.Page = parsed.Page + result.AllPage = parsed.AllPage + result.AllNum = parsed.AllNum + result.Items = make([]UpsertSupplierMediaResourceInput, 0, len(parsed.Data)) + for _, item := range parsed.Data { + resource, ok := parseMeijiequanMediaItem(modelID, item) + if ok { + result.Items = append(result.Items, resource) + } + } + return nil + }); err != nil { + return MeijiequanMediaPage{}, err + } + return result, nil +} + +func (c *MeijiequanClient) SearchOptions(ctx context.Context, modelID int) (*MediaSupplySearchOptionsResponse, error) { + if modelID <= 0 { + modelID = 1 + } + if cached, ok := c.loadSearchOptionsCache(ctx, modelID); ok { + return cached, nil + } + var result *MediaSupplySearchOptionsResponse + if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error { + if cached, ok := c.loadSearchOptionsCache(lockCtx, modelID); ok { + result = cached + return nil + } + cfg := c.config() + client, csrf, err := c.httpClient(lockCtx) + if err != nil { + return err + } + endpoint, err := url.Parse(cfg.BaseURL + "/advSupply/media/getMediaSearch") + if err != nil { + return err + } + q := endpoint.Query() + q.Set("model_id", strconv.Itoa(modelID)) + endpoint.RawQuery = q.Encode() + httpReq, err := http.NewRequestWithContext(lockCtx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return err + } + c.applyHeaders(httpReq, csrf) + resp, err := client.Do(httpReq) + if err != nil { + return err + } + defer resp.Body.Close() + if isMeijiequanAuthResponse(resp) { + return errMeijiequanAuthRequired + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("meijiequan search options http %d: %s", resp.StatusCode, string(body)) + } + groups, err := decodeMeijiequanSearchOptions(body) + if err != nil { + return err + } + result = &MediaSupplySearchOptionsResponse{ + ModelID: modelID, + Groups: groups, + UpdatedAt: time.Now().UTC(), + } + c.saveSearchOptionsCache(lockCtx, result) + return nil + }); err != nil { + return nil, err + } + return result, nil +} + +func (c *MeijiequanClient) CreateOrder(ctx context.Context, req MeijiequanCreateOrderRequest) (*MeijiequanCreateOrderResponse, error) { + var result *MeijiequanCreateOrderResponse + if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error { + cfg := c.config() + client, csrf, err := c.httpClient(lockCtx) + if err != nil { + return err + } + if strings.TrimSpace(req.UID) == "" { + return fmt.Errorf("meijiequan user_id is not configured") + } + form := meijiequanCreateOrderForm(req) + requestBody := form.Encode() + httpReq, err := http.NewRequestWithContext(lockCtx, http.MethodPost, cfg.BaseURL+"/advSupply/article/createOrder", strings.NewReader(requestBody)) + if err != nil { + return err + } + c.applyHeaders(httpReq, csrf) + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + resp, err := client.Do(httpReq) + if err != nil { + return err + } + defer resp.Body.Close() + if isMeijiequanAuthResponse(resp) { + return errMeijiequanAuthRequired + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("meijiequan create order http %d: %s", resp.StatusCode, string(body)) + } + var parsed MeijiequanCreateOrderResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return err + } + parsed.Raw = append(parsed.Raw[:0], body...) + result = &parsed + if parsed.Status == 0 { + return fmt.Errorf("%w: %s", errMeijiequanOrderRejected, parsed.Info) + } + return nil + }); err != nil { + return nil, err + } + return result, nil +} + +func (c *MeijiequanClient) ListPublishedArticles(ctx context.Context, userID string, page, pageSize int) (MeijiequanPublishedArticlePage, error) { + return c.listArticles(ctx, userID, "2", page, pageSize) +} + +func (c *MeijiequanClient) ListAllArticles(ctx context.Context, userID string, page, pageSize int) (MeijiequanPublishedArticlePage, error) { + return c.listArticles(ctx, userID, "", page, pageSize) +} + +func (c *MeijiequanClient) ListPendingArticles(ctx context.Context, userID string, page, pageSize int) (MeijiequanPublishedArticlePage, error) { + return c.listArticles(ctx, userID, "11", page, pageSize) +} + +func (c *MeijiequanClient) ListProblemArticles(ctx context.Context, userID string, page, pageSize int) (MeijiequanPublishedArticlePage, error) { + return c.listArticlePage(ctx, "/advSupply/article/question", userID, "4,5,6", page, pageSize) +} + +func (c *MeijiequanClient) listArticles(ctx context.Context, userID, status string, page, pageSize int) (MeijiequanPublishedArticlePage, error) { + return c.listArticlePage(ctx, "/advSupply/article/index", userID, status, page, pageSize) +} + +func (c *MeijiequanClient) listArticlePage(ctx context.Context, path, userID, status string, page, pageSize int) (MeijiequanPublishedArticlePage, error) { + var result MeijiequanPublishedArticlePage + if err := c.withUpstreamLock(ctx, func(lockCtx context.Context) error { + cfg := c.config() + client, csrf, err := c.httpClient(lockCtx) + if err != nil { + return err + } + endpoint, err := url.Parse(cfg.BaseURL + path) + if err != nil { + return err + } + q := endpoint.Query() + if status = strings.TrimSpace(status); status != "" { + q.Set("status", status) + } + if text := strings.TrimSpace(userID); text != "" { + q.Set("uk", text) + } + if page > 0 { + q.Set("page", strconv.Itoa(page)) + } + if pageSize > 0 { + q.Set("page_size", strconv.Itoa(pageSize)) + q.Set("limit", strconv.Itoa(pageSize)) + } + endpoint.RawQuery = q.Encode() + httpReq, err := http.NewRequestWithContext(lockCtx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return err + } + c.applyHeaders(httpReq, csrf) + httpReq.Header.Set("Accept", "application/json, text/html, */*") + resp, err := client.Do(httpReq) + if err != nil { + return err + } + defer resp.Body.Close() + if isMeijiequanAuthResponse(resp) { + return errMeijiequanAuthRequired + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("meijiequan published article list http %d: %s", resp.StatusCode, string(body)) + } + parsed, err := decodeMeijiequanPublishedArticlePage(body) + if err != nil { + return err + } + result = parsed + if result.Page == 0 { + result.Page = page + } + return nil + }); err != nil { + return MeijiequanPublishedArticlePage{}, err + } + return result, nil +} + +func meijiequanCreateOrderForm(req MeijiequanCreateOrderRequest) url.Values { + applyMeijiequanCreateOrderExtra(&req, req.Extra) + prop := meijiequanCreateOrderProp(req) + if extraProp := meijiequanCreateOrderExtraMap(req.Extra, "prop"); len(extraProp) > 0 { + for key, value := range extraProp { + key = strings.TrimSpace(key) + if key == "" { + continue + } + prop[key] = strings.TrimSpace(meijiequanAnyString(value)) + } + } + for key, value := range req.Extra { + key = strings.TrimSpace(key) + if key == "" || isKnownMeijiequanCreateOrderExtraKey(key) { + continue + } + if _, exists := prop[key]; !exists { + prop[key] = strings.TrimSpace(meijiequanAnyString(value)) + } + } + resourceIDs, _ := json.Marshal(nonNilMeijiequanStringSlice(req.ResourceIDArr)) + spareIDs, _ := json.Marshal(nonNilMeijiequanStringSlice(req.ResourceSpareArr)) + modelID := req.ModelID + if modelID <= 0 { + modelID = 1 + } + form := url.Values{} + form.Set("uid", strings.TrimSpace(req.UID)) + for _, key := range meijiequanCreateOrderPropKeys(prop) { + form.Set("prop["+key+"]", prop[key]) + } + form.Set("model_id", strconv.Itoa(modelID)) + form.Set("client", "") + form.Set("voucher_id", "0") + form.Set("resource_id_arr", string(resourceIDs)) + form.Set("resource_spare_arr", string(spareIDs)) + form.Set("order_brand", strings.TrimSpace(req.OrderBrand)) + form.Set("batch_resource_ids", strings.TrimSpace(req.BatchResourceIDs)) + form.Set("point_time", strings.TrimSpace(req.PointTime)) + form.Set("point_price_range", strings.TrimSpace(req.PointPriceRange)) + form.Set("limit_time_end", strings.TrimSpace(req.LimitTimeEnd)) + return form +} + +func meijiequanCreateOrderProp(req MeijiequanCreateOrderRequest) map[string]string { + return map[string]string{ + "biaoti": strings.TrimSpace(req.Title), + "laiyuan": strings.TrimSpace(req.Source), + "miaoshu": strings.TrimSpace(req.Description), + "neirong": meijiequanArticleHTML(req.Content), + "tuiguang": strings.TrimSpace(req.Promotion), + "xuanchuan": strings.TrimSpace(req.Propaganda), + "xinwenlianjie": strings.TrimSpace(req.NewsLink), + "yingyezhizhao": strings.TrimSpace(req.BusinessLicense), + "qiyelogo": strings.TrimSpace(req.EnterpriseLogo), + "youhuiquan": strings.TrimSpace(req.Coupon), + "cankaolianjie": strings.TrimSpace(req.ReferenceLink), + "richangjia": strings.TrimSpace(req.DailyPrice), + "zhibojia": strings.TrimSpace(req.LivePrice), + "kucunliang": strings.TrimSpace(req.Stock), + "fengmian": strings.TrimSpace(req.Cover), + "yuanwenlianjie": strings.TrimSpace(req.OriginalLink), + "brand_id": strings.TrimSpace(req.BrandID), + "extra_field_1": strings.TrimSpace(req.ExtraField1), + "extra_field_2": strings.TrimSpace(req.ExtraField2), + } +} + +func meijiequanCreateOrderPropKeys(prop map[string]string) []string { + preferred := []string{ + "biaoti", + "laiyuan", + "miaoshu", + "neirong", + "tuiguang", + "xuanchuan", + "xinwenlianjie", + "yingyezhizhao", + "qiyelogo", + "youhuiquan", + "cankaolianjie", + "richangjia", + "zhibojia", + "kucunliang", + "fengmian", + "yuanwenlianjie", + "brand_id", + "extra_field_1", + "extra_field_2", + } + seen := make(map[string]struct{}, len(prop)) + keys := make([]string, 0, len(prop)) + for _, key := range preferred { + if _, ok := prop[key]; ok { + keys = append(keys, key) + seen[key] = struct{}{} + } + } + for key := range prop { + if _, ok := seen[key]; !ok { + keys = append(keys, key) + } + } + return keys +} + +func applyMeijiequanCreateOrderExtra(req *MeijiequanCreateOrderRequest, extra map[string]any) { + if req == nil || len(extra) == 0 { + return + } + setStringIfEmpty := func(target *string, keys ...string) { + if target == nil || strings.TrimSpace(*target) != "" { + return + } + *target = meijiequanCreateOrderExtraString(extra, keys...) + } + setStringIfEmpty(&req.Source, "source", "laiyuan") + setStringIfEmpty(&req.Description, "description", "miaoshu") + setStringIfEmpty(&req.Promotion, "promotion", "tuiguang") + setStringIfEmpty(&req.Propaganda, "propaganda", "xuanchuan") + setStringIfEmpty(&req.NewsLink, "news_link", "xinwenlianjie") + setStringIfEmpty(&req.BusinessLicense, "business_license", "yingyezhizhao") + setStringIfEmpty(&req.EnterpriseLogo, "enterprise_logo", "qiyelogo") + setStringIfEmpty(&req.Coupon, "coupon", "youhuiquan") + setStringIfEmpty(&req.ReferenceLink, "reference_link", "cankaolianjie") + setStringIfEmpty(&req.DailyPrice, "daily_price", "richangjia") + setStringIfEmpty(&req.LivePrice, "live_price", "zhibojia") + setStringIfEmpty(&req.Stock, "stock", "kucunliang") + setStringIfEmpty(&req.Cover, "cover", "fengmian") + setStringIfEmpty(&req.OriginalLink, "original_link", "yuanwenlianjie") + setStringIfEmpty(&req.BrandID, "brand_id") + setStringIfEmpty(&req.ExtraField1, "extra_field_1") + setStringIfEmpty(&req.ExtraField2, "extra_field_2") + setStringIfEmpty(&req.OrderBrand, "order_brand") + setStringIfEmpty(&req.BatchResourceIDs, "batch_resource_ids") + setStringIfEmpty(&req.PointTime, "point_time") + setStringIfEmpty(&req.PointPriceRange, "point_price_range") + setStringIfEmpty(&req.LimitTimeEnd, "limit_time_end") + if len(req.ResourceSpareArr) == 0 { + req.ResourceSpareArr = meijiequanCreateOrderExtraStringSlice(extra, "resource_spare_arr", "resource_spare_ids") + } +} + +func meijiequanCreateOrderExtraString(extra map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := extra[key]; ok { + if text := strings.TrimSpace(meijiequanAnyString(value)); text != "" { + return text + } + } + } + return "" +} + +func meijiequanCreateOrderExtraStringSlice(extra map[string]any, keys ...string) []string { + for _, key := range keys { + value, ok := extra[key] + if !ok { + continue + } + switch typed := value.(type) { + case []string: + return compactMeijiequanStringSlice(typed) + case []any: + values := make([]string, 0, len(typed)) + for _, item := range typed { + if text := strings.TrimSpace(meijiequanAnyString(item)); text != "" { + values = append(values, text) + } + } + return values + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return nil + } + if strings.HasPrefix(trimmed, "[") { + var decoded []string + if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil { + return compactMeijiequanStringSlice(decoded) + } + var decodedAny []any + if err := json.Unmarshal([]byte(trimmed), &decodedAny); err == nil { + values := make([]string, 0, len(decodedAny)) + for _, item := range decodedAny { + if text := strings.TrimSpace(meijiequanAnyString(item)); text != "" { + values = append(values, text) + } + } + return values + } + } + parts := strings.Split(trimmed, ",") + return compactMeijiequanStringSlice(parts) + } + } + return nil +} + +func compactMeijiequanStringSlice(values []string) []string { + compacted := make([]string, 0, len(values)) + for _, value := range values { + if text := strings.TrimSpace(value); text != "" { + compacted = append(compacted, text) + } + } + return compacted +} + +func meijiequanCreateOrderExtraMap(extra map[string]any, key string) map[string]any { + if len(extra) == 0 { + return nil + } + value, ok := extra[key] + if !ok { + return nil + } + switch typed := value.(type) { + case map[string]any: + return typed + case map[string]string: + result := make(map[string]any, len(typed)) + for key, value := range typed { + result[key] = value + } + return result + default: + return nil + } +} + +func isKnownMeijiequanCreateOrderExtraKey(key string) bool { + switch strings.TrimSpace(key) { + case "prop", + "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": + return true + default: + return false + } +} + +func nonNilMeijiequanStringSlice(values []string) []string { + if values == nil { + return []string{} + } + return values +} + +func meijiequanArticleHTML(content string) string { + source := strings.TrimSpace(content) + if source == "" { + return "" + } + if looksLikeHTML(source) { + return source + } + return markdownToMeijiequanHTML(source) +} + +func looksLikeHTML(source string) bool { + lower := strings.ToLower(strings.TrimSpace(source)) + return strings.Contains(lower, " now then + scheduled = current +end +redis.call("SET", KEYS[1], scheduled + interval, "PX", interval * 4) +return scheduled - now +`) + waitMillis, err := throttleScript.Run(ctx, c.redis, []string{meijiequanUpstreamThrottleKey}, now, intervalMillis).Int64() + if err != nil { + return err + } + if waitMillis <= 0 { + return nil + } + timer := time.NewTimer(time.Duration(waitMillis) * time.Millisecond) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (c *MeijiequanClient) applyHeaders(req *http.Request, csrf string) { + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; GeoRanklyMediaSupply/1.0)") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + if csrf != "" { + req.Header.Set("X-CSRF-TOKEN", csrf) + } +} + +func newMeijiequanHTTPClient(timeout time.Duration, jar http.CookieJar) *http.Client { + return &http.Client{ + Jar: jar, + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return http.ErrUseLastResponse + } + return nil + }, + } +} + +func (c *MeijiequanClient) sessionStatusFromStoredSession(session *meijiequanStoredSession, available bool) *MeijiequanSessionStatus { + cfg := c.config() + userID := strings.TrimSpace(cfg.UserID) + if session != nil && strings.TrimSpace(session.UserID) != "" { + userID = strings.TrimSpace(session.UserID) + } + var expiresAt *time.Time + var updatedAt *time.Time + if session != nil { + expiresAt = session.ExpiresAt + updatedAt = &session.UpdatedAt + } + return &MeijiequanSessionStatus{ + Available: available, + ExpiresAt: expiresAt, + UpdatedAt: updatedAt, + AccountConfigured: strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) != "", + UserID: userID, + } +} + +func isMeijiequanAuthResponse(resp *http.Response) bool { + if resp == nil { + return false + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return true + } + location := resp.Header.Get("Location") + return strings.Contains(location, "/login") || strings.Contains(location, "/supply/index/index") +} + +func decodeMeijiequanMediaList(body []byte) (*meijiequanMediaListResponse, error) { + var parsed meijiequanMediaListResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + return &parsed, nil +} + +func decodeMeijiequanSearchOptions(body []byte) ([]MediaSupplySearchOptionGroup, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var root any + if err := decoder.Decode(&root); err != nil { + return nil, err + } + if nested, ok := firstSearchOptionsObject(root); ok { + root = nested + } + record, ok := root.(map[string]any) + if !ok { + return nil, fmt.Errorf("meijiequan search options response is not an object") + } + order := []string{ + "pindaoleixing", + "zonghemenhu", + "quyu", + "shouluxiaoguo", + "tebiehangye", + "lianjieleixing", + "chugaosudu", + "aiInclude", + "otherOption", + } + used := make(map[string]struct{}, len(record)) + groups := make([]MediaSupplySearchOptionGroup, 0, len(record)) + for _, key := range order { + if group, ok := parseMeijiequanSearchOptionGroup(key, record[key]); ok { + groups = append(groups, group) + used[key] = struct{}{} + } + } + for key, value := range record { + if _, exists := used[key]; exists { + continue + } + if group, ok := parseMeijiequanSearchOptionGroup(key, value); ok { + groups = append(groups, group) + } + } + return groups, nil +} + +func decodeMeijiequanPublishedArticlePage(body []byte) (MeijiequanPublishedArticlePage, error) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return MeijiequanPublishedArticlePage{}, nil + } + if trimmed[0] == '{' || trimmed[0] == '[' { + if page, ok, err := decodeMeijiequanPublishedArticleJSON(trimmed); ok || err != nil { + return page, err + } + } + return parseMeijiequanPublishedArticleHTML(bytes.NewReader(trimmed)) +} + +func decodeMeijiequanPublishedArticleJSON(body []byte) (MeijiequanPublishedArticlePage, bool, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var root any + if err := decoder.Decode(&root); err != nil { + return MeijiequanPublishedArticlePage{}, true, err + } + page := MeijiequanPublishedArticlePage{} + if record, ok := root.(map[string]any); ok { + if status := int(meijiequanFlexibleInt64(record["status"])); status == 0 { + info := strings.TrimSpace(meijiequanAnyString(record["info"])) + if info != "" { + return page, true, fmt.Errorf("meijiequan published article list failed: %s", info) + } + } + page.Page = int(meijiequanFlexibleInt64(firstMapValue(record, "page", "current_page"))) + page.AllPage = int(meijiequanFlexibleInt64(firstMapValue(record, "all_page", "last_page", "pages"))) + page.AllNum = int(meijiequanFlexibleInt64(firstMapValue(record, "all_num", "total", "count"))) + } + items := meijiequanPublishedArticleJSONItems(root) + if items == nil { + return page, false, nil + } + page.Items = make([]MeijiequanPublishedArticle, 0, len(items)) + for _, item := range items { + if article, ok := parseMeijiequanPublishedArticleMap(item); ok { + page.Items = append(page.Items, article) + } + } + return page, true, nil +} + +func meijiequanPublishedArticleJSONItems(value any) []map[string]any { + switch typed := value.(type) { + case []any: + items := make([]map[string]any, 0, len(typed)) + for _, item := range typed { + if record, ok := item.(map[string]any); ok { + items = append(items, record) + } + } + return items + case map[string]any: + for _, key := range []string{"data", "list", "rows", "items", "result"} { + nested, ok := typed[key] + if !ok { + continue + } + if items := meijiequanPublishedArticleJSONItems(nested); items != nil { + return items + } + } + } + return nil +} + +func parseMeijiequanPublishedArticleMap(item map[string]any) (MeijiequanPublishedArticle, bool) { + title := strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "biaoti", "title", "article_title", "name"))) + articleURL := normalizeMeijiequanArticleURL(meijiequanAnyString(firstMapValue(item, + "xinwenlianjie", + "article_url", + "external_article_url", + "url", + "link", + "publish_url", + "case_url", + ))) + if articleURL != "" && !isLikelyPublishedArticleURL(articleURL) { + articleURL = "" + } + if title == "" && articleURL == "" { + return MeijiequanPublishedArticle{}, false + } + raw, _ := json.Marshal(item) + return MeijiequanPublishedArticle{ + 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"))), + Title: title, + ResourceID: normalizeMeijiequanPublishedResourceID(meijiequanAnyString(firstMapValue(item, "resource_id", "media_id", "uid", "mid"))), + ResourceName: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "meiti", "media_name", "resource_name", "name"))), + ExternalArticleURL: articleURL, + 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"))), + PublishedAt: parseMeijiequanTime(meijiequanAnyString(firstMapValue(item, + "published_at", + "publish_time", + "fabushijian", + "updated_at", + "created_at", + ))), + Raw: raw, + }, true +} + +func parseMeijiequanPublishedArticleHTML(r io.Reader) (MeijiequanPublishedArticlePage, error) { + root, err := nethtml.Parse(r) + if err != nil { + return MeijiequanPublishedArticlePage{}, err + } + rows := meijiequanTableRows(root) + page := MeijiequanPublishedArticlePage{Items: make([]MeijiequanPublishedArticle, 0, len(rows))} + for _, row := range rows { + cells := meijiequanElementChildren(row, "td") + if len(cells) == 0 { + continue + } + article, ok := parseMeijiequanPublishedArticleHTMLRow(row, cells) + if ok { + page.Items = append(page.Items, article) + } + } + if len(page.Items) == 0 { + page.Items = parseMeijiequanPublishedArticleHTMLLinks(root) + } + return page, nil +} + +func meijiequanTableRows(node *nethtml.Node) []*nethtml.Node { + if node == nil { + return nil + } + rows := make([]*nethtml.Node, 0) + var walk func(*nethtml.Node) + walk = func(current *nethtml.Node) { + if current == nil { + return + } + if current.Type == nethtml.ElementNode && strings.EqualFold(current.Data, "tr") { + rows = append(rows, current) + } + for child := current.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(node) + return rows +} + +func meijiequanElementChildren(node *nethtml.Node, tag string) []*nethtml.Node { + children := make([]*nethtml.Node, 0) + for child := node.FirstChild; child != nil; child = child.NextSibling { + if child.Type == nethtml.ElementNode && strings.EqualFold(child.Data, tag) { + children = append(children, child) + } + } + return children +} + +func parseMeijiequanPublishedArticleHTMLRow(row *nethtml.Node, cells []*nethtml.Node) (MeijiequanPublishedArticle, bool) { + cellTexts := make([]string, 0, len(cells)) + texts := make([]string, 0, len(cells)) + for _, cell := range cells { + text := strings.TrimSpace(collapseMeijiequanCJKSpaces(normalizeWhitespace(meijiequanNodeText(cell)))) + cellTexts = append(cellTexts, text) + if text != "" { + texts = append(texts, text) + } + } + rowText := strings.Join(texts, " ") + if !looksLikeMeijiequanPublishedRow(rowText) { + return MeijiequanPublishedArticle{}, false + } + tableHeader := meijiequanTableHeader(row) + columnValues := meijiequanArticleColumnValues(tableHeader, cellTexts) + links := meijiequanLinks(row) + articleURL := extractMeijiequanRealBacklink(row) + orderCode := strings.TrimSpace(firstMeijiequanHTMLAttr(row, "data-order-code", "data-order-no", "data-sn", "data-code")) + if orderCode == "" { + orderCode = extractMeijiequanOrderCodeFromTexts(preferredMeijiequanTexts(columnValues, "订单号", texts)) + } + resourceName := strings.TrimSpace(columnValues["资源名称"]) + if resourceName == "" { + resourceName = extractMeijiequanResourceNameFromTexts(texts) + } + title := strings.TrimSpace(columnValues["标题"]) + if title == "" { + title = extractMeijiequanArticleTitleFromTexts(texts, resourceName) + } + for _, link := range links { + if articleURL == "" && isLikelyPublishedArticleURL(link.Href) { + articleURL = normalizeMeijiequanArticleURL(link.Href) + if title == "" && !isMeijiequanGenericPublishedLinkText(link.Text) { + title = strings.TrimSpace(link.Text) + } + } + if title == "" && link.Text != "" && !isMeijiequanGenericPublishedLinkText(link.Text) { + title = strings.TrimSpace(link.Text) + } + } + if title == "" && len(texts) > 0 { + for _, text := range texts { + if !isMeijiequanGenericPublishedLinkText(text) { + title = text + break + } + } + } + resourceID := normalizeMeijiequanPublishedResourceID(firstMeijiequanHTMLAttr(row, "data-resource-id", "data-media-id", "data-mid", "data-uid")) + if resourceID == "" { + resourceID = extractMeijiequanResourceIDFromText(rowText) + } + status := strings.TrimSpace(columnValues["状态"]) + if status == "" { + status = extractMeijiequanPublishedStatus(rowText) + } + rejectReason := strings.TrimSpace(columnValues["退稿原因"]) + return MeijiequanPublishedArticle{ + OrderID: strings.TrimSpace(firstMeijiequanHTMLAttr(row, "data-id", "data-order-id", "data-article-id")), + OrderCode: orderCode, + Title: strings.TrimSpace(title), + ResourceID: resourceID, + ResourceName: resourceName, + ExternalArticleURL: articleURL, + Status: status, + RejectReason: rejectReason, + }, articleURL != "" || title != "" || orderCode != "" +} + +func meijiequanTableHeader(row *nethtml.Node) []string { + if row == nil || row.Parent == nil { + return nil + } + table := row.Parent + for table != nil && !(table.Type == nethtml.ElementNode && strings.EqualFold(table.Data, "table")) { + table = table.Parent + } + if table == nil { + return nil + } + var header []string + var walk func(*nethtml.Node) + walk = func(current *nethtml.Node) { + if current == nil || len(header) > 0 { + return + } + if current.Type == nethtml.ElementNode && strings.EqualFold(current.Data, "tr") { + cells := meijiequanElementChildren(current, "th") + if len(cells) == 0 { + cells = meijiequanElementChildren(current, "td") + } + values := make([]string, 0, len(cells)) + for _, cell := range cells { + values = append(values, strings.TrimSpace(collapseMeijiequanCJKSpaces(normalizeWhitespace(meijiequanNodeText(cell))))) + } + if meijiequanLooksLikeArticleHeader(values) { + header = values + return + } + } + for child := current.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(table) + return header +} + +func meijiequanLooksLikeArticleHeader(values []string) bool { + joined := strings.Join(values, " ") + return strings.Contains(joined, "订单号") && + strings.Contains(joined, "标题") && + strings.Contains(joined, "资源名称") && + strings.Contains(joined, "状态") +} + +func meijiequanArticleColumnValues(header, values []string) map[string]string { + out := make(map[string]string) + for idx, name := range header { + if idx >= len(values) { + continue + } + key := normalizeMeijiequanHeaderName(name) + if key == "" { + continue + } + out[key] = strings.TrimSpace(values[idx]) + } + return out +} + +func normalizeMeijiequanHeaderName(value string) string { + value = strings.TrimSpace(value) + switch { + case strings.Contains(value, "订单号"): + return "订单号" + case strings.Contains(value, "资源名称"): + return "资源名称" + case strings.Contains(value, "退稿原因"): + return "退稿原因" + case strings.Contains(value, "状态"): + return "状态" + case strings.Contains(value, "标题"): + return "标题" + default: + return "" + } +} + +func preferredMeijiequanTexts(values map[string]string, key string, fallback []string) []string { + value := strings.TrimSpace(values[key]) + if value == "" { + return fallback + } + return []string{value} +} + +type meijiequanHTMLLink struct { + Href string + Text string +} + +func meijiequanLinks(node *nethtml.Node) []meijiequanHTMLLink { + links := make([]meijiequanHTMLLink, 0) + var walk func(*nethtml.Node) + walk = func(current *nethtml.Node) { + if current == nil { + return + } + if current.Type == nethtml.ElementNode && strings.EqualFold(current.Data, "a") { + href := strings.TrimSpace(meijiequanAttr(current, "href")) + if href != "" && !strings.HasPrefix(strings.ToLower(href), "javascript:") { + links = append(links, meijiequanHTMLLink{ + Href: href, + Text: strings.TrimSpace(normalizeWhitespace(meijiequanNodeText(current))), + }) + } + } + for child := current.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(node) + return links +} + +func parseMeijiequanPublishedArticleHTMLLinks(root *nethtml.Node) []MeijiequanPublishedArticle { + links := meijiequanLinks(root) + items := make([]MeijiequanPublishedArticle, 0, len(links)) + seen := make(map[string]struct{}, len(links)) + for _, link := range links { + articleURL := normalizeMeijiequanArticleURL(link.Href) + if articleURL == "" || !isLikelyPublishedArticleURL(articleURL) { + continue + } + if _, exists := seen[articleURL]; exists { + continue + } + seen[articleURL] = struct{}{} + items = append(items, MeijiequanPublishedArticle{ + Title: strings.TrimSpace(link.Text), + ExternalArticleURL: articleURL, + }) + } + return items +} + +func extractMeijiequanRealBacklink(node *nethtml.Node) string { + if node == nil { + return "" + } + candidates := make([]string, 0) + var walk func(*nethtml.Node) + walk = func(current *nethtml.Node) { + if current == nil { + return + } + if current.Type == nethtml.ElementNode { + for _, attr := range current.Attr { + key := strings.ToLower(strings.TrimSpace(attr.Key)) + value := strings.TrimSpace(attr.Val) + if value == "" { + continue + } + if isMeijiequanBacklinkAttr(key) { + candidates = append(candidates, value) + } + if key == "onclick" || key == "data-clipboard-text" || strings.Contains(key, "url") || strings.Contains(key, "link") || strings.Contains(key, "href") { + candidates = append(candidates, meijiequanURLPattern.FindAllString(value, -1)...) + } + } + } + for child := current.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(node) + for _, candidate := range candidates { + if normalized := normalizeMeijiequanArticleURL(candidate); normalized != "" && isLikelyPublishedArticleURL(normalized) { + return normalized + } + } + return "" +} + +func isMeijiequanBacklinkAttr(key string) bool { + switch strings.ToLower(strings.TrimSpace(key)) { + case "data-clipboard-text", + "data-url", + "data-link", + "data-href", + "data-backlink", + "data-article-url", + "data-copy", + "copy-url": + return true + default: + return false + } +} + +func firstMeijiequanHTMLAttr(node *nethtml.Node, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(meijiequanAttr(node, key)); value != "" { + return value + } + } + return "" +} + +func meijiequanAttr(node *nethtml.Node, key string) string { + if node == nil { + return "" + } + for _, attr := range node.Attr { + if strings.EqualFold(strings.TrimSpace(attr.Key), key) { + return attr.Val + } + } + return "" +} + +func meijiequanNodeText(node *nethtml.Node) string { + if node == nil { + return "" + } + if node.Type == nethtml.TextNode { + return node.Data + } + var builder strings.Builder + for child := node.FirstChild; child != nil; child = child.NextSibling { + if builder.Len() > 0 { + builder.WriteByte(' ') + } + builder.WriteString(meijiequanNodeText(child)) + } + return builder.String() +} + +func normalizeWhitespace(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} + +func collapseMeijiequanCJKSpaces(value string) string { + replacer := strings.NewReplacer( + "退稿 原因", "退稿原因", + "订单 号", "订单号", + "资源 名称", "资源名称", + "发稿 时间", "发稿时间", + "创建 时间", "创建时间", + ) + value = replacer.Replace(value) + runes := []rune(value) + var builder strings.Builder + for idx, r := range runes { + if r == ' ' && idx > 0 && idx+1 < len(runes) && isMeijiequanCJKRune(runes[idx-1]) && isMeijiequanCJKRune(runes[idx+1]) { + continue + } + builder.WriteRune(r) + } + return builder.String() +} + +func isMeijiequanCJKRune(r rune) bool { + return (r >= '\u4e00' && r <= '\u9fff') || + (r >= '\u3400' && r <= '\u4dbf') || + (r >= '\uf900' && r <= '\ufaff') +} + +func looksLikeMeijiequanPublishedRow(rowText string) bool { + rowText = strings.TrimSpace(rowText) + if rowText == "" { + return false + } + if meijiequanLooksLikeArticleHeader(strings.Fields(rowText)) { + return false + } + if extractMeijiequanOrderCodeFromText(rowText) != "" { + return true + } + return strings.Contains(rowText, "已发") || + strings.Contains(rowText, "已发布") || + strings.Contains(rowText, "已发表") || + strings.Contains(rowText, "退稿") || + strings.Contains(rowText, "待安排") || + strings.Contains(rowText, "待处理") || + strings.Contains(rowText, "发布中") || + strings.Contains(rowText, "待发布") || + strings.Contains(rowText, "查看链接") || + strings.Contains(rowText, "回链") || + strings.Contains(rowText, "链接") || + strings.Contains(strings.ToLower(rowText), "http") +} + +func isLikelyPublishedArticleURL(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return false + } + if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") && !strings.HasPrefix(value, "//") { + return false + } + return !strings.Contains(value, "/advSupply/") && + !strings.Contains(value, "/supply/") && + !strings.Contains(value, "/api/") && + !strings.Contains(value, "meitidaren.top") && + !strings.Contains(value, "meijiequan.com") +} + +func normalizeMeijiequanArticleURL(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if strings.HasPrefix(value, "//") { + return "https:" + value + } + if !strings.HasPrefix(strings.ToLower(value), "http://") && !strings.HasPrefix(strings.ToLower(value), "https://") { + return "" + } + return value +} + +func normalizeMeijiequanPublishedResourceID(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if idx := strings.Index(value, "_"); idx > 0 { + value = value[:idx] + } + return value +} + +func extractMeijiequanResourceIDFromText(value string) string { + for _, token := range strings.Fields(value) { + token = strings.Trim(token, " ,,;;[]()()") + if strings.Contains(token, "_price") || strings.Contains(token, "_priceyc") { + return normalizeMeijiequanPublishedResourceID(token) + } + } + return "" +} + +func extractMeijiequanOrderCodeFromText(value string) string { + fields := strings.Fields(value) + for _, field := range fields { + token := strings.Trim(field, " ::,,;;[]()()") + if isLikelyMeijiequanOrderCode(token) { + return token + } + } + for idx, field := range fields { + if strings.Contains(field, "单号") || strings.Contains(strings.ToLower(field), "order") { + if idx+1 < len(fields) { + return strings.Trim(fields[idx+1], " ::,,;;") + } + } + } + return "" +} + +func extractMeijiequanOrderCodeFromTexts(texts []string) string { + for _, text := range texts { + trimmed := strings.TrimSpace(text) + if isLikelyMeijiequanOrderCode(trimmed) { + return trimmed + } + } + for _, text := range texts { + if code := extractMeijiequanOrderCodeFromText(text); code != "" { + return code + } + } + return "" +} + +func isLikelyMeijiequanOrderCode(value string) bool { + value = strings.ToUpper(strings.TrimSpace(value)) + if len(value) < 4 || len(value) > 64 { + return false + } + if strings.Contains(value, "HTTP") || strings.Contains(value, "://") { + return false + } + hasDigit := false + hasLetter := false + for _, r := range value { + switch { + case r >= '0' && r <= '9': + hasDigit = true + case r >= 'A' && r <= 'Z': + hasLetter = true + default: + return false + } + } + return hasDigit && hasLetter +} + +func extractMeijiequanResourceNameFromTexts(texts []string) string { + for _, text := range texts { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + continue + } + if isMeijiequanOperationText(trimmed) || extractMeijiequanPublishedStatus(trimmed) != "" || strings.Contains(trimmed, "退稿原因") { + continue + } + if strings.Contains(trimmed, "媒体") || + strings.Contains(trimmed, "网") || + strings.Contains(trimmed, "报") || + strings.Contains(trimmed, "园") || + strings.Contains(trimmed, "号") { + return trimmed + } + } + return "" +} + +func extractMeijiequanPublishedStatus(value string) string { + for _, status := range []string{"退稿申请中", "已退稿", "退稿中", "已发表", "已发布", "已发", "发布成功", "待安排", "待处理", "发布中", "待发布"} { + if strings.Contains(value, status) { + return status + } + } + return "" +} + +func extractMeijiequanArticleTitleFromTexts(texts []string, resourceName string) string { + for _, text := range texts { + trimmed := strings.TrimSpace(text) + if !isMeijiequanArticleTitleCandidate(trimmed, resourceName) { + continue + } + return trimmed + } + return "" +} + +func isMeijiequanArticleTitleCandidate(value, resourceName string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + if strings.EqualFold(value, strings.TrimSpace(resourceName)) { + return false + } + if isLikelyMeijiequanOrderCode(value) || extractMeijiequanPublishedStatus(value) != "" { + return false + } + if isMeijiequanOperationText(value) { + return false + } + if isMeijiequanGenericPublishedLinkText(value) || strings.Contains(value, "订单号") || strings.Contains(value, "资源名称") { + return false + } + if _, err := strconv.ParseFloat(strings.Trim(value, "¥元 "), 64); err == nil { + return false + } + if parseMeijiequanTime(value) != nil { + return false + } + return true +} + +func isMeijiequanOperationText(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + return strings.Contains(value, "再次发稿") || + strings.Contains(value, "申请退稿") || + strings.Contains(value, "改稿") || + strings.Contains(value, "标签") || + strings.Contains(value, "一键复制") +} + +func isMeijiequanGenericPublishedLinkText(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return true + } + switch value { + case "查看", "查看链接", "链接", "回链", "打开", "访问", "预览": + return true + default: + return false + } +} + +func firstSearchOptionsObject(value any) (any, bool) { + record, ok := value.(map[string]any) + if !ok { + return nil, false + } + for _, key := range []string{"data", "result", "list"} { + if nested, exists := record[key]; exists { + if _, ok := nested.(map[string]any); ok { + return nested, true + } + } + } + return nil, false +} + +func parseMeijiequanSearchOptionGroup(key string, value any) (MediaSupplySearchOptionGroup, bool) { + record, ok := value.(map[string]any) + if !ok { + return MediaSupplySearchOptionGroup{}, false + } + name := strings.TrimSpace(meijiequanAnyString(firstMapValue(record, "name", "label", "title"))) + if name == "" { + name = strings.TrimSpace(key) + } + values := meijiequanStringList(firstMapValue(record, "list", "options", "values")) + if len(values) == 0 { + return MediaSupplySearchOptionGroup{}, false + } + return MediaSupplySearchOptionGroup{Key: key, Name: name, List: values}, true +} + +func meijiequanStringList(value any) []string { + var raw []any + switch typed := value.(type) { + case []any: + raw = typed + case []string: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if text := strings.TrimSpace(item); text != "" { + out = append(out, text) + } + } + return out + default: + text := strings.TrimSpace(meijiequanAnyString(value)) + if text == "" { + return nil + } + parts := strings.Split(text, ",") + raw = make([]any, 0, len(parts)) + for _, part := range parts { + raw = append(raw, part) + } + } + out := make([]string, 0, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for _, item := range raw { + text := strings.TrimSpace(meijiequanAnyString(item)) + if text == "" { + continue + } + if _, exists := seen[text]; exists { + continue + } + seen[text] = struct{}{} + out = append(out, text) + } + return out +} + +func parseMeijiequanHiddenInputs(r io.Reader) map[string]string { + out := map[string]string{} + tokenizer := nethtml.NewTokenizer(r) + for { + tokenType := tokenizer.Next() + switch tokenType { + case nethtml.ErrorToken: + return out + case nethtml.StartTagToken, nethtml.SelfClosingTagToken: + token := tokenizer.Token() + if !strings.EqualFold(token.Data, "input") { + continue + } + attrs := map[string]string{} + for _, attr := range token.Attr { + attrs[strings.ToLower(strings.TrimSpace(attr.Key))] = attr.Val + } + if !strings.EqualFold(strings.TrimSpace(attrs["type"]), "hidden") { + continue + } + name := strings.TrimSpace(attrs["name"]) + if name == "" { + name = strings.TrimSpace(attrs["id"]) + } + if name != "" { + out[name] = attrs["value"] + } + } + } +} + +func extractMeijiequanUserID(resp *meijiequanLoginResponse) string { + if resp == nil { + return "" + } + if resp.UserID > 0 { + return strconv.FormatInt(resp.UserID, 10) + } + if value := extractMeijiequanUserIDFromJSON(resp.Data); value != "" { + return value + } + if value := extractMeijiequanUserIDFromJSON(resp.Raw); value != "" { + return value + } + return "" +} + +func extractMeijiequanUserIDFromJSON(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return "" + } + return extractMeijiequanUserIDFromValue(value) +} + +func extractMeijiequanUserIDFromValue(value any) string { + switch typed := value.(type) { + case map[string]any: + for _, key := range []string{"user_id", "uid", "id", "uk"} { + if out := strings.TrimSpace(meijiequanAnyString(typed[key])); out != "" && out != "0" { + return out + } + } + for _, key := range []string{"user", "account", "member", "data"} { + if out := extractMeijiequanUserIDFromValue(typed[key]); out != "" { + return out + } + } + case []any: + for _, item := range typed { + if out := extractMeijiequanUserIDFromValue(item); out != "" { + return out + } + } + } + return "" +} + +func isMeijiequanChallengeMessage(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return false + } + return strings.Contains(value, "验证码") || + strings.Contains(value, "captcha") || + strings.Contains(value, "verification code") || + strings.Contains(value, "code error") +} + +func maskMeijiequanUsername(username string) string { + username = strings.TrimSpace(username) + switch n := len([]rune(username)); { + case n == 0: + return "" + case n <= 4: + return strings.Repeat("*", n) + default: + runes := []rune(username) + return string(runes[:3]) + strings.Repeat("*", n-5) + string(runes[n-2:]) + } +} + +func parseMeijiequanMediaItem(modelID int, item map[string]any) (UpsertSupplierMediaResourceInput, bool) { + resourceID := strings.TrimSpace(meijiequanAnyString(item["id"])) + name := strings.TrimSpace(meijiequanAnyString(item["name"])) + if resourceID == "" || name == "" { + return UpsertSupplierMediaResourceInput{}, false + } + raw, _ := json.Marshal(item) + costPrices := extractMeijiequanPrices(item) + costPrice := costPrices["price"] + if costPrice <= 0 { + costPrice = costPrices["sale_price"] + } + if costPrice <= 0 { + costPrice = firstPositivePrice(costPrices) + } + updatedAt := parseMeijiequanTime(meijiequanAnyString(item["updated_at"])) + weight := parseOptionalMeijiequanInt(firstMapValue(item, "meitiquanzhong", "baidu_weight", "weight", "quanzhong")) + return UpsertSupplierMediaResourceInput{ + Supplier: mediaSupplySupplierMeijiequan, + SupplierResourceID: resourceID, + ModelID: modelID, + Name: name, + Status: meijiequanAnyString(item["status"]), + CostPriceCents: costPrice, + CostPrices: costPrices, + SalePriceLabel: meijiequanAnyString(item["sale_price"]), + ResourceURL: meijiequanAnyString(firstMapValue(item, "meitianli", "resource_url", "url", "site_url", "link")), + BaiduWeight: weight, + ResourceRemark: meijiequanAnyString(firstMapValue(item, "meitibeizhu", "remark", "beizhu", "note", "description")), + ChannelType: meijiequanAnyString(firstMapValue(item, "pindaoleixing", "channel_type")), + Region: meijiequanAnyString(firstMapValue(item, "quyu", "region")), + InclusionEffect: meijiequanAnyString(firstMapValue(item, "shouluxiaoguo", "inclusion_effect")), + LinkType: meijiequanAnyString(firstMapValue(item, "lianjieleixing", "link_type")), + PublishRate: meijiequanAnyString(item["publish_rate"]), + DeliverySpeed: meijiequanAnyString(firstMapValue(item, "chugaosudu", "avg_delivery_speed")), + SupplierUpdatedAt: updatedAt, + Raw: raw, + }, true +} + +func parseOptionalMeijiequanInt(value any) *int { + text := strings.TrimSpace(meijiequanAnyString(value)) + if text == "" { + return nil + } + parsed, err := strconv.Atoi(text) + if err != nil { + return nil + } + return &parsed +} + +func extractMeijiequanPrices(item map[string]any) map[string]int64 { + priceKeys := []string{"sale_price", "price", "price_top", "price_union", "pricecar", "priceyc", "priceyccar", "pricegoods"} + out := make(map[string]int64, len(priceKeys)) + for _, key := range priceKeys { + if cents, ok := parsePriceCents(item[key]); ok { + out[key] = cents + } + } + if sale, ok := out["sale_price"]; ok { + out["price"] = sale + } + return out +} + +func parsePriceCents(value any) (int64, bool) { + switch typed := value.(type) { + case nil: + return 0, false + case float64: + if typed <= 0 { + return 0, false + } + return int64(typed*100 + 0.5), true + case json.Number: + f, err := typed.Float64() + if err != nil || f <= 0 { + return 0, false + } + return int64(f*100 + 0.5), true + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" || trimmed == "--" { + return 0, false + } + trimmed = strings.TrimPrefix(trimmed, "¥") + trimmed = strings.TrimPrefix(trimmed, "¥") + trimmed = strings.TrimSpace(strings.ReplaceAll(trimmed, ",", "")) + f, err := strconv.ParseFloat(trimmed, 64) + if err != nil || f <= 0 { + return 0, false + } + return int64(f*100 + 0.5), true + default: + return parsePriceCents(fmt.Sprint(typed)) + } +} + +func meijiequanAnyString(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(typed) + case float64: + if typed == float64(int64(typed)) { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', -1, 64) + case json.Number: + return typed.String() + default: + return strings.TrimSpace(fmt.Sprint(typed)) + } +} + +func meijiequanFlexibleInt64(value any) int64 { + switch typed := value.(type) { + case nil: + return 0 + case int: + return int64(typed) + case int64: + return typed + case float64: + return int64(typed) + case json.Number: + parsed, err := typed.Int64() + if err == nil { + return parsed + } + f, err := typed.Float64() + if err != nil { + return 0 + } + return int64(f) + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return 0 + } + parsed, err := strconv.ParseInt(trimmed, 10, 64) + if err == nil { + return parsed + } + f, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0 + } + return int64(f) + default: + return meijiequanFlexibleInt64(fmt.Sprint(typed)) + } +} + +func firstMapValue(item map[string]any, keys ...string) any { + for _, key := range keys { + if value, ok := item[key]; ok && strings.TrimSpace(meijiequanAnyString(value)) != "" { + return value + } + } + return nil +} + +func firstPositivePrice(prices map[string]int64) int64 { + for _, value := range prices { + if value > 0 { + return value + } + } + return 0 +} + +func parseMeijiequanTime(value string) *time.Time { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + layouts := []string{ + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil { + utc := parsed.UTC() + return &utc + } + } + return nil +} + +func mimeExt(contentType string) string { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return "" + } + extensions, err := mime.ExtensionsByType(mediaType) + if err != nil || len(extensions) == 0 { + return "" + } + return extensions[0] +} + +func compactJSON(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage(`{}`) + } + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + return raw + } + return buf.Bytes() +} diff --git a/server/internal/tenant/app/publish_job_service.go b/server/internal/tenant/app/publish_job_service.go index 8cfaa2f..f2b75a0 100644 --- a/server/internal/tenant/app/publish_job_service.go +++ b/server/internal/tenant/app/publish_job_service.go @@ -89,6 +89,7 @@ type CreatePublishJobResponse struct { TaskIDs []string `json:"task_ids"` CreatedTaskIDs []string `json:"created_task_ids"` ExistingTaskIDs []string `json:"existing_task_ids"` + RequeuedTaskIDs []string `json:"requeued_task_ids,omitempty"` } type publishTarget struct { @@ -668,6 +669,9 @@ func (s *PublishJobService) RetryByDesktopTask( if task.Status == "queued" || task.Status == "in_progress" { return nil, response.ErrConflict(40994, "desktop_task_retry_conflict", "desktop task is still pending or running") } + if shouldRequeuePublishTaskInsteadOfCreatingNew(task) { + return s.requeueUnknownPublishTask(ctx, task) + } payload := unmarshalJSONObject(task.Payload) articleID, err := s.resolveRetryArticleID(ctx, client.TenantID, payload) @@ -730,6 +734,9 @@ func (s *PublishJobService) RetryTenantDesktopTask( if task.Status == "queued" || task.Status == "in_progress" { return nil, response.ErrConflict(40994, "desktop_task_retry_conflict", "desktop task is still pending or running") } + if shouldRequeuePublishTaskInsteadOfCreatingNew(task) { + return s.requeueUnknownPublishTask(ctx, task) + } payload := unmarshalJSONObject(task.Payload) articleID, err := s.resolveRetryArticleID(ctx, actor.TenantID, payload) @@ -758,6 +765,59 @@ func (s *PublishJobService) RetryTenantDesktopTask( }) } +func shouldRequeuePublishTaskInsteadOfCreatingNew(task *repository.DesktopTask) bool { + return task != nil && task.Kind == "publish" && task.Status == "unknown" +} + +func (s *PublishJobService) requeueUnknownPublishTask( + ctx context.Context, + task *repository.DesktopTask, +) (*CreatePublishJobResponse, error) { + if task == nil { + return nil, response.ErrNotFound(40482, "desktop_task_not_found", "desktop task not found") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, response.ErrInternal(50105, "desktop_task_reconcile_begin_failed", "failed to start desktop task reconcile") + } + defer tx.Rollback(ctx) + + taskRepo := repository.NewDesktopTaskRepository(tx) + requeued, err := taskRepo.Reconcile(ctx, task.DesktopID, task.WorkspaceID, "retry", nil, nil) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrConflict(40985, "desktop_task_reconcile_conflict", "desktop task is not waiting for reconcile") + } + return nil, response.ErrInternal(50094, "desktop_task_reconcile_failed", "failed to reconcile desktop task") + } + + if err := tx.Commit(ctx); err != nil { + return nil, response.ErrInternal(50106, "desktop_task_reconcile_commit_failed", "failed to commit desktop task reconcile") + } + + publishDesktopTaskLifecycleEvent(ctx, s.messaging, s.logger, requeued, "task_available") + return createPublishJobResponseForRequeuedTask(requeued), nil +} + +func createPublishJobResponseForRequeuedTask(task *repository.DesktopTask) *CreatePublishJobResponse { + if task == nil { + return &CreatePublishJobResponse{ + TaskIDs: []string{}, + CreatedTaskIDs: []string{}, + ExistingTaskIDs: []string{}, + RequeuedTaskIDs: []string{}, + } + } + taskID := task.DesktopID.String() + return &CreatePublishJobResponse{ + JobID: "", + TaskIDs: []string{taskID}, + CreatedTaskIDs: []string{}, + ExistingTaskIDs: []string{}, + RequeuedTaskIDs: []string{taskID}, + } +} + func (s *PublishJobService) authorizePublishTaskForClientUser( ctx context.Context, client *repository.DesktopClient, diff --git a/server/internal/tenant/app/publish_job_service_test.go b/server/internal/tenant/app/publish_job_service_test.go index e1cb47b..9d54072 100644 --- a/server/internal/tenant/app/publish_job_service_test.go +++ b/server/internal/tenant/app/publish_job_service_test.go @@ -155,6 +155,54 @@ func TestPublishTargetPlatformsSortsAndDedupes(t *testing.T) { } } +func TestShouldRequeuePublishTaskInsteadOfCreatingNewOnlyForUnknownPublish(t *testing.T) { + t.Parallel() + + if !shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{ + Kind: "publish", + Status: "unknown", + }) { + t.Fatal("unknown publish tasks should be retried by requeueing the existing task") + } + if shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{ + Kind: "publish", + Status: "failed", + }) { + t.Fatal("failed publish tasks should keep create-new retry semantics") + } + if shouldRequeuePublishTaskInsteadOfCreatingNew(&repository.DesktopTask{ + Kind: "monitor", + Status: "unknown", + }) { + t.Fatal("monitor retry semantics are owned by desktop task reconcile") + } + if shouldRequeuePublishTaskInsteadOfCreatingNew(nil) { + t.Fatal("nil tasks should not be requeued") + } +} + +func TestCreatePublishJobResponseForRequeuedTaskReportsRequeuedTaskID(t *testing.T) { + t.Parallel() + + taskID := uuid.MustParse("55555555-5555-5555-5555-555555555555") + got := createPublishJobResponseForRequeuedTask(&repository.DesktopTask{DesktopID: taskID}) + if got.JobID != "" { + t.Fatalf("JobID = %q, want empty for requeued task", got.JobID) + } + if len(got.TaskIDs) != 1 || got.TaskIDs[0] != taskID.String() { + t.Fatalf("TaskIDs = %v, want [%s]", got.TaskIDs, taskID) + } + if len(got.CreatedTaskIDs) != 0 { + t.Fatalf("CreatedTaskIDs = %v, want empty", got.CreatedTaskIDs) + } + if len(got.ExistingTaskIDs) != 0 { + t.Fatalf("ExistingTaskIDs = %v, want empty", got.ExistingTaskIDs) + } + if len(got.RequeuedTaskIDs) != 1 || got.RequeuedTaskIDs[0] != taskID.String() { + t.Fatalf("RequeuedTaskIDs = %v, want [%s]", got.RequeuedTaskIDs, taskID) + } +} + func TestLoadExistingPublishRecordTargetsDedupeUnknownTasks(t *testing.T) { t.Parallel() diff --git a/server/internal/tenant/transport/media_supply_handler.go b/server/internal/tenant/transport/media_supply_handler.go new file mode 100644 index 0000000..05ed7a8 --- /dev/null +++ b/server/internal/tenant/transport/media_supply_handler.go @@ -0,0 +1,312 @@ +package transport + +import ( + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/app" +) + +type MediaSupplyHandler struct { + svc *app.MediaSupplyService +} + +func NewMediaSupplyHandler(a *bootstrap.App) *MediaSupplyHandler { + svc := a.MediaSupplyService + if svc == nil { + svc = app.NewMediaSupplyService(a.DB, a.Redis, a.Logger, a.ConfigStore) + } + return &MediaSupplyHandler{ + svc: svc, + } +} + +func (h *MediaSupplyHandler) ListResources(c *gin.Context) { + modelID, err := parseOptionalIntQuery(c, "model_id") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字")) + return + } + page, _ := parseOptionalIntQuery(c, "page") + pageSize, _ := parseOptionalIntQuery(c, "page_size") + minPriceCents, err := parseOptionalInt64Query(c, "min_price_cents") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_min_price_cents", "最低价格参数必须是数字")) + return + } + maxPriceCents, err := parseOptionalInt64Query(c, "max_price_cents") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_max_price_cents", "最高价格参数必须是数字")) + return + } + data, err := h.svc.ListCustomerResources(c.Request.Context(), app.ListSupplierMediaResourcesRequest{ + ModelID: modelID, + Keyword: c.Query("keyword"), + RemarkKeyword: c.Query("remark_keyword"), + ChannelType: c.Query("channel_type"), + Portal: c.Query("portal"), + Region: c.Query("region"), + InclusionEffect: c.Query("inclusion_effect"), + SpecialIndustry: c.Query("special_industry"), + LinkType: c.Query("link_type"), + DeliverySpeed: c.Query("delivery_speed"), + AIInclude: c.Query("ai_include"), + OtherOption: c.Query("other_option"), + MinPriceCents: minPriceCents, + MaxPriceCents: maxPriceCents, + Sort: c.Query("sort"), + Page: page, + PageSize: pageSize, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *MediaSupplyHandler) ListResourcesByIDs(c *gin.Context) { + ids, err := parseInt64ListQuery(c, "ids") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_resource_ids", "媒体资源参数必须是数字")) + return + } + modelID, err := parseOptionalIntQuery(c, "model_id") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字")) + return + } + data, err := h.svc.ListCustomerResourcesByIDs(c.Request.Context(), ids, modelID) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *MediaSupplyHandler) QueueSync(c *gin.Context) { + response.Error(c, response.ErrNotFound(40400, "not_found", "接口不存在")) +} + +func (h *MediaSupplyHandler) SearchOptions(c *gin.Context) { + modelID, err := parseOptionalIntQuery(c, "model_id") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字")) + return + } + data, err := h.svc.SearchOptions(c.Request.Context(), modelID) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *MediaSupplyHandler) SetResourcePrice(c *gin.Context) { + response.Error(c, response.ErrNotFound(40400, "not_found", "接口不存在")) +} + +func (h *MediaSupplyHandler) CreateOrder(c *gin.Context) { + var req app.CreateMediaSupplyOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", "投稿参数不正确")) + return + } + data, err := h.svc.CreateOrder(c.Request.Context(), req) + if err != nil { + response.Error(c, err) + return + } + response.SuccessWithStatus(c, 201, data) +} + +func (h *MediaSupplyHandler) WalletStatus(c *gin.Context) { + data, err := h.svc.WalletStatus(c.Request.Context()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *MediaSupplyHandler) ListWalletLedgers(c *gin.Context) { + page, _ := parseOptionalIntQuery(c, "page") + pageSize, _ := parseOptionalIntQuery(c, "page_size") + userID, err := parseOptionalInt64Query(c, "user_id") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_user_id", "用户参数必须是数字")) + return + } + createdFrom, err := parseOptionalTimeQuery(c, "created_from") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_created_from", "开始时间格式不正确")) + return + } + createdTo, err := parseOptionalTimeQuery(c, "created_to") + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_created_to", "结束时间格式不正确")) + return + } + data, err := h.svc.ListWalletLedgers(c.Request.Context(), app.ListMediaSupplyWalletLedgersRequest{ + Page: page, + PageSize: pageSize, + UserID: userID, + CreatedFrom: createdFrom, + CreatedTo: createdTo, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *MediaSupplyHandler) AdjustWallet(c *gin.Context) { + response.Error(c, response.ErrNotFound(40400, "not_found", "接口不存在")) +} + +func (h *MediaSupplyHandler) ListOrders(c *gin.Context) { + page, _ := parseOptionalIntQuery(c, "page") + pageSize, _ := parseOptionalIntQuery(c, "page_size") + var articleID *int64 + if raw := c.Query("article_id"); raw != "" { + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil || parsed <= 0 { + response.Error(c, response.ErrBadRequest(40001, "invalid_article_id", "文章参数必须是数字")) + return + } + articleID = &parsed + } + data, err := h.svc.ListOrders(c.Request.Context(), app.ListMediaSupplyOrdersRequest{ + Status: c.Query("status"), + ArticleID: articleID, + Page: page, + PageSize: pageSize, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *MediaSupplyHandler) GetOrder(c *gin.Context) { + orderID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || orderID <= 0 { + response.Error(c, response.ErrBadRequest(40001, "invalid_id", "订单参数必须是数字")) + return + } + data, err := h.svc.GetOrder(c.Request.Context(), orderID) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *MediaSupplyHandler) ImportSession(c *gin.Context) { + var req app.ImportMeijiequanSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", "登录状态参数不正确")) + return + } + data, err := h.svc.ImportSession(c.Request.Context(), req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *MediaSupplyHandler) LoginConfiguredAccount(c *gin.Context) { + data, err := h.svc.LoginConfiguredAccount(c.Request.Context()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *MediaSupplyHandler) SessionStatus(c *gin.Context) { + data, err := h.svc.SessionStatus(c.Request.Context()) + if err != nil { + response.Error(c, err) + return + } + 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) { + raw := c.Query(key) + if raw == "" { + return 0, nil + } + value, err := strconv.Atoi(raw) + if err != nil { + return 0, err + } + return value, nil +} + +func parseOptionalInt64Query(c *gin.Context, key string) (int64, error) { + raw := c.Query(key) + if raw == "" { + return 0, nil + } + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, err + } + return value, nil +} + +func parseOptionalTimeQuery(c *gin.Context, key string) (*time.Time, error) { + raw := c.Query(key) + if raw == "" { + return nil, nil + } + value, err := time.Parse(time.RFC3339, raw) + if err != nil { + return nil, err + } + return &value, nil +} + +func parseInt64ListQuery(c *gin.Context, key string) ([]int64, error) { + rawValues := c.QueryArray(key) + if len(rawValues) == 0 { + rawValues = []string{c.Query(key)} + } + values := make([]int64, 0, len(rawValues)) + for _, raw := range rawValues { + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + value, err := strconv.ParseInt(part, 10, 64) + if err != nil || value <= 0 { + return nil, strconv.ErrSyntax + } + values = append(values, value) + if len(values) >= 100 { + return values, nil + } + } + } + return values, nil +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index ddb2ecc..cd92e65 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -97,6 +97,24 @@ func RegisterRoutes(app *bootstrap.App) { media := tenantProtected.Group("/media") media.GET("/platforms", mediaHandler.ListPlatforms) + mediaSupplyHandler := NewMediaSupplyHandler(app) + mediaSupply := tenantProtected.Group("/media-supply") + mediaSupply.GET("/resources", mediaSupplyHandler.ListResources) + mediaSupply.GET("/resources/by-ids", mediaSupplyHandler.ListResourcesByIDs) + mediaSupply.GET("/resources/search-options", mediaSupplyHandler.SearchOptions) + mediaSupply.POST("/sync-jobs", mediaSupplyHandler.QueueSync) + mediaSupply.PUT("/resources/:id/price", mediaSupplyHandler.SetResourcePrice) + mediaSupply.POST("/orders", middleware.RequireCurrentBrand(), mediaSupplyHandler.CreateOrder) + mediaSupply.GET("/orders", mediaSupplyHandler.ListOrders) + mediaSupply.POST("/orders/sync-backlinks", mediaSupplyHandler.SyncBacklinks) + mediaSupply.GET("/orders/:id", mediaSupplyHandler.GetOrder) + mediaSupply.GET("/wallet", mediaSupplyHandler.WalletStatus) + mediaSupply.GET("/wallet/ledgers", mediaSupplyHandler.ListWalletLedgers) + mediaSupply.POST("/wallet/adjustments", mediaSupplyHandler.AdjustWallet) + mediaSupply.GET("/supplier-session", mediaSupplyHandler.SessionStatus) + mediaSupply.PUT("/supplier-session", mediaSupplyHandler.ImportSession) + mediaSupply.POST("/supplier-session/login", mediaSupplyHandler.LoginConfiguredAccount) + workspace := tenantProtected.Group("/workspace") wsHandler := NewWorkspaceHandler(app) workspace.GET("/overview", middleware.RequireCurrentBrand(), wsHandler.Overview) diff --git a/server/migrations/20260528110000_create_media_supply_tables.down.sql b/server/migrations/20260528110000_create_media_supply_tables.down.sql new file mode 100644 index 0000000..bb50f76 --- /dev/null +++ b/server/migrations/20260528110000_create_media_supply_tables.down.sql @@ -0,0 +1,7 @@ +DROP TABLE IF EXISTS media_supply_sync_jobs; +DROP TABLE IF EXISTS media_supply_wallet_ledgers; +DROP TABLE IF EXISTS media_supply_order_items; +DROP TABLE IF EXISTS media_supply_orders; +DROP TABLE IF EXISTS media_supply_user_wallets; +DROP TABLE IF EXISTS supplier_media_price_overrides; +DROP TABLE IF EXISTS supplier_media_resources; diff --git a/server/migrations/20260528110000_create_media_supply_tables.up.sql b/server/migrations/20260528110000_create_media_supply_tables.up.sql new file mode 100644 index 0000000..82b1f1a --- /dev/null +++ b/server/migrations/20260528110000_create_media_supply_tables.up.sql @@ -0,0 +1,173 @@ +CREATE TABLE IF NOT EXISTS supplier_media_resources ( + id BIGSERIAL PRIMARY KEY, + supplier VARCHAR(32) NOT NULL, + supplier_resource_id VARCHAR(64) NOT NULL, + model_id INT NOT NULL, + name VARCHAR(500) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'unknown', + cost_price_cents BIGINT NOT NULL DEFAULT 0, + cost_prices_json JSONB NOT NULL DEFAULT '{}'::jsonb, + sale_price_label VARCHAR(64), + resource_url TEXT, + baidu_weight INT, + resource_remark TEXT, + customer_visible BOOLEAN NOT NULL DEFAULT TRUE, + channel_type VARCHAR(128), + region VARCHAR(128), + inclusion_effect VARCHAR(128), + link_type VARCHAR(128), + publish_rate VARCHAR(64), + delivery_speed VARCHAR(128), + supplier_updated_at TIMESTAMPTZ, + last_synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uk_supplier_media_resources_supplier_id UNIQUE (supplier, supplier_resource_id) +); + +CREATE INDEX IF NOT EXISTS idx_supplier_media_resources_model_status + ON supplier_media_resources (supplier, model_id, status, updated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_supplier_media_resources_name_trgm_fallback + ON supplier_media_resources (supplier, name); + +CREATE TABLE IF NOT EXISTS supplier_media_price_overrides ( + id BIGSERIAL PRIMARY KEY, + resource_id BIGINT NOT NULL REFERENCES supplier_media_resources(id) ON DELETE CASCADE, + price_type VARCHAR(64) NOT NULL DEFAULT 'price', + sell_price_cents BIGINT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + updated_by BIGINT REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_supplier_media_price_override_non_negative CHECK (sell_price_cents >= 0), + CONSTRAINT uk_supplier_media_price_overrides_resource_type UNIQUE (resource_id, price_type) +); + +CREATE INDEX IF NOT EXISTS idx_supplier_media_price_overrides_resource + ON supplier_media_price_overrides (resource_id, enabled); + +CREATE TABLE IF NOT EXISTS media_supply_user_wallets ( + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + user_id BIGINT NOT NULL REFERENCES users(id), + balance_cents BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tenant_id, user_id), + CONSTRAINT chk_media_supply_user_wallet_balance_non_negative CHECK (balance_cents >= 0) +); + +CREATE TABLE IF NOT EXISTS media_supply_orders ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + workspace_id BIGINT NOT NULL REFERENCES workspaces(id), + brand_id BIGINT NOT NULL REFERENCES brands(id), + user_id BIGINT NOT NULL REFERENCES users(id), + article_id BIGINT REFERENCES articles(id), + supplier VARCHAR(32) NOT NULL, + model_id INT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'queued', + title VARCHAR(500) NOT NULL, + content_snapshot TEXT NOT NULL, + remark TEXT, + order_brand VARCHAR(255), + external_order_id VARCHAR(128), + external_order_code VARCHAR(128), + supplier_status VARCHAR(64), + cost_total_cents BIGINT NOT NULL DEFAULT 0, + sell_total_cents BIGINT NOT NULL DEFAULT 0, + wallet_debit_cents BIGINT NOT NULL DEFAULT 0, + wallet_debited_at TIMESTAMPTZ, + wallet_refunded_at TIMESTAMPTZ, + request_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + response_payload_json JSONB, + error_message TEXT, + attempt_count INT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + submitted_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_media_supply_order_totals_non_negative CHECK (cost_total_cents >= 0 AND sell_total_cents >= 0), + CONSTRAINT chk_media_supply_order_no_loss CHECK (sell_total_cents >= cost_total_cents), + CONSTRAINT chk_media_supply_order_wallet_debit_non_negative CHECK (wallet_debit_cents >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_media_supply_orders_user_created + ON media_supply_orders (tenant_id, user_id, created_at DESC) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_media_supply_orders_status_next + ON media_supply_orders (supplier, status, next_attempt_at, created_at) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_media_supply_orders_article + ON media_supply_orders (tenant_id, article_id, created_at DESC) + WHERE article_id IS NOT NULL AND deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS media_supply_order_items ( + id BIGSERIAL PRIMARY KEY, + order_id BIGINT NOT NULL REFERENCES media_supply_orders(id) ON DELETE CASCADE, + resource_id BIGINT NOT NULL REFERENCES supplier_media_resources(id), + supplier_resource_id VARCHAR(64) NOT NULL, + price_type VARCHAR(64) NOT NULL DEFAULT 'price', + resource_name_snapshot VARCHAR(500) NOT NULL, + locked_cost_price_cents BIGINT NOT NULL, + locked_sell_price_cents BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'queued', + external_article_url TEXT, + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_media_supply_order_item_prices_non_negative CHECK (locked_cost_price_cents >= 0 AND locked_sell_price_cents >= 0), + CONSTRAINT chk_media_supply_order_item_no_loss CHECK (locked_sell_price_cents >= locked_cost_price_cents) +); + +CREATE INDEX IF NOT EXISTS idx_media_supply_order_items_order + ON media_supply_order_items (order_id, id); + +CREATE TABLE IF NOT EXISTS media_supply_wallet_ledgers ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + user_id BIGINT NOT NULL REFERENCES users(id), + order_id BIGINT REFERENCES media_supply_orders(id) ON DELETE SET NULL, + delta_cents BIGINT NOT NULL, + balance_after_cents BIGINT NOT NULL, + reason VARCHAR(128) NOT NULL, + note TEXT, + created_by BIGINT REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_media_supply_wallet_ledger_balance_non_negative CHECK (balance_after_cents >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_media_supply_wallet_ledgers_user_created + ON media_supply_wallet_ledgers (tenant_id, user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_media_supply_wallet_ledgers_user_recent + ON media_supply_wallet_ledgers (tenant_id, user_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_media_supply_wallet_ledgers_order + ON media_supply_wallet_ledgers (order_id) + WHERE order_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS media_supply_sync_jobs ( + id BIGSERIAL PRIMARY KEY, + supplier VARCHAR(32) NOT NULL, + model_id INT, + status VARCHAR(32) NOT NULL DEFAULT 'queued', + requested_by BIGINT REFERENCES users(id), + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + error_message TEXT, + attempt_count INT NOT NULL DEFAULT 0, + stats_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_media_supply_sync_jobs_status_created + ON media_supply_sync_jobs (supplier, status, created_at); diff --git a/server/migrations/20260529143000_add_media_supply_resource_detail_fields.down.sql b/server/migrations/20260529143000_add_media_supply_resource_detail_fields.down.sql new file mode 100644 index 0000000..8a3bcf3 --- /dev/null +++ b/server/migrations/20260529143000_add_media_supply_resource_detail_fields.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS idx_supplier_media_resources_remark; + +ALTER TABLE supplier_media_resources + DROP COLUMN IF EXISTS resource_remark, + DROP COLUMN IF EXISTS baidu_weight, + DROP COLUMN IF EXISTS resource_url; diff --git a/server/migrations/20260529143000_add_media_supply_resource_detail_fields.up.sql b/server/migrations/20260529143000_add_media_supply_resource_detail_fields.up.sql new file mode 100644 index 0000000..f78a122 --- /dev/null +++ b/server/migrations/20260529143000_add_media_supply_resource_detail_fields.up.sql @@ -0,0 +1,17 @@ +ALTER TABLE supplier_media_resources + ADD COLUMN IF NOT EXISTS resource_url TEXT, + ADD COLUMN IF NOT EXISTS baidu_weight INT, + ADD COLUMN IF NOT EXISTS resource_remark TEXT; + +UPDATE supplier_media_resources +SET resource_url = NULLIF(BTRIM(COALESCE(raw_json->>'meitianli', raw_json->>'resource_url', raw_json->>'url', raw_json->>'site_url', raw_json->>'link')), ''), + baidu_weight = CASE + WHEN BTRIM(COALESCE(raw_json->>'meitiquanzhong', raw_json->>'baidu_weight', raw_json->>'weight', raw_json->>'quanzhong')) ~ '^[0-9]+$' + THEN BTRIM(COALESCE(raw_json->>'meitiquanzhong', raw_json->>'baidu_weight', raw_json->>'weight', raw_json->>'quanzhong'))::INT + ELSE NULL + END, + resource_remark = NULLIF(BTRIM(COALESCE(raw_json->>'meitibeizhu', raw_json->>'remark', raw_json->>'beizhu', raw_json->>'note', raw_json->>'description')), '') +WHERE resource_url IS NULL OR baidu_weight IS NULL OR resource_remark IS NULL; + +CREATE INDEX IF NOT EXISTS idx_supplier_media_resources_remark + ON supplier_media_resources (supplier, resource_remark); diff --git a/server/migrations/20260529161000_add_media_supply_customer_visibility.down.sql b/server/migrations/20260529161000_add_media_supply_customer_visibility.down.sql new file mode 100644 index 0000000..2c17c8c --- /dev/null +++ b/server/migrations/20260529161000_add_media_supply_customer_visibility.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_supplier_media_resources_customer_visible; + +ALTER TABLE supplier_media_resources + DROP COLUMN IF EXISTS customer_visible; diff --git a/server/migrations/20260529161000_add_media_supply_customer_visibility.up.sql b/server/migrations/20260529161000_add_media_supply_customer_visibility.up.sql new file mode 100644 index 0000000..3a3a6e5 --- /dev/null +++ b/server/migrations/20260529161000_add_media_supply_customer_visibility.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE supplier_media_resources + ADD COLUMN IF NOT EXISTS customer_visible BOOLEAN NOT NULL DEFAULT TRUE; + +CREATE INDEX IF NOT EXISTS idx_supplier_media_resources_customer_visible + ON supplier_media_resources (supplier, model_id, customer_visible, updated_at DESC) + WHERE deleted_at IS NULL; diff --git a/server/migrations/20260529170000_refund_terminal_media_supply_orders.down.sql b/server/migrations/20260529170000_refund_terminal_media_supply_orders.down.sql new file mode 100644 index 0000000..0008b5b --- /dev/null +++ b/server/migrations/20260529170000_refund_terminal_media_supply_orders.down.sql @@ -0,0 +1 @@ +-- Irreversible data repair: refunded terminal media supply orders should not be re-debited. diff --git a/server/migrations/20260529170000_refund_terminal_media_supply_orders.up.sql b/server/migrations/20260529170000_refund_terminal_media_supply_orders.up.sql new file mode 100644 index 0000000..52d43a2 --- /dev/null +++ b/server/migrations/20260529170000_refund_terminal_media_supply_orders.up.sql @@ -0,0 +1,88 @@ +WITH terminal_orders AS ( + SELECT id, + tenant_id, + user_id, + wallet_debit_cents, + title, + error_message + FROM media_supply_orders + WHERE supplier = 'meijiequan' + AND status IN ('queued', 'submitting') + AND wallet_debited_at IS NOT NULL + AND wallet_refunded_at IS NULL + AND wallet_debit_cents > 0 + AND deleted_at IS NULL + AND ( + error_message ILIKE 'failed to refresh supplier price%' + OR error_message ILIKE 'meijiequan create order failed:%' + OR error_message ILIKE 'supplier cost increased above locked sell price%' + ) +), +wallet_snapshot AS ( + SELECT o.*, + w.balance_cents AS balance_before, + SUM(o.wallet_debit_cents) OVER ( + PARTITION BY o.tenant_id, o.user_id + ORDER BY o.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS running_refund + FROM terminal_orders o + JOIN media_supply_user_wallets w + ON w.tenant_id = o.tenant_id + AND w.user_id = o.user_id +), +refund_totals AS ( + SELECT tenant_id, + user_id, + SUM(wallet_debit_cents) AS total_refund + FROM terminal_orders + GROUP BY tenant_id, user_id +), +updated_wallets AS ( + UPDATE media_supply_user_wallets w + SET balance_cents = w.balance_cents + rt.total_refund, + updated_at = NOW() + FROM refund_totals rt + WHERE w.tenant_id = rt.tenant_id + AND w.user_id = rt.user_id + RETURNING w.tenant_id, w.user_id +), +updated_orders AS ( + UPDATE media_supply_orders o + SET status = 'failed', + wallet_refunded_at = NOW(), + next_attempt_at = NOW(), + updated_at = NOW() + FROM terminal_orders t + WHERE o.id = t.id + RETURNING o.id +), +updated_items AS ( + UPDATE media_supply_order_items i + SET status = 'failed', + updated_at = NOW() + FROM terminal_orders t + WHERE i.order_id = t.id + AND i.status IN ('queued', 'submitting') + RETURNING i.id +) +INSERT INTO media_supply_wallet_ledgers ( + tenant_id, + user_id, + order_id, + delta_cents, + balance_after_cents, + reason, + note, + created_by +) +SELECT ws.tenant_id, + ws.user_id, + ws.id, + ws.wallet_debit_cents, + ws.balance_before + ws.running_refund, + 'order_refund', + COALESCE(NULLIF(ws.error_message, ''), ws.title), + ws.user_id +FROM wallet_snapshot ws +JOIN updated_orders uo ON uo.id = ws.id;