Compare commits
3 Commits
85bb373a8b
...
723c3ffb86
| Author | SHA1 | Date | |
|---|---|---|---|
| 723c3ffb86 | |||
| 842782b3dd | |||
| ba2f117265 |
@@ -128,7 +128,6 @@ import type {
|
|||||||
ScheduleTaskListResponse,
|
ScheduleTaskListResponse,
|
||||||
ScheduleTaskRequest,
|
ScheduleTaskRequest,
|
||||||
ScheduleTaskStatusRequest,
|
ScheduleTaskStatusRequest,
|
||||||
SyncMediaSupplyBacklinksResponse,
|
|
||||||
TemplateAnalyzeTaskRequest,
|
TemplateAnalyzeTaskRequest,
|
||||||
TemplateAnalyzeTaskResultResponse,
|
TemplateAnalyzeTaskResultResponse,
|
||||||
TemplateAssistTaskCreateResponse,
|
TemplateAssistTaskCreateResponse,
|
||||||
@@ -1257,12 +1256,6 @@ export const mediaSupplyApi = {
|
|||||||
getOrder(id: number) {
|
getOrder(id: number) {
|
||||||
return apiClient.get<MediaSupplyOrderDetail>(`/api/tenant/media-supply/orders/${id}`)
|
return apiClient.get<MediaSupplyOrderDetail>(`/api/tenant/media-supply/orders/${id}`)
|
||||||
},
|
},
|
||||||
syncBacklinks() {
|
|
||||||
return apiClient.post<SyncMediaSupplyBacklinksResponse, Record<string, never>>(
|
|
||||||
'/api/tenant/media-supply/orders/sync-backlinks',
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
wallet() {
|
wallet() {
|
||||||
return apiClient.get<MediaSupplyWalletStatus>('/api/tenant/media-supply/wallet')
|
return apiClient.get<MediaSupplyWalletStatus>('/api/tenant/media-supply/wallet')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||||
import type { MediaSupplyOrderDetail } from '@geo/shared-types'
|
import type { MediaSupplyOrderDetail } from '@geo/shared-types'
|
||||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import { message, type TableColumnsType } from 'ant-design-vue'
|
import { message, type TableColumnsType } from 'ant-design-vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { mediaSupplyApi } from '@/lib/api'
|
import { mediaSupplyApi } from '@/lib/api'
|
||||||
import { formatDateTime } from '@/lib/display'
|
import { formatDateTime } from '@/lib/display'
|
||||||
@@ -19,11 +19,14 @@ function copyToClipboard(text?: string | null): void {
|
|||||||
message.warning('链接为空,无法复制')
|
message.warning('链接为空,无法复制')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void navigator.clipboard.writeText(url).then(() => {
|
void navigator.clipboard
|
||||||
message.success('已复制到剪贴板')
|
.writeText(url)
|
||||||
}).catch(() => {
|
.then(() => {
|
||||||
message.error('复制失败,请手动复制')
|
message.success('已复制到剪贴板')
|
||||||
})
|
})
|
||||||
|
.catch(() => {
|
||||||
|
message.error('复制失败,请手动复制')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const ordersQuery = useQuery({
|
const ordersQuery = useQuery({
|
||||||
@@ -36,35 +39,6 @@ const ordersQuery = useQuery({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const syncBacklinksMutation = useMutation({
|
|
||||||
mutationFn: () => mediaSupplyApi.syncBacklinks(),
|
|
||||||
onSuccess: async (result) => {
|
|
||||||
await ordersQuery.refetch()
|
|
||||||
if (result.refunded_orders > 0) {
|
|
||||||
message.warning(`已同步 ${result.refunded_orders} 个退稿并退款`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.links_updated > 0) {
|
|
||||||
message.success(`已同步 ${result.links_updated} 条回链`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.order_codes_updated > 0) {
|
|
||||||
message.success(`已同步 ${result.order_codes_updated} 个外部单号`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
message.info(
|
|
||||||
result.published_fetched > 0
|
|
||||||
? '已检查已发表记录,暂未发现新的回链'
|
|
||||||
: result.problem_fetched > 0
|
|
||||||
? '已检查退稿记录,暂未发现需要退款的订单'
|
|
||||||
: result.pending_fetched > 0
|
|
||||||
? '已检查发表中记录,暂未发现新的外部单号'
|
|
||||||
: '暂未读取到投稿记录',
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onError: () => message.error('同步回链失败,请稍后重试'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const orders = computed(() => ordersQuery.data.value?.items ?? [])
|
const orders = computed(() => ordersQuery.data.value?.items ?? [])
|
||||||
const total = computed(() => ordersQuery.data.value?.total ?? 0)
|
const total = computed(() => ordersQuery.data.value?.total ?? 0)
|
||||||
|
|
||||||
@@ -90,10 +64,6 @@ function refresh(): void {
|
|||||||
void ordersQuery.refetch()
|
void ordersQuery.refetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncBacklinks(): void {
|
|
||||||
void syncBacklinksMutation.mutateAsync()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTableChange(nextPage: number, nextPageSize: number): void {
|
function handleTableChange(nextPage: number, nextPageSize: number): void {
|
||||||
page.value = nextPage
|
page.value = nextPage
|
||||||
pageSize.value = nextPageSize
|
pageSize.value = nextPageSize
|
||||||
@@ -119,7 +89,7 @@ function orderStatusMeta(order: MediaSupplyOrderDetail): { label: string; color:
|
|||||||
? { label: '已发表', color: 'success' }
|
? { label: '已发表', color: 'success' }
|
||||||
: { label: '发表中', color: 'processing' }
|
: { label: '发表中', color: 'processing' }
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return order.wallet_refunded_at
|
return order.wallet_refunded_at && order.supplier_status === 'rejected'
|
||||||
? { label: '已退稿', color: 'default' }
|
? { label: '已退稿', color: 'default' }
|
||||||
: { label: '投稿失败', color: 'error' }
|
: { label: '投稿失败', color: 'error' }
|
||||||
default:
|
default:
|
||||||
@@ -149,6 +119,52 @@ function pendingBacklinkCount(order: MediaSupplyOrderDetail): number {
|
|||||||
return (order.items ?? []).filter((item) => !item.external_article_url).length
|
return (order.items ?? []).filter((item) => !item.external_article_url).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldPollOrders = computed(() =>
|
||||||
|
orders.value.some(
|
||||||
|
(order) =>
|
||||||
|
order.status === 'queued' ||
|
||||||
|
order.status === 'submitting' ||
|
||||||
|
(order.status === 'submitted' && pendingBacklinkCount(order) > 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
let pollingTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
function stopOrderPolling(): void {
|
||||||
|
if (pollingTimer) {
|
||||||
|
clearInterval(pollingTimer)
|
||||||
|
pollingTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOrderPolling(): void {
|
||||||
|
if (pollingTimer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pollingTimer = setInterval(() => {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!ordersQuery.isFetching.value) {
|
||||||
|
void ordersQuery.refetch()
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
shouldPollOrders,
|
||||||
|
(enabled) => {
|
||||||
|
if (enabled) {
|
||||||
|
startOrderPolling()
|
||||||
|
} else {
|
||||||
|
stopOrderPolling()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(stopOrderPolling)
|
||||||
|
|
||||||
function mediaNames(order: MediaSupplyOrderDetail): string[] {
|
function mediaNames(order: MediaSupplyOrderDetail): string[] {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const names: string[] = []
|
const names: string[] = []
|
||||||
@@ -233,15 +249,6 @@ function formatOrderError(message?: string | null): string {
|
|||||||
<ReloadOutlined :class="{ 'refresh-spinning': ordersQuery.isFetching.value }" />
|
<ReloadOutlined :class="{ 'refresh-spinning': ordersQuery.isFetching.value }" />
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="toolbar-btn-sync"
|
|
||||||
:disabled="syncBacklinksMutation.isPending.value"
|
|
||||||
@click="syncBacklinks"
|
|
||||||
>
|
|
||||||
<ReloadOutlined v-if="syncBacklinksMutation.isPending.value" class="refresh-spinning" />
|
|
||||||
同步状态/回链
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -320,7 +327,7 @@ function formatOrderError(message?: string | null): string {
|
|||||||
<span v-else>{{ backlinkSummary(record) }}</span>
|
<span v-else>{{ backlinkSummary(record) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'external_order_code'">
|
<template v-else-if="column.key === 'external_order_code'">
|
||||||
{{ record.external_order_code || record.external_order_id || '--' }}
|
{{ record.external_order_code || '--' }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'created_at'">
|
<template v-else-if="column.key === 'created_at'">
|
||||||
{{ formatDateTime(record.created_at) }}
|
{{ formatDateTime(record.created_at) }}
|
||||||
|
|||||||
@@ -87,8 +87,12 @@ export const opsMediaSupplyApi = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
queueSync(modelId = 1) {
|
queueSync(modelId = 1) {
|
||||||
return http.post<{ job_id: number }, { model_id: number }>('/media-supply/sync-jobs', {
|
return http.post<
|
||||||
model_id: modelId,
|
{ id: number; job_key: string; status: string },
|
||||||
|
{ reason: string; config_override: { model_id: number } }
|
||||||
|
>('/scheduler/jobs/media_supply_cache_sync/run', {
|
||||||
|
reason: 'ops 手动同步媒体资源缓存',
|
||||||
|
config_override: { model_id: modelId },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
listWallets(params: OpsMediaSupplyWalletsParams) {
|
listWallets(params: OpsMediaSupplyWalletsParams) {
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ async function queueSync(): Promise<void> {
|
|||||||
syncLoading.value = true
|
syncLoading.value = true
|
||||||
try {
|
try {
|
||||||
await opsMediaSupplyApi.queueSync(MODEL_ID_AUTHORITY_NEWS)
|
await opsMediaSupplyApi.queueSync(MODEL_ID_AUTHORITY_NEWS)
|
||||||
message.success('同步任务已提交')
|
message.success('调度任务已提交')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showOpsError(error)
|
showOpsError(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -652,7 +652,6 @@ export interface MediaSupplyOrderDetail {
|
|||||||
title: string
|
title: string
|
||||||
remark?: string | null
|
remark?: string | null
|
||||||
order_brand?: string | null
|
order_brand?: string | null
|
||||||
external_order_id?: string | null
|
|
||||||
external_order_code?: string | null
|
external_order_code?: string | null
|
||||||
supplier_status?: string | null
|
supplier_status?: string | null
|
||||||
cost_total_cents: number
|
cost_total_cents: number
|
||||||
@@ -684,17 +683,6 @@ export interface ListMediaSupplyOrdersResponse {
|
|||||||
page_size: number
|
page_size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncMediaSupplyBacklinksResponse {
|
|
||||||
published_fetched: number
|
|
||||||
pending_fetched: number
|
|
||||||
problem_fetched: number
|
|
||||||
orders_checked: number
|
|
||||||
orders_updated: number
|
|
||||||
order_codes_updated: number
|
|
||||||
refunded_orders: number
|
|
||||||
links_updated: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaSupplyWalletStatus {
|
export interface MediaSupplyWalletStatus {
|
||||||
tenant_id: number
|
tenant_id: number
|
||||||
user_id: number
|
user_id: number
|
||||||
|
|||||||
@@ -181,6 +181,19 @@ func main() {
|
|||||||
return schedulerRunRetentionWorker.RunOnce(runCtx, jobCtx)
|
return schedulerRunRetentionWorker.RunOnce(runCtx, jobCtx)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Key: "media_supply_cache_sync",
|
||||||
|
Run: func(runCtx context.Context, jobCtx internalscheduler.JobRunContext) (map[string]any, error) {
|
||||||
|
modelID := internalscheduler.AsInt(jobCtx.Config, "model_id", 1)
|
||||||
|
if jobCtx.DryRun {
|
||||||
|
return map[string]any{
|
||||||
|
"model_id": modelID,
|
||||||
|
"synced": 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return app.MediaSupplyService.RunMediaResourceSyncOnce(runCtx, modelID)
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
startSchedulerWorker(ctx, &workerWG, "control_plane", app.Logger.Sugar(), controlPlane.Run)
|
startSchedulerWorker(ctx, &workerWG, "control_plane", app.Logger.Sugar(), controlPlane.Run)
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (s *SchedulerService) ListJobs(ctx context.Context, in SchedulerJobListInpu
|
|||||||
Offset: (page - 1) * size,
|
Offset: (page - 1) * size,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(53001, "scheduler_jobs_query_failed", "failed to list scheduler jobs")
|
return nil, response.ErrInternal(53001, "scheduler_jobs_query_failed", "调度任务列表读取失败")
|
||||||
}
|
}
|
||||||
return &SchedulerJobListResult{Items: items, Total: total, Page: page, Size: size}, nil
|
return &SchedulerJobListResult{Items: items, Total: total, Page: page, Size: size}, nil
|
||||||
}
|
}
|
||||||
@@ -140,22 +140,37 @@ func (s *SchedulerService) SetEnabled(ctx context.Context, actor *Actor, key str
|
|||||||
|
|
||||||
func (s *SchedulerService) Trigger(ctx context.Context, actor *Actor, key string, triggerType string, in SchedulerTriggerInput) (*domain.SchedulerTrigger, error) {
|
func (s *SchedulerService) Trigger(ctx context.Context, actor *Actor, key string, triggerType string, in SchedulerTriggerInput) (*domain.SchedulerTrigger, error) {
|
||||||
key = normalizeSchedulerJobKey(key)
|
key = normalizeSchedulerJobKey(key)
|
||||||
if _, err := s.repo.GetJobForRuntime(ctx, key); err != nil {
|
job, err := s.repo.GetJobForRuntime(ctx, key)
|
||||||
|
if err != nil {
|
||||||
return nil, mapSchedulerJobError(err)
|
return nil, mapSchedulerJobError(err)
|
||||||
}
|
}
|
||||||
triggerType = strings.TrimSpace(triggerType)
|
triggerType = strings.TrimSpace(triggerType)
|
||||||
if triggerType != domain.SchedulerTriggerManual && triggerType != domain.SchedulerTriggerDryRun {
|
if triggerType != domain.SchedulerTriggerManual && triggerType != domain.SchedulerTriggerDryRun {
|
||||||
return nil, response.ErrBadRequest(40083, "invalid_trigger_type", "trigger_type 不合法")
|
return nil, response.ErrBadRequest(40083, "invalid_trigger_type", "trigger_type 不合法")
|
||||||
}
|
}
|
||||||
trigger, err := s.repo.CreateTrigger(ctx, repository.SchedulerTriggerCreate{
|
create := repository.SchedulerTriggerCreate{
|
||||||
JobKey: key,
|
JobKey: key,
|
||||||
TriggerType: triggerType,
|
TriggerType: triggerType,
|
||||||
RequestedBy: actorID(actor),
|
RequestedBy: actorID(actor),
|
||||||
Reason: strings.TrimSpace(in.Reason),
|
Reason: strings.TrimSpace(in.Reason),
|
||||||
ConfigOverride: sanitizeSchedulerConfig(in.ConfigOverride),
|
ConfigOverride: sanitizeSchedulerConfig(in.ConfigOverride),
|
||||||
})
|
}
|
||||||
|
if triggerType == domain.SchedulerTriggerManual && boolFromSchedulerConfig(job.Config, "dedupe_manual_triggers") {
|
||||||
|
trigger, created, triggerErr := s.repo.CreateDedupedManualTrigger(ctx, create)
|
||||||
|
if triggerErr != nil {
|
||||||
|
return nil, response.ErrInternal(53004, "scheduler_trigger_create_failed", "创建调度触发失败")
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
s.auditScheduler(ctx, actor, ActionSchedulerJobRun, key, map[string]any{
|
||||||
|
"trigger_id": trigger.ID,
|
||||||
|
"reason": strings.TrimSpace(in.Reason),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return trigger, nil
|
||||||
|
}
|
||||||
|
trigger, err := s.repo.CreateTrigger(ctx, create)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(53004, "scheduler_trigger_create_failed", "failed to create scheduler trigger")
|
return nil, response.ErrInternal(53004, "scheduler_trigger_create_failed", "创建调度触发失败")
|
||||||
}
|
}
|
||||||
action := ActionSchedulerJobRun
|
action := ActionSchedulerJobRun
|
||||||
if triggerType == domain.SchedulerTriggerDryRun {
|
if triggerType == domain.SchedulerTriggerDryRun {
|
||||||
@@ -180,15 +195,30 @@ func (s *SchedulerService) ListRuns(ctx context.Context, key, status string, pag
|
|||||||
Offset: (page - 1) * size,
|
Offset: (page - 1) * size,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(53005, "scheduler_runs_query_failed", "failed to list scheduler runs")
|
return nil, response.ErrInternal(53005, "scheduler_runs_query_failed", "调度运行历史读取失败")
|
||||||
}
|
}
|
||||||
return &SchedulerRunListResult{Items: items, Total: total, Page: page, Size: size}, nil
|
return &SchedulerRunListResult{Items: items, Total: total, Page: page, Size: size}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolFromSchedulerConfig(config map[string]any, key string) bool {
|
||||||
|
value, ok := config[key]
|
||||||
|
if !ok || value == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case bool:
|
||||||
|
return typed
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(strings.TrimSpace(typed), "true")
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SchedulerService) ListInstances(ctx context.Context) ([]domain.SchedulerInstance, error) {
|
func (s *SchedulerService) ListInstances(ctx context.Context) ([]domain.SchedulerInstance, error) {
|
||||||
items, err := s.repo.ListInstances(ctx)
|
items, err := s.repo.ListInstances(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(53006, "scheduler_instances_query_failed", "failed to list scheduler instances")
|
return nil, response.ErrInternal(53006, "scheduler_instances_query_failed", "调度实例读取失败")
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
@@ -255,7 +285,7 @@ func mapSchedulerJobError(err error) error {
|
|||||||
if errors.Is(err, repository.ErrSchedulerJobNotFound) {
|
if errors.Is(err, repository.ErrSchedulerJobNotFound) {
|
||||||
return response.ErrNotFound(40480, "scheduler_job_not_found", "调度任务不存在")
|
return response.ErrNotFound(40480, "scheduler_job_not_found", "调度任务不存在")
|
||||||
}
|
}
|
||||||
return response.ErrInternal(53002, "scheduler_job_query_failed", "failed to load scheduler job")
|
return response.ErrInternal(53002, "scheduler_job_query_failed", "调度任务读取失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
func actorID(actor *Actor) *int64 {
|
func actorID(actor *Actor) *int64 {
|
||||||
|
|||||||
@@ -270,6 +270,115 @@ func (r *SchedulerRepository) CreateTrigger(ctx context.Context, in SchedulerTri
|
|||||||
`, in.JobKey, in.TriggerType, in.RequestedBy, reason, configJSON))
|
`, in.JobKey, in.TriggerType, in.RequestedBy, reason, configJSON))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *SchedulerRepository) CreateDedupedManualTrigger(ctx context.Context, in SchedulerTriggerCreate) (*domain.SchedulerTrigger, bool, error) {
|
||||||
|
if in.TriggerType != domain.SchedulerTriggerManual {
|
||||||
|
trigger, err := r.CreateTrigger(ctx, in)
|
||||||
|
return trigger, true, err
|
||||||
|
}
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, "scheduler_trigger", in.JobKey); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if item, ok, err := findActiveTrigger(ctx, tx, in.JobKey, in.TriggerType); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
} else if ok {
|
||||||
|
return item, false, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
if _, ok, err := findRunningRun(ctx, tx, in.JobKey); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
} else if ok {
|
||||||
|
reason := in.Reason
|
||||||
|
if reason == "" {
|
||||||
|
reason = "已有调度任务正在执行,本次请求已合并"
|
||||||
|
}
|
||||||
|
item := &domain.SchedulerTrigger{
|
||||||
|
ID: 0,
|
||||||
|
JobKey: in.JobKey,
|
||||||
|
TriggerType: in.TriggerType,
|
||||||
|
Status: domain.SchedulerRunSkipped,
|
||||||
|
RequestedBy: in.RequestedBy,
|
||||||
|
Reason: nullableString(reason),
|
||||||
|
ConfigOverride: decodeSchedulerJSONMap(nil),
|
||||||
|
RequestedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if in.ConfigOverride != nil {
|
||||||
|
item.ConfigOverride = in.ConfigOverride
|
||||||
|
}
|
||||||
|
return item, false, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := marshalSchedulerJSONMap(in.ConfigOverride)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
trigger, err := scanSchedulerTrigger(tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO ops.scheduler_job_triggers (job_key, trigger_type, requested_by, reason, config_override)
|
||||||
|
VALUES ($1, $2, $3, $4, COALESCE($5::jsonb, '{}'::jsonb))
|
||||||
|
RETURNING id, job_key, trigger_type, status, requested_by, reason, config_override,
|
||||||
|
scheduler_instance_id, run_id, requested_at, claimed_at, finished_at, error_message
|
||||||
|
`, in.JobKey, in.TriggerType, in.RequestedBy, nullableString(in.Reason), configJSON))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return trigger, true, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SchedulerRepository) FindActiveTrigger(ctx context.Context, jobKey, triggerType string) (*domain.SchedulerTrigger, bool, error) {
|
||||||
|
return findActiveTrigger(ctx, r.pool, jobKey, triggerType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findActiveTrigger(ctx context.Context, q interface {
|
||||||
|
QueryRow(context.Context, string, ...any) pgx.Row
|
||||||
|
}, jobKey, triggerType string) (*domain.SchedulerTrigger, bool, error) {
|
||||||
|
item, err := scanSchedulerTrigger(q.QueryRow(ctx, `
|
||||||
|
SELECT id, job_key, trigger_type, status, requested_by, reason, config_override,
|
||||||
|
scheduler_instance_id, run_id, requested_at, claimed_at, finished_at, error_message
|
||||||
|
FROM ops.scheduler_job_triggers
|
||||||
|
WHERE job_key = $1
|
||||||
|
AND trigger_type = $2
|
||||||
|
AND status IN ('pending', 'claimed')
|
||||||
|
ORDER BY requested_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
`, jobKey, triggerType))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return item, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SchedulerRepository) FindRunningRun(ctx context.Context, jobKey string) (*domain.SchedulerRun, bool, error) {
|
||||||
|
return findRunningRun(ctx, r.pool, jobKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRunningRun(ctx context.Context, q interface {
|
||||||
|
QueryRow(context.Context, string, ...any) pgx.Row
|
||||||
|
}, jobKey string) (*domain.SchedulerRun, bool, error) {
|
||||||
|
item, err := scanSchedulerRun(q.QueryRow(ctx, `
|
||||||
|
SELECT id, job_key, trigger_id, scheduler_instance_id, trigger_type, status,
|
||||||
|
config_version, config_snapshot, stats, error_message, started_at,
|
||||||
|
finished_at, duration_ms
|
||||||
|
FROM ops.scheduler_job_runs
|
||||||
|
WHERE job_key = $1 AND status = 'running'
|
||||||
|
ORDER BY started_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, jobKey))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return item, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *SchedulerRepository) ClaimPendingTrigger(ctx context.Context, jobKey, instanceID string) (*SchedulerTriggerClaim, bool, error) {
|
func (r *SchedulerRepository) ClaimPendingTrigger(ctx context.Context, jobKey, instanceID string) (*SchedulerTriggerClaim, bool, error) {
|
||||||
var claim SchedulerTriggerClaim
|
var claim SchedulerTriggerClaim
|
||||||
var configRaw []byte
|
var configRaw []byte
|
||||||
|
|||||||
@@ -222,6 +222,27 @@ func (p *ControlPlane) nextAutoAt(ctx context.Context, key string, now time.Time
|
|||||||
base := now.In(loc)
|
base := now.In(loc)
|
||||||
return schedule.Next(base)
|
return schedule.Next(base)
|
||||||
}
|
}
|
||||||
|
if window, ok := parseRandomRunWindowLocal(job.Config); ok {
|
||||||
|
loc, locErr := time.LoadLocation(job.Timezone)
|
||||||
|
if locErr != nil {
|
||||||
|
loc = time.Local
|
||||||
|
}
|
||||||
|
var lastStartedAt *time.Time
|
||||||
|
if initial {
|
||||||
|
startedAt, ok, lastErr := p.repo.LastAutoRunStartedAt(ctx, key)
|
||||||
|
if lastErr != nil {
|
||||||
|
if p.logger != nil {
|
||||||
|
p.logger.Warn("scheduler last auto run lookup failed",
|
||||||
|
zap.String("job_key", key),
|
||||||
|
zap.Error(lastErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if ok {
|
||||||
|
lastStartedAt = &startedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nextRandomWindowAutoAt(key, now, loc, window, initial, lastStartedAt)
|
||||||
|
}
|
||||||
if runAt := parseRunAtLocal(job.Config); runAt != "" {
|
if runAt := parseRunAtLocal(job.Config); runAt != "" {
|
||||||
loc, locErr := time.LoadLocation(job.Timezone)
|
loc, locErr := time.LoadLocation(job.Timezone)
|
||||||
if locErr != nil {
|
if locErr != nil {
|
||||||
@@ -629,6 +650,93 @@ func parseRunAtLocal(config map[string]any) string {
|
|||||||
return fmt.Sprint(value)
|
return fmt.Sprint(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type randomRunWindowLocal struct {
|
||||||
|
StartHour int
|
||||||
|
StartMinute int
|
||||||
|
EndHour int
|
||||||
|
EndMinute int
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRandomRunWindowLocal(config map[string]any) (randomRunWindowLocal, bool) {
|
||||||
|
start := firstConfigString(config, "random_window_start", "random_window_start_local", "random_run_window_start")
|
||||||
|
end := firstConfigString(config, "random_window_end", "random_window_end_local", "random_run_window_end")
|
||||||
|
if start == "" || end == "" {
|
||||||
|
return randomRunWindowLocal{}, false
|
||||||
|
}
|
||||||
|
startHour, startMinute, startOK := parseHourMinute(start)
|
||||||
|
endHour, endMinute, endOK := parseHourMinute(end)
|
||||||
|
startTotal := startHour*60 + startMinute
|
||||||
|
endTotal := endHour*60 + endMinute
|
||||||
|
if !startOK || !endOK || endTotal <= startTotal {
|
||||||
|
return randomRunWindowLocal{}, false
|
||||||
|
}
|
||||||
|
return randomRunWindowLocal{
|
||||||
|
StartHour: startHour,
|
||||||
|
StartMinute: startMinute,
|
||||||
|
EndHour: endHour,
|
||||||
|
EndMinute: endMinute,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextRandomWindowAutoAt(key string, now time.Time, loc *time.Location, window randomRunWindowLocal, initial bool, lastStartedAt *time.Time) time.Time {
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.Local
|
||||||
|
}
|
||||||
|
localNow := now.In(loc)
|
||||||
|
todayStart := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), window.StartHour, window.StartMinute, 0, 0, loc)
|
||||||
|
todayEnd := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), window.EndHour, window.EndMinute, 0, 0, loc)
|
||||||
|
if !todayEnd.After(todayStart) {
|
||||||
|
return now.Add(schedulerConfigRetryInterval)
|
||||||
|
}
|
||||||
|
todayCandidate := randomWindowCandidate(key, todayStart, todayEnd)
|
||||||
|
if !initial {
|
||||||
|
tomorrowStart := todayStart.AddDate(0, 0, 1)
|
||||||
|
return randomWindowCandidate(key, tomorrowStart, tomorrowStart.Add(todayEnd.Sub(todayStart)))
|
||||||
|
}
|
||||||
|
if lastStartedAt != nil && sameLocalDate((*lastStartedAt).In(loc), localNow) {
|
||||||
|
tomorrowStart := todayStart.AddDate(0, 0, 1)
|
||||||
|
return randomWindowCandidate(key, tomorrowStart, tomorrowStart.Add(todayEnd.Sub(todayStart)))
|
||||||
|
}
|
||||||
|
if localNow.Before(todayCandidate) {
|
||||||
|
return todayCandidate
|
||||||
|
}
|
||||||
|
if localNow.Before(todayEnd) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
tomorrowStart := todayStart.AddDate(0, 0, 1)
|
||||||
|
return randomWindowCandidate(key, tomorrowStart, tomorrowStart.Add(todayEnd.Sub(todayStart)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomWindowCandidate(key string, start, end time.Time) time.Time {
|
||||||
|
seconds := int64(end.Sub(start).Seconds())
|
||||||
|
if seconds <= 0 {
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
hash := fnv.New64a()
|
||||||
|
_, _ = hash.Write([]byte("scheduler-random-window:" + key + ":" + start.Format("2006-01-02") + ":" + start.Format("15:04") + ":" + end.Format("15:04")))
|
||||||
|
offset := int64(hash.Sum64() % uint64(seconds))
|
||||||
|
return start.Add(time.Duration(offset) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameLocalDate(a, b time.Time) bool {
|
||||||
|
ay, am, ad := a.Date()
|
||||||
|
by, bm, bd := b.Date()
|
||||||
|
return ay == by && am == bm && ad == bd
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstConfigString(config map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
value, ok := config[key]
|
||||||
|
if !ok || value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func parseHourMinute(value string) (int, int, bool) {
|
func parseHourMinute(value string) (int, int, bool) {
|
||||||
parts := strings.Split(strings.TrimSpace(value), ":")
|
parts := strings.Split(strings.TrimSpace(value), ":")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
|
|||||||
@@ -46,6 +46,46 @@ func TestNextIntervalAutoAtSchedulesAfterCurrentCompletion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNextRandomWindowAutoAtIsStableInsideWindow(t *testing.T) {
|
||||||
|
loc := time.FixedZone("CST", 8*60*60)
|
||||||
|
window, ok := parseRandomRunWindowLocal(map[string]any{
|
||||||
|
"random_window_start": "01:00",
|
||||||
|
"random_window_end": "04:00",
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected random window config to parse")
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 6, 2, 0, 30, 0, 0, loc)
|
||||||
|
|
||||||
|
got := nextRandomWindowAutoAt("media_supply_cache_sync", now, loc, window, true, nil)
|
||||||
|
gotAgain := nextRandomWindowAutoAt("media_supply_cache_sync", now.Add(10*time.Minute), loc, window, true, nil)
|
||||||
|
start := time.Date(2026, 6, 2, 1, 0, 0, 0, loc)
|
||||||
|
end := time.Date(2026, 6, 2, 4, 0, 0, 0, loc)
|
||||||
|
if got.Before(start) || !got.Before(end) {
|
||||||
|
t.Fatalf("nextRandomWindowAutoAt() = %s, want inside [%s, %s)", got, start, end)
|
||||||
|
}
|
||||||
|
if !got.Equal(gotAgain) {
|
||||||
|
t.Fatalf("expected stable candidate, got %s and %s", got, gotAgain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextRandomWindowAutoAtSkipsTodayAfterAutoRun(t *testing.T) {
|
||||||
|
loc := time.FixedZone("CST", 8*60*60)
|
||||||
|
window, _ := parseRandomRunWindowLocal(map[string]any{
|
||||||
|
"random_window_start": "01:00",
|
||||||
|
"random_window_end": "04:00",
|
||||||
|
})
|
||||||
|
now := time.Date(2026, 6, 2, 2, 0, 0, 0, loc)
|
||||||
|
last := time.Date(2026, 6, 2, 1, 30, 0, 0, loc)
|
||||||
|
|
||||||
|
got := nextRandomWindowAutoAt("media_supply_cache_sync", now, loc, window, true, &last)
|
||||||
|
wantStart := time.Date(2026, 6, 3, 1, 0, 0, 0, loc)
|
||||||
|
wantEnd := time.Date(2026, 6, 3, 4, 0, 0, 0, loc)
|
||||||
|
if got.Before(wantStart) || !got.Before(wantEnd) {
|
||||||
|
t.Fatalf("nextRandomWindowAutoAt() = %s, want inside tomorrow window [%s, %s)", got, wantStart, wantEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsEmptySuccessRunRequiresConfiguredZeroMetrics(t *testing.T) {
|
func TestIsEmptySuccessRunRequiresConfiguredZeroMetrics(t *testing.T) {
|
||||||
stats := map[string]any{
|
stats := map[string]any{
|
||||||
"hydrated_count": 0,
|
"hydrated_count": 0,
|
||||||
|
|||||||
@@ -844,7 +844,7 @@ func NormalizeMediaSupplyConfig(cfg *MediaSupplyConfig) {
|
|||||||
cfg.Worker.SyncMaxAttempts = 2
|
cfg.Worker.SyncMaxAttempts = 2
|
||||||
}
|
}
|
||||||
if cfg.Worker.BacklinkSyncInterval <= 0 {
|
if cfg.Worker.BacklinkSyncInterval <= 0 {
|
||||||
cfg.Worker.BacklinkSyncInterval = 10 * time.Minute
|
cfg.Worker.BacklinkSyncInterval = 30 * time.Minute
|
||||||
}
|
}
|
||||||
if cfg.Worker.BacklinkBatchSize <= 0 {
|
if cfg.Worker.BacklinkBatchSize <= 0 {
|
||||||
cfg.Worker.BacklinkBatchSize = 20
|
cfg.Worker.BacklinkBatchSize = 20
|
||||||
|
|||||||
@@ -214,7 +214,6 @@ var routeDocs = map[string]routeDoc{
|
|||||||
"POST /api/tenant/media-supply/orders": {"创建媒介投放订单", "为选定媒介资源创建投放订单。"},
|
"POST /api/tenant/media-supply/orders": {"创建媒介投放订单", "为选定媒介资源创建投放订单。"},
|
||||||
"GET /api/tenant/media-supply/orders": {"媒介订单列表", "分页查询当前 Workspace 的媒介投放订单。"},
|
"GET /api/tenant/media-supply/orders": {"媒介订单列表", "分页查询当前 Workspace 的媒介投放订单。"},
|
||||||
"GET /api/tenant/media-supply/orders/:id": {"媒介订单详情", "读取指定媒介投放订单详情。"},
|
"GET /api/tenant/media-supply/orders/:id": {"媒介订单详情", "读取指定媒介投放订单详情。"},
|
||||||
"POST /api/tenant/media-supply/orders/sync-backlinks": {"同步媒介订单链接", "从供应商侧同步媒介订单的发布链接。"},
|
|
||||||
"GET /api/tenant/media-supply/wallet": {"媒介钱包状态", "读取当前 Workspace 的媒介供应钱包余额与状态。"},
|
"GET /api/tenant/media-supply/wallet": {"媒介钱包状态", "读取当前 Workspace 的媒介供应钱包余额与状态。"},
|
||||||
"GET /api/tenant/media-supply/wallet/ledgers": {"媒介钱包流水", "分页查询媒介供应钱包收支流水。"},
|
"GET /api/tenant/media-supply/wallet/ledgers": {"媒介钱包流水", "分页查询媒介供应钱包收支流水。"},
|
||||||
"POST /api/tenant/media-supply/wallet/adjustments": {"调整媒介钱包", "创建媒介钱包余额调整记录。"},
|
"POST /api/tenant/media-supply/wallet/adjustments": {"调整媒介钱包", "创建媒介钱包余额调整记录。"},
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ func (s *ArticleImitationService) failGeneration(ctx context.Context, job articl
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
logCtx := articleImitationJobLogContext(job, stage, "", "failed", GenerationTaskResultFailure)
|
logCtx := articleImitationJobLogContext(job, stage, "", "failed", GenerationTaskResultFailure)
|
||||||
RecordGenerationTaskEvent(GenerationTaskEventFailure, logCtx)
|
RecordGenerationTaskEvent(GenerationTaskEventFailure, logCtx)
|
||||||
LogGenerationTaskWarn(s.logger, "imitation generation failed", logCtx, zap.Error(genErr))
|
LogGenerationTaskWarn(s.logger, "imitation generation failed", logCtx, GenerationTaskErrorFields(genErr)...)
|
||||||
title := strings.TrimSpace(job.InitialTitle)
|
title := strings.TrimSpace(job.InitialTitle)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = articleImitationFallbackTitle
|
title = articleImitationFallbackTitle
|
||||||
@@ -659,22 +659,18 @@ func buildImitationKnowledgeQuery(params map[string]interface{}) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := make([]string, 0, 8)
|
intent := knowledgeQueryIntent{
|
||||||
appendValue := func(value string) {
|
TaskType: "仿写文章",
|
||||||
value = strings.TrimSpace(value)
|
Title: extractString(params, "source_title"),
|
||||||
if value == "" {
|
Topic: firstNonEmptyText(extractString(params, "source_title"), extractString(params, "extra_requirements")),
|
||||||
return
|
BrandName: extractString(params, "brand_name"),
|
||||||
}
|
Region: extractString(params, "region"),
|
||||||
parts = append(parts, value)
|
PrimaryKeyword: firstNonEmptyText(extractString(params, "source_title"), knowledgeQueryKeywordText(extractStringList(params["keywords"], 16))),
|
||||||
|
Keywords: mergeKnowledgeQueryKeywords(params["keywords"]),
|
||||||
|
KeyPoints: extractString(params, "preserve_points"),
|
||||||
|
Extra: strings.Join([]string{extractString(params, "avoid_points"), extractString(params, "extra_requirements")}, "\n"),
|
||||||
}
|
}
|
||||||
|
return buildKnowledgeQueryInputText(intent)
|
||||||
appendValue(extractString(params, "source_title"))
|
|
||||||
appendValue(extractString(params, "brand_name"))
|
|
||||||
appendValue(extractString(params, "region"))
|
|
||||||
appendValue(strings.Join(extractStringList(params["keywords"], 16), "\n"))
|
|
||||||
appendValue(extractString(params, "extra_requirements"))
|
|
||||||
|
|
||||||
return strings.Join(parts, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendPromptLine(b *strings.Builder, label, value string) {
|
func appendPromptLine(b *strings.Builder, label, value string) {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
const GenerationTaskStatusRunning = "running"
|
const GenerationTaskStatusRunning = "running"
|
||||||
@@ -103,6 +106,30 @@ func LogGenerationTaskError(logger *zap.Logger, message string, ctx GenerationTa
|
|||||||
logger.Error(message, append(GenerationTaskLogFields(ctx), fields...)...)
|
logger.Error(message, append(GenerationTaskLogFields(ctx), fields...)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GenerationTaskErrorFields(err error) []zap.Field {
|
||||||
|
if err == nil {
|
||||||
|
return []zap.Field{}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []zap.Field{zap.Error(err)}
|
||||||
|
var appErr *response.AppError
|
||||||
|
if errors.As(err, &appErr) {
|
||||||
|
if appErr.Code > 0 {
|
||||||
|
fields = append(fields, zap.Int("error_code", appErr.Code))
|
||||||
|
}
|
||||||
|
if appErr.HTTPStatus > 0 {
|
||||||
|
fields = append(fields, zap.Int("http_status", appErr.HTTPStatus))
|
||||||
|
}
|
||||||
|
if message := strings.TrimSpace(appErr.Message); message != "" {
|
||||||
|
fields = append(fields, zap.String("error_message", message))
|
||||||
|
}
|
||||||
|
if detail := strings.TrimSpace(appErr.Detail); detail != "" {
|
||||||
|
fields = append(fields, zap.String("error_detail", detail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
type GenerationTaskMetricsSnapshot struct {
|
type GenerationTaskMetricsSnapshot struct {
|
||||||
TransitionTotal int64 `json:"transition_total"`
|
TransitionTotal int64 `json:"transition_total"`
|
||||||
FailureTotal int64 `json:"failure_total"`
|
FailureTotal int64 `json:"failure_total"`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,6 +92,34 @@ func TestGenerationTaskMetricsRecordsEventsAndPrometheus(t *testing.T) {
|
|||||||
assert.True(t, strings.HasSuffix(lastEventLine, " 1"), lastEventLine)
|
assert.True(t, strings.HasSuffix(lastEventLine, " 1"), lastEventLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormatGenerationFailureKeepsAppErrorBriefForUI(t *testing.T) {
|
||||||
|
err := response.ErrServiceUnavailable(50353, "embedding_failed", "request siliconflow: timeout")
|
||||||
|
|
||||||
|
got := formatGenerationFailure("knowledge_resolve", err)
|
||||||
|
|
||||||
|
assert.Equal(t, "知识库检索失败 [knowledge_resolve]: embedding_failed", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerationTaskErrorFieldsIncludesAppErrorDetailForLogs(t *testing.T) {
|
||||||
|
err := response.ErrServiceUnavailable(50353, "embedding_failed", "request siliconflow: timeout")
|
||||||
|
|
||||||
|
fields := GenerationTaskErrorFields(err)
|
||||||
|
values := map[string]any{}
|
||||||
|
for _, field := range fields {
|
||||||
|
switch {
|
||||||
|
case field.String != "":
|
||||||
|
values[field.Key] = field.String
|
||||||
|
case field.Integer != 0:
|
||||||
|
values[field.Key] = int(field.Integer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "embedding_failed", values["error_message"])
|
||||||
|
assert.Equal(t, "request siliconflow: timeout", values["error_detail"])
|
||||||
|
assert.Equal(t, 50353, values["error_code"])
|
||||||
|
assert.Equal(t, 503, values["http_status"])
|
||||||
|
}
|
||||||
|
|
||||||
func findPrometheusMetricLine(output, metricName string) string {
|
func findPrometheusMetricLine(output, metricName string) string {
|
||||||
for _, line := range strings.Split(output, "\n") {
|
for _, line := range strings.Split(output, "\n") {
|
||||||
if strings.HasPrefix(line, metricName+"{") || strings.HasPrefix(line, metricName+" ") {
|
if strings.HasPrefix(line, metricName+"{") || strings.HasPrefix(line, metricName+" ") {
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxKnowledgeQueryQuestions = 3
|
||||||
|
|
||||||
|
type knowledgeQueryIntent struct {
|
||||||
|
TaskType string
|
||||||
|
TemplateName string
|
||||||
|
Title string
|
||||||
|
Topic string
|
||||||
|
BrandName string
|
||||||
|
Region string
|
||||||
|
PrimaryKeyword string
|
||||||
|
Keywords []string
|
||||||
|
KeyPoints string
|
||||||
|
Extra string
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnowledgeQueryInput struct {
|
||||||
|
TaskType string
|
||||||
|
TemplateName string
|
||||||
|
Title string
|
||||||
|
Topic string
|
||||||
|
BrandName string
|
||||||
|
Region string
|
||||||
|
PrimaryKeyword string
|
||||||
|
Keywords []string
|
||||||
|
KeyPoints string
|
||||||
|
Extra string
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildKnowledgeQuery(input KnowledgeQueryInput) string {
|
||||||
|
intent := knowledgeQueryIntent{
|
||||||
|
TaskType: input.TaskType,
|
||||||
|
TemplateName: input.TemplateName,
|
||||||
|
Title: input.Title,
|
||||||
|
Topic: input.Topic,
|
||||||
|
BrandName: input.BrandName,
|
||||||
|
Region: input.Region,
|
||||||
|
PrimaryKeyword: input.PrimaryKeyword,
|
||||||
|
Keywords: input.Keywords,
|
||||||
|
KeyPoints: input.KeyPoints,
|
||||||
|
Extra: input.Extra,
|
||||||
|
}
|
||||||
|
return buildKnowledgeQueryInputText(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKnowledgeQueryQuestions(intent knowledgeQueryIntent) string {
|
||||||
|
questions := buildKnowledgeQueryQuestionList(intent)
|
||||||
|
return strings.Join(questions, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKnowledgeQueryInputText(intent knowledgeQueryIntent) string {
|
||||||
|
intent = normalizeKnowledgeQueryIntent(intent)
|
||||||
|
lines := make([]string, 0, 12)
|
||||||
|
appendLine := func(label, value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("%s:%s", label, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendLine("文章类型", intent.TaskType)
|
||||||
|
appendLine("模板名称", intent.TemplateName)
|
||||||
|
appendLine("标题", intent.Title)
|
||||||
|
appendLine("主题", intent.Topic)
|
||||||
|
appendLine("品牌", intent.BrandName)
|
||||||
|
appendLine("地域", intent.Region)
|
||||||
|
appendLine("主关键词", intent.PrimaryKeyword)
|
||||||
|
appendLine("关键词", knowledgeQueryKeywordText(intent.Keywords))
|
||||||
|
appendLine("重点要求", intent.KeyPoints)
|
||||||
|
appendLine("其他要求", intent.Extra)
|
||||||
|
fallback := buildKnowledgeQueryQuestionList(intent)
|
||||||
|
if len(fallback) > 0 {
|
||||||
|
lines = append(lines, "兜底检索问题:")
|
||||||
|
for _, question := range fallback {
|
||||||
|
lines = append(lines, "- "+question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKnowledgeQueryRewritePrompt(rawQuery string) string {
|
||||||
|
rawQuery = strings.TrimSpace(rawQuery)
|
||||||
|
if rawQuery == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprintf(`你是知识库检索 Query Rewrite 助手。请根据用户的文章生成输入,改写出 3 个最适合去企业/产品知识库中检索的自然语言问题。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
1. 让检索问题覆盖“文章类型/写作任务”真正需要的知识,而不是只重复品牌名或地域词。
|
||||||
|
2. 优先围绕用户输入的标题、主题、品牌、地域、关键词、重点要求生成问题。
|
||||||
|
3. 问题应适合在知识库中召回:业务范围、产品服务、优势案例、地址联系方式、资质事实、用户痛点、对比维度、避坑点、适用场景。
|
||||||
|
4. 如果输入中出现“兜底检索问题”,只能作为参考;你需要根据上方结构化字段重新生成更贴近本次文章的 3 个问题。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 必须输出 3 个问题。
|
||||||
|
- 每个问题 18-60 个中文字符,尽量具体。
|
||||||
|
- 不要生成泛泛的问题,如“这个品牌怎么样”。
|
||||||
|
- 不要编造输入中没有的品牌、地域、产品或事实。
|
||||||
|
- 只输出 JSON,不要 Markdown,不要解释。
|
||||||
|
|
||||||
|
用户输入:
|
||||||
|
%s`, rawQuery))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKnowledgeQueryRewriteOutput(content string) []string {
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if content == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
content = strings.TrimPrefix(content, "```json")
|
||||||
|
content = strings.TrimPrefix(content, "```")
|
||||||
|
content = strings.TrimSuffix(content, "```")
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Questions []string `json:"questions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(content), &payload); err == nil && len(payload.Questions) > 0 {
|
||||||
|
return normalizeKnowledgeQueryQuestions(payload.Questions)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []string
|
||||||
|
if err := json.Unmarshal([]byte(content), &list); err == nil && len(list) > 0 {
|
||||||
|
return normalizeKnowledgeQueryQuestions(list)
|
||||||
|
}
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKnowledgeQueryQuestions(values []string) []string {
|
||||||
|
questions := make([]string, 0, maxKnowledgeQueryQuestions)
|
||||||
|
for _, value := range values {
|
||||||
|
appendKnowledgeQuestion(&questions, value)
|
||||||
|
if len(questions) >= maxKnowledgeQueryQuestions {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return questions
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKnowledgeQueryQuestionList(intent knowledgeQueryIntent) []string {
|
||||||
|
intent = normalizeKnowledgeQueryIntent(intent)
|
||||||
|
questions := make([]string, 0, maxKnowledgeQueryQuestions)
|
||||||
|
|
||||||
|
subject := firstNonEmptyText(intent.Topic, intent.Title, intent.PrimaryKeyword, intent.BrandName, "当前主题")
|
||||||
|
brandScope := firstNonEmptyText(joinKnowledgeQueryParts(",", intent.BrandName, intent.Region), intent.BrandName, subject)
|
||||||
|
keywordScope := knowledgeQueryKeywordText(intent.Keywords)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(intent.TaskType, "推荐榜") || strings.Contains(strings.ToLower(intent.TaskType), "top"):
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("%s 中有哪些与 %s 相关的品牌、产品、服务能力和选型依据?", subject, brandScope))
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("%s 写作时需要引用哪些关于 %s 的准确事实、优势、案例、地址或联系方式?", intent.TaskType, firstNonEmptyText(intent.BrandName, subject)))
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("%s 相关关键词 %s 对应的用户关注点、避坑点和对比维度是什么?", subject, firstNonEmptyText(keywordScope, intent.PrimaryKeyword, subject)))
|
||||||
|
case strings.Contains(intent.TaskType, "评测") || strings.Contains(strings.ToLower(intent.TaskType), "review"):
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("%s 的产品特性、参数、卖点、适用人群和使用场景是什么?", subject))
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("%s 有哪些真实体验、优缺点、案例、价格或售后信息可用于评测?", firstNonEmptyText(intent.BrandName, subject)))
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("围绕 %s 和 %s,用户最关心哪些购买决策问题?", subject, firstNonEmptyText(keywordScope, intent.PrimaryKeyword, subject)))
|
||||||
|
case strings.Contains(intent.TaskType, "仿写"):
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("仿写 %s 时,%s 有哪些必须保留或补充的品牌事实和业务信息?", subject, firstNonEmptyText(intent.BrandName, subject)))
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("%s 相关关键词 %s 对应的产品、服务、案例和用户需求是什么?", subject, firstNonEmptyText(keywordScope, intent.PrimaryKeyword, subject)))
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("生成 %s 时需要避免遗漏哪些地址、联系方式、优势、资质或落地案例?", firstNonEmptyText(intent.TaskType, "仿写文章")))
|
||||||
|
default:
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("%s 这篇%s需要引用哪些核心事实、产品服务、案例和用户关注点?", subject, firstNonEmptyText(intent.TaskType, "文章")))
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("%s 与 %s 相关的准确资料、品牌信息、地址、联系方式和业务范围是什么?", firstNonEmptyText(intent.BrandName, subject), firstNonEmptyText(keywordScope, intent.PrimaryKeyword, subject)))
|
||||||
|
appendKnowledgeQuestion(&questions, fmt.Sprintf("围绕 %s,用户最可能关心哪些问题、痛点、对比维度和解决方案?", firstNonEmptyText(keywordScope, subject)))
|
||||||
|
}
|
||||||
|
|
||||||
|
appendKnowledgeQuestion(&questions, intent.KeyPoints)
|
||||||
|
appendKnowledgeQuestion(&questions, intent.Extra)
|
||||||
|
return questions
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKnowledgeQueryIntent(intent knowledgeQueryIntent) knowledgeQueryIntent {
|
||||||
|
intent.TaskType = strings.TrimSpace(intent.TaskType)
|
||||||
|
intent.TemplateName = strings.TrimSpace(intent.TemplateName)
|
||||||
|
intent.Title = strings.TrimSpace(intent.Title)
|
||||||
|
intent.Topic = strings.TrimSpace(intent.Topic)
|
||||||
|
intent.BrandName = strings.TrimSpace(intent.BrandName)
|
||||||
|
intent.Region = strings.TrimSpace(intent.Region)
|
||||||
|
intent.PrimaryKeyword = strings.TrimSpace(intent.PrimaryKeyword)
|
||||||
|
intent.KeyPoints = strings.TrimSpace(intent.KeyPoints)
|
||||||
|
intent.Extra = strings.TrimSpace(intent.Extra)
|
||||||
|
intent.Keywords = normalizeKnowledgeQueryKeywords(intent.Keywords, 6)
|
||||||
|
if intent.TaskType == "" {
|
||||||
|
intent.TaskType = strings.TrimSpace(intent.TemplateName)
|
||||||
|
}
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendKnowledgeQuestion(questions *[]string, question string) {
|
||||||
|
if questions == nil || len(*questions) >= maxKnowledgeQueryQuestions {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
question = normalizeKnowledgeQueryQuestion(question)
|
||||||
|
if question == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, existing := range *questions {
|
||||||
|
if normalizeKnowledgeQueryQuestion(existing) == question {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*questions = append(*questions, question)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKnowledgeQueryQuestion(question string) string {
|
||||||
|
question = strings.TrimSpace(question)
|
||||||
|
if question == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
question = strings.Join(strings.Fields(question), " ")
|
||||||
|
for _, bad := range []string{" ,", ", ", " ?", " ?"} {
|
||||||
|
question = strings.ReplaceAll(question, bad, strings.TrimSpace(bad))
|
||||||
|
}
|
||||||
|
question = strings.Trim(question, " ,,;;")
|
||||||
|
if question == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(question, "?") && !strings.HasSuffix(question, "?") {
|
||||||
|
question += "?"
|
||||||
|
}
|
||||||
|
return question
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeKnowledgeQueryKeywords(values ...interface{}) []string {
|
||||||
|
keywords := make([]string, 0)
|
||||||
|
for _, value := range values {
|
||||||
|
keywords = append(keywords, extractStringList(value, 16)...)
|
||||||
|
if text := strings.TrimSpace(formatPromptValue(value)); text != "" && len(extractStringList(value, 1)) == 0 {
|
||||||
|
keywords = append(keywords, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeKnowledgeQueryKeywords(keywords, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKnowledgeQueryKeywords(keywords []string, limit int) []string {
|
||||||
|
result := make([]string, 0, len(keywords))
|
||||||
|
seen := make(map[string]struct{}, len(keywords))
|
||||||
|
for _, keyword := range keywords {
|
||||||
|
keyword = strings.TrimSpace(keyword)
|
||||||
|
if keyword == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.Join(strings.Fields(keyword), " "))
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
result = append(result, keyword)
|
||||||
|
if limit > 0 && len(result) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func knowledgeQueryKeywordText(keywords []string) string {
|
||||||
|
keywords = normalizeKnowledgeQueryKeywords(keywords, 6)
|
||||||
|
return strings.Join(keywords, "、")
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyPromptString(params map[string]interface{}, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if text := strings.TrimSpace(stringValue(params[key])); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyText(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if text := strings.TrimSpace(value); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinKnowledgeQueryParts(sep string, values ...string) string {
|
||||||
|
parts := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if text := strings.TrimSpace(value); text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, sep)
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildGenerationKnowledgeQueryIncludesContextAndFallbackQuestions(t *testing.T) {
|
||||||
|
query := buildGenerationKnowledgeQuery("top_x_article", "Top X 推荐文章", map[string]interface{}{
|
||||||
|
"topic": "合肥全屋定制",
|
||||||
|
"brand_name": "安徽海翔家居",
|
||||||
|
"region": "合肥",
|
||||||
|
"primary_keyword": "合肥定制家具",
|
||||||
|
"supplemental_questions": []string{"合肥家具品牌怎么选", "全屋定制避坑"},
|
||||||
|
"key_points": "突出门店与服务能力",
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, expected := range []string{
|
||||||
|
"文章类型:推荐榜文章",
|
||||||
|
"模板名称:Top X 推荐文章",
|
||||||
|
"主题:合肥全屋定制",
|
||||||
|
"品牌:安徽海翔家居",
|
||||||
|
"地域:合肥",
|
||||||
|
"主关键词:合肥定制家具",
|
||||||
|
"兜底检索问题:",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(query, expected) {
|
||||||
|
t.Fatalf("buildGenerationKnowledgeQuery() = %q, want %q", query, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
questions := normalizeKnowledgeResolveQueries(query)
|
||||||
|
if len(questions) != maxKnowledgeQueryQuestions {
|
||||||
|
t.Fatalf("normalizeKnowledgeResolveQueries() len = %d, want %d: %#v", len(questions), maxKnowledgeQueryQuestions, questions)
|
||||||
|
}
|
||||||
|
for _, question := range questions {
|
||||||
|
if !strings.HasSuffix(question, "?") {
|
||||||
|
t.Fatalf("question = %q, want Chinese question mark", question)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseKnowledgeQueryRewriteOutputAcceptsObjectAndArray(t *testing.T) {
|
||||||
|
objectQuestions := parseKnowledgeQueryRewriteOutput(`{"questions":["安徽海翔家居在合肥有哪些门店和联系方式?","安徽海翔家居主营哪些全屋定制产品和服务?","合肥全屋定制用户选择时关注哪些案例和售后?"]}`)
|
||||||
|
if len(objectQuestions) != maxKnowledgeQueryQuestions {
|
||||||
|
t.Fatalf("object questions len = %d, want %d: %#v", len(objectQuestions), maxKnowledgeQueryQuestions, objectQuestions)
|
||||||
|
}
|
||||||
|
if objectQuestions[0] != "安徽海翔家居在合肥有哪些门店和联系方式?" {
|
||||||
|
t.Fatalf("first object question = %q", objectQuestions[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
arrayQuestions := parseKnowledgeQueryRewriteOutput(`["安徽海翔家居有什么品牌优势?","合肥全屋定制有哪些案例可引用?","全屋定制选型需要哪些避坑信息?"]`)
|
||||||
|
if len(arrayQuestions) != maxKnowledgeQueryQuestions {
|
||||||
|
t.Fatalf("array questions len = %d, want %d: %#v", len(arrayQuestions), maxKnowledgeQueryQuestions, arrayQuestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKnowledgeResolveQueriesPrefersFallbackQuestionBlock(t *testing.T) {
|
||||||
|
query := strings.Join([]string{
|
||||||
|
"文章类型:推荐榜文章",
|
||||||
|
"主题:合肥全屋定制",
|
||||||
|
"品牌:安徽海翔家居",
|
||||||
|
"重点要求:",
|
||||||
|
"请突出本地服务",
|
||||||
|
"不要写成硬广",
|
||||||
|
"兜底检索问题:",
|
||||||
|
"- 安徽海翔家居在合肥有哪些门店和联系方式?",
|
||||||
|
"- 安徽海翔家居主营哪些全屋定制产品和服务?",
|
||||||
|
"- 合肥全屋定制用户选择时关注哪些案例和售后?",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
got := normalizeKnowledgeResolveQueries(query)
|
||||||
|
want := []string{
|
||||||
|
"安徽海翔家居在合肥有哪些门店和联系方式?",
|
||||||
|
"安徽海翔家居主营哪些全屋定制产品和服务?",
|
||||||
|
"合肥全屋定制用户选择时关注哪些案例和售后?",
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("normalizeKnowledgeResolveQueries() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
for index := range want {
|
||||||
|
if got[index] != want[index] {
|
||||||
|
t.Fatalf("normalizeKnowledgeResolveQueries()[%d] = %q, want %q", index, got[index], want[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteKnowledgeResolveQueriesUsesKnowledgeURLModelAndJSONObject(t *testing.T) {
|
||||||
|
client := &fakeKnowledgeQueryLLM{
|
||||||
|
content: `{"questions":["安徽海翔家居在合肥有哪些门店和联系方式?","安徽海翔家居主营哪些全屋定制产品和服务?","合肥全屋定制用户选择时关注哪些案例和售后?"]}`,
|
||||||
|
}
|
||||||
|
svc := &KnowledgeService{
|
||||||
|
llmClient: client,
|
||||||
|
cfg: knowledgeRuntimeConfig{URLMarkdownModel: "knowledge-url-fast"},
|
||||||
|
}
|
||||||
|
|
||||||
|
questions, err := svc.rewriteKnowledgeResolveQueries(context.Background(), "品牌:安徽海翔家居\n地域:合肥")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rewriteKnowledgeResolveQueries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(questions) != maxKnowledgeQueryQuestions {
|
||||||
|
t.Fatalf("questions len = %d, want %d: %#v", len(questions), maxKnowledgeQueryQuestions, questions)
|
||||||
|
}
|
||||||
|
if client.request.Model != "knowledge-url-fast" {
|
||||||
|
t.Fatalf("Generate model = %q, want knowledge-url-fast", client.request.Model)
|
||||||
|
}
|
||||||
|
if client.request.ResponseFormat == nil || client.request.ResponseFormat.Type != llm.ResponseFormatTypeJSONObject {
|
||||||
|
t.Fatalf("ResponseFormat = %#v, want json_object", client.request.ResponseFormat)
|
||||||
|
}
|
||||||
|
if !strings.Contains(client.request.Prompt, "品牌:安徽海翔家居") || !strings.Contains(client.request.Prompt, "必须输出 3 个问题") {
|
||||||
|
t.Fatalf("Prompt = %q, want raw user input and three-question instruction", client.request.Prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeKnowledgeQueryLLM struct {
|
||||||
|
request llm.GenerateRequest
|
||||||
|
content string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKnowledgeQueryLLM) Validate() error {
|
||||||
|
if f.err != nil {
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeKnowledgeQueryLLM) Generate(_ context.Context, req llm.GenerateRequest, _ func(string)) (*llm.GenerateResult, error) {
|
||||||
|
f.request = req
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(f.content) == "" {
|
||||||
|
return nil, errors.New("empty fake content")
|
||||||
|
}
|
||||||
|
return &llm.GenerateResult{Content: f.content, Model: req.Model}, nil
|
||||||
|
}
|
||||||
@@ -836,9 +836,34 @@ func (s *KnowledgeService) ResolveContext(
|
|||||||
if len(requestedGroupIDs) == 0 {
|
if len(requestedGroupIDs) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(query) == "" {
|
queries := normalizeKnowledgeResolveQueries(query)
|
||||||
|
if len(queries) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
queryRewriteStage := "fallback"
|
||||||
|
queryRewriteModel := strings.TrimSpace(s.runtimeConfig().URLMarkdownModel)
|
||||||
|
if rewritten, err := s.rewriteKnowledgeResolveQueries(ctx, query); err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("knowledge query rewrite failed",
|
||||||
|
zap.String("query", knowledgeLogPreview(query, 600)),
|
||||||
|
zap.String("query_rewrite_model", queryRewriteModel),
|
||||||
|
zap.Strings("fallback_queries", queries),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if len(rewritten) >= maxKnowledgeQueryQuestions {
|
||||||
|
queries = rewritten
|
||||||
|
queryRewriteStage = "model"
|
||||||
|
} else if len(rewritten) > 0 && s.logger != nil {
|
||||||
|
s.logger.Warn("knowledge query rewrite returned insufficient questions",
|
||||||
|
zap.String("query", knowledgeLogPreview(query, 600)),
|
||||||
|
zap.String("query_rewrite_model", queryRewriteModel),
|
||||||
|
zap.Strings("model_queries", rewritten),
|
||||||
|
zap.Int("model_query_count", len(rewritten)),
|
||||||
|
zap.Strings("fallback_queries", queries),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rerankQuery := strings.Join(queries, "\n")
|
||||||
if err := s.provider.Validate(); err != nil {
|
if err := s.provider.Validate(); err != nil {
|
||||||
return nil, response.ErrServiceUnavailable(50351, "retrieval_unavailable", err.Error())
|
return nil, response.ErrServiceUnavailable(50351, "retrieval_unavailable", err.Error())
|
||||||
}
|
}
|
||||||
@@ -861,32 +886,39 @@ func (s *KnowledgeService) ResolveContext(
|
|||||||
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, nil),
|
PreciseFacts: s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
vectors, err := s.provider.Embed(ctx, []string{query})
|
vectors, err := s.provider.Embed(ctx, queries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", err.Error())
|
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", err.Error())
|
||||||
}
|
}
|
||||||
if len(vectors) == 0 || len(vectors[0]) == 0 {
|
if len(vectors) != len(queries) {
|
||||||
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", "embedding vector is empty")
|
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", "embedding result count mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
runtimeCfg := s.runtimeConfig()
|
runtimeCfg := s.runtimeConfig()
|
||||||
searchPoints, err := s.store.Search(ctx, retrieval.SearchRequest{
|
searchPoints := make([]retrieval.SearchPoint, 0, len(queries)*runtimeCfg.RecallLimit)
|
||||||
Vector: vectors[0],
|
for index, vector := range vectors {
|
||||||
TenantID: tenantID,
|
if len(vector) == 0 {
|
||||||
GroupIDs: searchGroupIDs,
|
return nil, response.ErrServiceUnavailable(50353, "embedding_failed", fmt.Sprintf("embedding vector is empty at query index %d", index))
|
||||||
Limit: runtimeCfg.RecallLimit,
|
}
|
||||||
})
|
points, err := s.store.Search(ctx, retrieval.SearchRequest{
|
||||||
if err != nil {
|
Vector: vector,
|
||||||
return nil, response.ErrServiceUnavailable(50354, "qdrant_search_failed", err.Error())
|
TenantID: tenantID,
|
||||||
|
GroupIDs: searchGroupIDs,
|
||||||
|
Limit: runtimeCfg.RecallLimit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrServiceUnavailable(50354, "qdrant_search_failed", err.Error())
|
||||||
|
}
|
||||||
|
searchPoints = append(searchPoints, points...)
|
||||||
}
|
}
|
||||||
if len(searchPoints) == 0 {
|
if len(searchPoints) == 0 {
|
||||||
s.logKnowledgeResolve(baseContext, tenantID, query, "no_search_points", nil, nil)
|
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "no_search_points", queryRewriteStage, queryRewriteModel, nil, nil)
|
||||||
return baseContext, nil
|
return baseContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates := dedupeKnowledgeSearchCandidates(knowledgeSnippetsFromSearchPoints(searchPoints))
|
candidates := dedupeKnowledgeSearchCandidates(knowledgeSnippetsFromSearchPoints(searchPoints))
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
s.logKnowledgeResolve(baseContext, tenantID, query, "no_candidate_text", nil, nil)
|
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "no_candidate_text", queryRewriteStage, queryRewriteModel, nil, nil)
|
||||||
return baseContext, nil
|
return baseContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,7 +927,7 @@ func (s *KnowledgeService) ResolveContext(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
s.logKnowledgeResolve(baseContext, tenantID, query, "no_active_candidates", nil, nil)
|
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "no_active_candidates", queryRewriteStage, queryRewriteModel, nil, nil)
|
||||||
return baseContext, nil
|
return baseContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,13 +936,13 @@ func (s *KnowledgeService) ResolveContext(
|
|||||||
documents = append(documents, candidate.RerankText())
|
documents = append(documents, candidate.RerankText())
|
||||||
}
|
}
|
||||||
|
|
||||||
reranked, err := s.provider.Rerank(ctx, query, documents, minInt(runtimeCfg.RerankTopN, len(documents)))
|
reranked, err := s.provider.Rerank(ctx, rerankQuery, documents, minInt(runtimeCfg.RerankTopN, len(documents)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
selected := selectKnowledgeCandidateSnippets(candidates, runtimeCfg.RerankTopN)
|
selected := selectKnowledgeCandidateSnippets(candidates, runtimeCfg.RerankTopN)
|
||||||
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
|
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
|
||||||
baseContext.Snippets = selected
|
baseContext.Snippets = selected
|
||||||
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
|
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
|
||||||
s.logKnowledgeResolve(baseContext, tenantID, query, "rerank_failed_fallback", candidates, selected)
|
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "rerank_failed_fallback", queryRewriteStage, queryRewriteModel, candidates, selected)
|
||||||
return baseContext, nil
|
return baseContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,21 +950,48 @@ func (s *KnowledgeService) ResolveContext(
|
|||||||
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
|
selected = s.enrichKnowledgePromptSnippets(ctx, tenantID, selected)
|
||||||
baseContext.Snippets = selected
|
baseContext.Snippets = selected
|
||||||
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
|
baseContext.PreciseFacts = s.collectKnowledgeContextPreciseFacts(ctx, tenantID, searchGroupIDs, selected)
|
||||||
s.logKnowledgeResolve(baseContext, tenantID, query, "resolved", candidates, selected)
|
s.logKnowledgeResolve(baseContext, tenantID, rerankQuery, "resolved", queryRewriteStage, queryRewriteModel, candidates, selected)
|
||||||
return baseContext, nil
|
return baseContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *KnowledgeService) rewriteKnowledgeResolveQueries(ctx context.Context, query string) ([]string, error) {
|
||||||
|
if s == nil || s.llmClient == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
prompt := buildKnowledgeQueryRewritePrompt(query)
|
||||||
|
if prompt == "" {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
runtimeCfg := s.runtimeConfig()
|
||||||
|
result, err := s.llmClient.Generate(ctx, llm.GenerateRequest{
|
||||||
|
Model: runtimeCfg.URLMarkdownModel,
|
||||||
|
Prompt: prompt,
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
MaxOutputTokens: 600,
|
||||||
|
ResponseFormat: &llm.ResponseFormat{
|
||||||
|
Type: llm.ResponseFormatTypeJSONObject,
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseKnowledgeQueryRewriteOutput(result.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *KnowledgeService) logKnowledgeResolve(
|
func (s *KnowledgeService) logKnowledgeResolve(
|
||||||
knowledgeCtx *KnowledgeContext,
|
knowledgeCtx *KnowledgeContext,
|
||||||
tenantID int64,
|
tenantID int64,
|
||||||
query string,
|
query string,
|
||||||
stage string,
|
stage string,
|
||||||
|
queryRewriteStage string,
|
||||||
|
queryRewriteModel string,
|
||||||
candidates []KnowledgeSnippet,
|
candidates []KnowledgeSnippet,
|
||||||
selected []KnowledgeSnippet,
|
selected []KnowledgeSnippet,
|
||||||
) {
|
) {
|
||||||
if s == nil || s.logger == nil || knowledgeCtx == nil {
|
if s == nil || s.logger == nil || knowledgeCtx == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
queries := normalizeKnowledgeResolveQueries(query)
|
||||||
|
|
||||||
s.logger.Info("knowledge resolve result",
|
s.logger.Info("knowledge resolve result",
|
||||||
zap.String("stage", strings.TrimSpace(stage)),
|
zap.String("stage", strings.TrimSpace(stage)),
|
||||||
@@ -940,6 +999,10 @@ func (s *KnowledgeService) logKnowledgeResolve(
|
|||||||
zap.Int64s("group_ids", knowledgeCtx.GroupIDs),
|
zap.Int64s("group_ids", knowledgeCtx.GroupIDs),
|
||||||
zap.Strings("group_names", knowledgeCtx.GroupNames),
|
zap.Strings("group_names", knowledgeCtx.GroupNames),
|
||||||
zap.String("query", knowledgeLogPreview(query, 600)),
|
zap.String("query", knowledgeLogPreview(query, 600)),
|
||||||
|
zap.Strings("queries", queries),
|
||||||
|
zap.Int("query_count", len(queries)),
|
||||||
|
zap.String("query_rewrite_stage", strings.TrimSpace(queryRewriteStage)),
|
||||||
|
zap.String("query_rewrite_model", strings.TrimSpace(queryRewriteModel)),
|
||||||
zap.Int("candidate_count", len(candidates)),
|
zap.Int("candidate_count", len(candidates)),
|
||||||
zap.Int("selected_count", len(selected)),
|
zap.Int("selected_count", len(selected)),
|
||||||
zap.Int("precise_fact_count", len(knowledgeCtx.PreciseFacts)),
|
zap.Int("precise_fact_count", len(knowledgeCtx.PreciseFacts)),
|
||||||
@@ -947,6 +1010,75 @@ func (s *KnowledgeService) logKnowledgeResolve(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeKnowledgeResolveQueries(query string) []string {
|
||||||
|
lines := strings.Split(strings.TrimSpace(query), "\n")
|
||||||
|
queries := make([]string, 0, minInt(maxKnowledgeQueryQuestions, len(lines)))
|
||||||
|
seen := make(map[string]struct{}, len(lines))
|
||||||
|
|
||||||
|
for index, line := range lines {
|
||||||
|
if strings.TrimSuffix(strings.TrimSpace(line), ":") != "兜底检索问题" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, fallbackLine := range lines[index+1:] {
|
||||||
|
text := strings.TrimSpace(fallbackLine)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(text, "-") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
appendNormalizedKnowledgeResolveQuery(&queries, seen, strings.TrimSpace(strings.TrimPrefix(text, "-")))
|
||||||
|
if len(queries) >= maxKnowledgeQueryQuestions {
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(queries) > 0 {
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inFallbackQuestions := false
|
||||||
|
for _, line := range lines {
|
||||||
|
text := strings.TrimSpace(line)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSuffix(text, ":") == "兜底检索问题" {
|
||||||
|
inFallbackQuestions = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(text, ":") && !strings.HasPrefix(text, "-") && !inFallbackQuestions {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inFallbackQuestions && !strings.HasPrefix(text, "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text = strings.TrimPrefix(text, "-")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
appendNormalizedKnowledgeResolveQuery(&queries, seen, text)
|
||||||
|
if len(queries) >= maxKnowledgeQueryQuestions {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendNormalizedKnowledgeResolveQuery(queries *[]string, seen map[string]struct{}, text string) {
|
||||||
|
if queries == nil || len(*queries) >= maxKnowledgeQueryQuestions {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.Join(strings.Fields(text), " "))
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
*queries = append(*queries, text)
|
||||||
|
}
|
||||||
|
|
||||||
// for debug
|
// for debug
|
||||||
func summarizeKnowledgeSnippetsForLog(snippets []KnowledgeSnippet) []map[string]any {
|
func summarizeKnowledgeSnippetsForLog(snippets []KnowledgeSnippet) []map[string]any {
|
||||||
if len(snippets) == 0 {
|
if len(snippets) == 0 {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func mediaSupplyPublicErrorMessage(err error) string {
|
|||||||
case errors.Is(err, errMeijiequanOrderRejected):
|
case errors.Is(err, errMeijiequanOrderRejected):
|
||||||
return "媒体供应商拒绝投稿,请检查文章内容或联系管理员处理"
|
return "媒体供应商拒绝投稿,请检查文章内容或联系管理员处理"
|
||||||
case errors.Is(err, errMediaSupplySelectedResourcesMissing):
|
case errors.Is(err, errMediaSupplySelectedResourcesMissing):
|
||||||
return "媒体资源价格刷新失败,请稍后重试或重新选择媒体"
|
return "媒体资源暂不可用或已下架,请重新选择媒体"
|
||||||
case errors.Is(err, errMediaSupplyLockedSellBelowCost):
|
case errors.Is(err, errMediaSupplyLockedSellBelowCost):
|
||||||
return "媒体成本价已变化,当前锁定价格低于成本,请重新提交"
|
return "媒体成本价已变化,当前锁定价格低于成本,请重新提交"
|
||||||
case errors.Is(err, errMediaSupplyAccountUserIDMissing):
|
case errors.Is(err, errMediaSupplyAccountUserIDMissing):
|
||||||
@@ -63,7 +63,7 @@ func mediaSupplyPublicGenericErrorMessage(message string) string {
|
|||||||
lower := strings.ToLower(message)
|
lower := strings.ToLower(message)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(lower, "failed to refresh supplier price"):
|
case strings.Contains(lower, "failed to refresh supplier price"):
|
||||||
return "媒体资源价格刷新失败,请稍后重试或重新选择媒体"
|
return "媒体资源暂不可用或已下架,请重新选择媒体"
|
||||||
case strings.Contains(lower, "supplier cost increased above locked sell price"):
|
case strings.Contains(lower, "supplier cost increased above locked sell price"):
|
||||||
return "媒体成本价已变化,当前锁定价格低于成本,请重新提交"
|
return "媒体成本价已变化,当前锁定价格低于成本,请重新提交"
|
||||||
case strings.Contains(lower, "username") || strings.Contains(lower, "password") || strings.Contains(lower, "credentials"):
|
case strings.Contains(lower, "username") || strings.Contains(lower, "password") || strings.Contains(lower, "credentials"):
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
type MediaSupplyService struct {
|
type MediaSupplyService struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
|
redis *goredis.Client
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
cfg config.Provider
|
cfg config.Provider
|
||||||
client *MeijiequanClient
|
client *MeijiequanClient
|
||||||
@@ -35,6 +36,7 @@ func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *za
|
|||||||
client := NewMeijiequanClient(redis, current.MediaSupply.Meijiequan)
|
client := NewMeijiequanClient(redis, current.MediaSupply.Meijiequan)
|
||||||
return &MediaSupplyService{
|
return &MediaSupplyService{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
|
redis: redis,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
client: client,
|
client: client,
|
||||||
@@ -403,19 +405,6 @@ func (s *MediaSupplyService) SessionStatus(ctx context.Context) (*MeijiequanSess
|
|||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaSupplyService) SyncBacklinks(ctx context.Context) (*SyncMediaSupplyBacklinksResponse, error) {
|
|
||||||
if !isTenantAdmin(auth.MustActor(ctx)) {
|
|
||||||
return nil, response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限")
|
|
||||||
}
|
|
||||||
_ = s.mediaSupplyConfig()
|
|
||||||
worker := NewMediaSupplyWorker(s, s.pool, s.logger)
|
|
||||||
result, err := worker.syncPublishedBacklinksNow(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, mediaSupplyClientError(err)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MediaSupplyService) SetResourcePrice(ctx context.Context, resourceID int64, req SetSupplierMediaPriceRequest) error {
|
func (s *MediaSupplyService) SetResourcePrice(ctx context.Context, resourceID int64, req SetSupplierMediaPriceRequest) error {
|
||||||
if actor, ok := auth.ActorFromCtx(ctx); ok && !isTenantAdmin(actor) {
|
if actor, ok := auth.ActorFromCtx(ctx); ok && !isTenantAdmin(actor) {
|
||||||
return response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限")
|
return response.ErrForbidden(40360, "media_supply_admin_required", "需要租户管理员权限")
|
||||||
@@ -982,18 +971,34 @@ func (s *MediaSupplyService) GetOrder(ctx context.Context, orderID int64) (*Medi
|
|||||||
return &item, nil
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mediaResourceSyncStats struct {
|
||||||
|
Synced int
|
||||||
|
RemovedPriceOverride int
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaSupplyService) UpsertSyncedResources(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (int, error) {
|
func (s *MediaSupplyService) UpsertSyncedResources(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (int, error) {
|
||||||
|
stats, err := s.upsertSyncedResourcesWithStats(ctx, resources)
|
||||||
|
return stats.Synced, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaSupplyService) upsertSyncedResourcesWithStats(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (mediaResourceSyncStats, error) {
|
||||||
if len(resources) == 0 {
|
if len(resources) == 0 {
|
||||||
return 0, nil
|
return mediaResourceSyncStats{}, nil
|
||||||
}
|
}
|
||||||
count := 0
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return mediaResourceSyncStats{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
stats := mediaResourceSyncStats{}
|
||||||
for _, resource := range resources {
|
for _, resource := range resources {
|
||||||
raw := compactJSON(resource.Raw)
|
raw := compactJSON(resource.Raw)
|
||||||
costPrices, _ := json.Marshal(resource.CostPrices)
|
costPrices, _ := json.Marshal(resource.CostPrices)
|
||||||
if len(costPrices) == 0 {
|
if len(costPrices) == 0 {
|
||||||
costPrices = []byte(`{}`)
|
costPrices = []byte(`{}`)
|
||||||
}
|
}
|
||||||
if _, err := s.pool.Exec(ctx, `
|
var resourceID int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
INSERT INTO supplier_media_resources (
|
INSERT INTO supplier_media_resources (
|
||||||
supplier, supplier_resource_id, model_id, name, status, cost_price_cents,
|
supplier, supplier_resource_id, model_id, name, status, cost_price_cents,
|
||||||
cost_prices_json, sale_price_label, resource_url, baidu_weight, resource_remark,
|
cost_prices_json, sale_price_label, resource_url, baidu_weight, resource_remark,
|
||||||
@@ -1022,18 +1027,111 @@ func (s *MediaSupplyService) UpsertSyncedResources(ctx context.Context, resource
|
|||||||
raw_json = EXCLUDED.raw_json,
|
raw_json = EXCLUDED.raw_json,
|
||||||
updated_at = NOW(),
|
updated_at = NOW(),
|
||||||
deleted_at = NULL
|
deleted_at = NULL
|
||||||
|
RETURNING id
|
||||||
`, resource.Supplier, resource.SupplierResourceID, resource.ModelID, resource.Name, resource.Status,
|
`, resource.Supplier, resource.SupplierResourceID, resource.ModelID, resource.Name, resource.Status,
|
||||||
resource.CostPriceCents, costPrices, nullableTrimmedString(resource.SalePriceLabel),
|
resource.CostPriceCents, costPrices, nullableTrimmedString(resource.SalePriceLabel),
|
||||||
nullableTrimmedString(resource.ResourceURL), resource.BaiduWeight, nullableTrimmedString(resource.ResourceRemark),
|
nullableTrimmedString(resource.ResourceURL), resource.BaiduWeight, nullableTrimmedString(resource.ResourceRemark),
|
||||||
nullableTrimmedString(resource.ChannelType), nullableTrimmedString(resource.Region),
|
nullableTrimmedString(resource.ChannelType), nullableTrimmedString(resource.Region),
|
||||||
nullableTrimmedString(resource.InclusionEffect), nullableTrimmedString(resource.LinkType),
|
nullableTrimmedString(resource.InclusionEffect), nullableTrimmedString(resource.LinkType),
|
||||||
nullableTrimmedString(resource.PublishRate), nullableTrimmedString(resource.DeliverySpeed),
|
nullableTrimmedString(resource.PublishRate), nullableTrimmedString(resource.DeliverySpeed),
|
||||||
resource.SupplierUpdatedAt, raw); err != nil {
|
resource.SupplierUpdatedAt, raw).Scan(&resourceID); err != nil {
|
||||||
return count, err
|
return stats, err
|
||||||
}
|
}
|
||||||
count++
|
removed, err := s.deletePriceOverridesBelowCost(ctx, tx, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
stats.RemovedPriceOverride += removed
|
||||||
|
stats.Synced++
|
||||||
}
|
}
|
||||||
return count, nil
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaSupplyService) deletePriceOverridesBelowCost(ctx context.Context, q interface {
|
||||||
|
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
|
||||||
|
}, resourceID int64) (int, error) {
|
||||||
|
if s == nil || q == nil || resourceID <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
tag, err := q.Exec(ctx, `
|
||||||
|
DELETE FROM supplier_media_price_overrides o
|
||||||
|
USING supplier_media_resources r
|
||||||
|
WHERE o.resource_id = r.id
|
||||||
|
AND r.id = $1
|
||||||
|
AND r.supplier = $2
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
AND o.enabled = TRUE
|
||||||
|
AND o.sell_price_cents < COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN o.price_type = 'price' THEN COALESCE(NULLIF(r.cost_prices_json->>'price', '')::bigint, NULLIF(r.cost_prices_json->>'sale_price', '')::bigint)
|
||||||
|
ELSE NULLIF(r.cost_prices_json->>o.price_type, '')::bigint
|
||||||
|
END,
|
||||||
|
r.cost_price_cents
|
||||||
|
)
|
||||||
|
`, resourceID, mediaSupplySupplierMeijiequan)
|
||||||
|
return int(tag.RowsAffected()), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaSupplyService) RunMediaResourceSyncOnce(ctx context.Context, modelID int) (map[string]any, error) {
|
||||||
|
if s == nil || s.pool == nil || s.client == nil {
|
||||||
|
return nil, response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用")
|
||||||
|
}
|
||||||
|
cfg := s.mediaSupplyConfig()
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return map[string]any{"synced": 0, "model_id": modelID, "disabled": true}, nil
|
||||||
|
}
|
||||||
|
if modelID <= 0 {
|
||||||
|
modelID = 1
|
||||||
|
}
|
||||||
|
if status, statusErr := s.client.SessionStatus(ctx); statusErr != nil || status == nil || !status.Available {
|
||||||
|
if loginStatus, loginErr := s.client.LoginWithConfiguredAccount(ctx); loginErr != nil {
|
||||||
|
return nil, mediaSupplyClientError(loginErr)
|
||||||
|
} else if loginStatus == nil || !loginStatus.Available {
|
||||||
|
return nil, mediaSupplyClientError(errMeijiequanSessionMissing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats, err := s.syncMediaResourceModel(ctx, modelID, cfg.Meijiequan.SyncPageSize, cfg.Meijiequan.MaxSyncPages, cfg.Meijiequan.SyncPageDelay)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mediaSupplyClientError(err)
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"model_id": modelID,
|
||||||
|
"synced": stats.Synced,
|
||||||
|
"removed_price_overrides": stats.RemovedPriceOverride,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaSupplyService) syncMediaResourceModel(ctx context.Context, modelID, pageSize, maxPages int, pageDelay time.Duration) (mediaResourceSyncStats, error) {
|
||||||
|
var total mediaResourceSyncStats
|
||||||
|
for page := 1; page <= maxPages; page++ {
|
||||||
|
if page > 1 && pageDelay > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return total, ctx.Err()
|
||||||
|
case <-time.After(pageDelay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaPage, err := s.client.ListMedia(ctx, modelID, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
stats, err := s.upsertSyncedResourcesWithStats(ctx, mediaPage.Items)
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
total.Synced += stats.Synced
|
||||||
|
total.RemovedPriceOverride += stats.RemovedPriceOverride
|
||||||
|
if len(mediaPage.Items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if mediaPage.AllPage > 0 && page >= mediaPage.AllPage {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaSupplyService) resourceCostForPriceType(ctx context.Context, resourceID int64, priceType string) (int64, error) {
|
func (s *MediaSupplyService) resourceCostForPriceType(ctx context.Context, resourceID int64, priceType string) (int64, error) {
|
||||||
@@ -1253,6 +1351,14 @@ func costForResourcePriceType(resource SupplierMediaResource, priceType string)
|
|||||||
return resource.CostPriceCents
|
return resource.CostPriceCents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mediaSupplyOverrideBelowCost(resource SupplierMediaResource, priceType string, overrideCents int64, overrideEnabled bool) bool {
|
||||||
|
if !overrideEnabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cost := costForResourcePriceType(resource, normalizeMediaSupplyPriceType(priceType))
|
||||||
|
return cost > 0 && overrideCents < cost
|
||||||
|
}
|
||||||
|
|
||||||
func mediaSupplyClientError(err error) error {
|
func mediaSupplyClientError(err error) error {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, errMeijiequanCredentialsMissing):
|
case errors.Is(err, errMeijiequanCredentialsMissing):
|
||||||
@@ -1335,5 +1441,14 @@ func scanMediaSupplyOrder(row mediaSupplyOrderScanner) (MediaSupplyOrderDetail,
|
|||||||
message := mediaSupplyPublicGenericErrorMessage(*item.ErrorMessage)
|
message := mediaSupplyPublicGenericErrorMessage(*item.ErrorMessage)
|
||||||
item.ErrorMessage = &message
|
item.ErrorMessage = &message
|
||||||
}
|
}
|
||||||
|
item.ExternalOrderID = nil
|
||||||
|
if item.ExternalOrderCode != nil {
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(*item.ExternalOrderCode))
|
||||||
|
if !isLikelyMeijiequanOrderCode(code) {
|
||||||
|
item.ExternalOrderCode = nil
|
||||||
|
} else {
|
||||||
|
item.ExternalOrderCode = &code
|
||||||
|
}
|
||||||
|
}
|
||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,28 @@ func TestMediaSupplySellPriceAppliesMarkupBeforeRoundUp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMediaSupplyOverrideBelowCostUsesSyncedCostPrices(t *testing.T) {
|
||||||
|
resource := SupplierMediaResource{
|
||||||
|
CostPriceCents: 1000,
|
||||||
|
CostPrices: map[string]int64{
|
||||||
|
"price": 1500,
|
||||||
|
"priceyc": 2200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !mediaSupplyOverrideBelowCost(resource, "price", 1400, true) {
|
||||||
|
t.Fatal("expected default price override below synced price cost to be cleared")
|
||||||
|
}
|
||||||
|
if mediaSupplyOverrideBelowCost(resource, "price", 1500, true) {
|
||||||
|
t.Fatal("expected override equal to synced price cost to stay")
|
||||||
|
}
|
||||||
|
if !mediaSupplyOverrideBelowCost(resource, "priceyc", 2100, true) {
|
||||||
|
t.Fatal("expected priceyc override below synced priceyc cost to be cleared")
|
||||||
|
}
|
||||||
|
if mediaSupplyOverrideBelowCost(resource, "priceyc", 2100, false) {
|
||||||
|
t.Fatal("disabled override should not be treated as active loss risk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMediaSupplyResourceOrderSQL(t *testing.T) {
|
func TestMediaSupplyResourceOrderSQL(t *testing.T) {
|
||||||
priceSQL := "sell_price_expr"
|
priceSQL := "sell_price_expr"
|
||||||
if got := mediaSupplyResourceOrderSQL("price", priceSQL); got != "(sell_price_expr) ASC, r.id DESC" {
|
if got := mediaSupplyResourceOrderSQL("price", priceSQL); got != "(sell_price_expr) ASC, r.id DESC" {
|
||||||
@@ -149,9 +171,23 @@ func TestRetryableMediaSupplyErrorTreatsBusinessFailuresAsTerminal(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMediaSupplyRefreshFailureMessageIsActionable(t *testing.T) {
|
||||||
|
got := mediaSupplyPublicErrorMessage(errMediaSupplySelectedResourcesMissing)
|
||||||
|
if got != "媒体资源暂不可用或已下架,请重新选择媒体" {
|
||||||
|
t.Fatalf("unexpected refresh failure message: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = mediaSupplyPublicGenericErrorMessage("failed to refresh supplier price for 1 selected resources")
|
||||||
|
if got != "媒体资源暂不可用或已下架,请重新选择媒体" {
|
||||||
|
t.Fatalf("unexpected generic refresh failure message: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) {
|
func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
errorMessage := "failed to refresh supplier price for 1 selected resources"
|
errorMessage := "failed to refresh supplier price for 1 selected resources"
|
||||||
|
externalOrderID := "U6210"
|
||||||
|
externalOrderCode := "U6210"
|
||||||
item, err := scanMediaSupplyOrder(mediaSupplyOrderScanStub{values: []any{
|
item, err := scanMediaSupplyOrder(mediaSupplyOrderScanStub{values: []any{
|
||||||
int64(1),
|
int64(1),
|
||||||
int64(2),
|
int64(2),
|
||||||
@@ -165,8 +201,8 @@ func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) {
|
|||||||
"测试标题",
|
"测试标题",
|
||||||
(*string)(nil),
|
(*string)(nil),
|
||||||
(*string)(nil),
|
(*string)(nil),
|
||||||
(*string)(nil),
|
&externalOrderID,
|
||||||
(*string)(nil),
|
&externalOrderCode,
|
||||||
(*string)(nil),
|
(*string)(nil),
|
||||||
int64(100),
|
int64(100),
|
||||||
int64(120),
|
int64(120),
|
||||||
@@ -187,9 +223,15 @@ func TestScanMediaSupplyOrderRedactsPublicFields(t *testing.T) {
|
|||||||
if strings.Contains(strings.ToLower(item.Supplier), "meijiequan") {
|
if strings.Contains(strings.ToLower(item.Supplier), "meijiequan") {
|
||||||
t.Fatalf("supplier was not redacted: %s", item.Supplier)
|
t.Fatalf("supplier was not redacted: %s", item.Supplier)
|
||||||
}
|
}
|
||||||
if item.ErrorMessage == nil || *item.ErrorMessage != "媒体资源价格刷新失败,请稍后重试或重新选择媒体" {
|
if item.ErrorMessage == nil || *item.ErrorMessage != "媒体资源暂不可用或已下架,请重新选择媒体" {
|
||||||
t.Fatalf("unexpected public error message: %#v", item.ErrorMessage)
|
t.Fatalf("unexpected public error message: %#v", item.ErrorMessage)
|
||||||
}
|
}
|
||||||
|
if item.ExternalOrderID != nil {
|
||||||
|
t.Fatalf("external order id should not be exposed: %#v", item.ExternalOrderID)
|
||||||
|
}
|
||||||
|
if item.ExternalOrderCode != nil {
|
||||||
|
t.Fatalf("invalid external order code should not be exposed: %#v", item.ExternalOrderCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMeijiequanCreateOrderFormMatchesUpstreamShape(t *testing.T) {
|
func TestMeijiequanCreateOrderFormMatchesUpstreamShape(t *testing.T) {
|
||||||
@@ -471,6 +513,7 @@ func TestDecodeMeijiequanAllArticleHTMLKeepsStatusAndRejectReasonColumns(t *test
|
|||||||
if problem.OrderCode != "WM119065" ||
|
if problem.OrderCode != "WM119065" ||
|
||||||
problem.Title != "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议" ||
|
problem.Title != "合肥全屋定制怎么选?5 大主流品牌优缺点对比 + 选购建议" ||
|
||||||
problem.ResourceName != "博客园(GEO排名可发)" ||
|
problem.ResourceName != "博客园(GEO排名可发)" ||
|
||||||
|
problem.PriceCents != 300 ||
|
||||||
problem.Status != "退稿申请中" ||
|
problem.Status != "退稿申请中" ||
|
||||||
problem.RejectReason != "不想发布了" {
|
problem.RejectReason != "不想发布了" {
|
||||||
t.Fatalf("unexpected problem article: %#v", problem)
|
t.Fatalf("unexpected problem article: %#v", problem)
|
||||||
@@ -478,6 +521,7 @@ func TestDecodeMeijiequanAllArticleHTMLKeepsStatusAndRejectReasonColumns(t *test
|
|||||||
published := page.Items[1]
|
published := page.Items[1]
|
||||||
if published.OrderCode != "WM118996" ||
|
if published.OrderCode != "WM118996" ||
|
||||||
published.ResourceName != "金融树洞网" ||
|
published.ResourceName != "金融树洞网" ||
|
||||||
|
published.PriceCents != 300 ||
|
||||||
published.Status != "已发布" ||
|
published.Status != "已发布" ||
|
||||||
published.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" {
|
published.ExternalArticleURL != "http://jrsd.huaihuarexian.cn/caijing/11221014.html" {
|
||||||
t.Fatalf("unexpected published article: %#v", published)
|
t.Fatalf("unexpected published article: %#v", published)
|
||||||
@@ -507,14 +551,15 @@ func TestDecodeMeijiequanPublishedArticleJSONRejectsShowURL(t *testing.T) {
|
|||||||
|
|
||||||
func TestMatchMeijiequanPublishedArticlesPrefersResourceID(t *testing.T) {
|
func TestMatchMeijiequanPublishedArticlesPrefersResourceID(t *testing.T) {
|
||||||
items := []MediaSupplyOrderItem{
|
items := []MediaSupplyOrderItem{
|
||||||
{ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "测试新闻网"},
|
{ID: 11, SupplierResourceID: "36122", ResourceNameSnapshot: "测试新闻网", LockedCostPriceCents: 300, LockedSellPriceCents: 500},
|
||||||
{ID: 12, SupplierResourceID: "36123", ResourceNameSnapshot: "另一个媒体"},
|
{ID: 12, SupplierResourceID: "36123", ResourceNameSnapshot: "另一个媒体", LockedCostPriceCents: 300, LockedSellPriceCents: 500},
|
||||||
}
|
}
|
||||||
matches := matchMeijiequanPublishedArticles(mediaSupplySubmitOrder{Title: "2026合肥品牌推荐"}, items, []MeijiequanPublishedArticle{
|
matches := matchMeijiequanPublishedArticles(mediaSupplySubmitOrder{Title: "2026合肥品牌推荐"}, items, []MeijiequanPublishedArticle{
|
||||||
{
|
{
|
||||||
Title: "2026合肥品牌推荐",
|
Title: "2026合肥品牌推荐",
|
||||||
ResourceID: "36123_price_0",
|
ResourceID: "36123_price_0",
|
||||||
ResourceName: "另一个媒体",
|
ResourceName: "另一个媒体",
|
||||||
|
PriceCents: 300,
|
||||||
ExternalArticleURL: "https://news.example.test/b.html",
|
ExternalArticleURL: "https://news.example.test/b.html",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -526,6 +571,76 @@ func TestMatchMeijiequanPublishedArticlesPrefersResourceID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchMeijiequanOrderCodeArticleUsesTitleResourceAndPriceFallback(t *testing.T) {
|
||||||
|
items := []MediaSupplyOrderItem{{
|
||||||
|
ID: 11,
|
||||||
|
SupplierResourceID: "36122",
|
||||||
|
ResourceNameSnapshot: "博客园(GEO排名可发)",
|
||||||
|
LockedCostPriceCents: 300,
|
||||||
|
LockedSellPriceCents: 900,
|
||||||
|
}}
|
||||||
|
article := matchMeijiequanOrderCodeArticle(
|
||||||
|
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选"},
|
||||||
|
items,
|
||||||
|
[]MeijiequanPublishedArticle{
|
||||||
|
{OrderCode: "WM120001", Title: "合肥全屋定制怎么选", ResourceName: "博客园(GEO排名可发)", PriceCents: 900},
|
||||||
|
{OrderCode: "WM120002", Title: "合肥全屋定制怎么选", ResourceName: "博客园(GEO排名可发)", PriceCents: 300},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if article == nil || article.OrderCode != "WM120002" {
|
||||||
|
t.Fatalf("expected title/resource/price fallback to match WM120002, got %#v", article)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchMeijiequanOrderCodeArticleDoesNotFallbackWhenOrderCodeDiffers(t *testing.T) {
|
||||||
|
orderCode := "WM120090"
|
||||||
|
items := []MediaSupplyOrderItem{{
|
||||||
|
ID: 11,
|
||||||
|
SupplierResourceID: "36122",
|
||||||
|
ResourceNameSnapshot: "博客园(GEO排名可发)",
|
||||||
|
LockedCostPriceCents: 300,
|
||||||
|
LockedSellPriceCents: 900,
|
||||||
|
}}
|
||||||
|
article := matchMeijiequanOrderCodeArticle(
|
||||||
|
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选", ExternalOrderCode: &orderCode},
|
||||||
|
items,
|
||||||
|
[]MeijiequanPublishedArticle{{
|
||||||
|
OrderCode: "WM120091",
|
||||||
|
Title: "合肥全屋定制怎么选",
|
||||||
|
ResourceName: "博客园(GEO排名可发)",
|
||||||
|
PriceCents: 300,
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
if article != nil {
|
||||||
|
t.Fatalf("article with different order code should not match: %#v", article)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchMeijiequanPublishedArticlesDoesNotFallbackWhenOrderCodeDiffers(t *testing.T) {
|
||||||
|
orderCode := "WM120090"
|
||||||
|
items := []MediaSupplyOrderItem{{
|
||||||
|
ID: 11,
|
||||||
|
SupplierResourceID: "36122",
|
||||||
|
ResourceNameSnapshot: "博客园(GEO排名可发)",
|
||||||
|
LockedCostPriceCents: 300,
|
||||||
|
LockedSellPriceCents: 900,
|
||||||
|
}}
|
||||||
|
matches := matchMeijiequanPublishedArticles(
|
||||||
|
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选", ExternalOrderCode: &orderCode},
|
||||||
|
items,
|
||||||
|
[]MeijiequanPublishedArticle{{
|
||||||
|
OrderCode: "WM120091",
|
||||||
|
Title: "合肥全屋定制怎么选",
|
||||||
|
ResourceName: "博客园(GEO排名可发)",
|
||||||
|
PriceCents: 300,
|
||||||
|
ExternalArticleURL: "https://news.example.test/a.html",
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
if len(matches) != 0 {
|
||||||
|
t.Fatalf("article with different order code should not match: %#v", matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchMeijiequanProblemArticleReturnsRejectReasonForCustomer(t *testing.T) {
|
func TestMatchMeijiequanProblemArticleReturnsRejectReasonForCustomer(t *testing.T) {
|
||||||
orderCode := "WM119065"
|
orderCode := "WM119065"
|
||||||
article := matchMeijiequanProblemArticle(
|
article := matchMeijiequanProblemArticle(
|
||||||
@@ -547,6 +662,43 @@ func TestMatchMeijiequanProblemArticleReturnsRejectReasonForCustomer(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchMeijiequanProblemArticleDoesNotFallbackWhenOrderCodeDiffers(t *testing.T) {
|
||||||
|
orderCode := "WM119065"
|
||||||
|
article := matchMeijiequanProblemArticle(
|
||||||
|
mediaSupplySubmitOrder{Title: "合肥全屋定制怎么选", ExternalOrderCode: &orderCode},
|
||||||
|
[]MediaSupplyOrderItem{{
|
||||||
|
ID: 11,
|
||||||
|
SupplierResourceID: "36122",
|
||||||
|
ResourceNameSnapshot: "博客园(GEO排名可发)",
|
||||||
|
LockedCostPriceCents: 300,
|
||||||
|
}},
|
||||||
|
[]MeijiequanPublishedArticle{{
|
||||||
|
OrderCode: "WM119066",
|
||||||
|
Title: "合肥全屋定制怎么选",
|
||||||
|
ResourceName: "博客园(GEO排名可发)",
|
||||||
|
PriceCents: 300,
|
||||||
|
Status: "退稿申请中",
|
||||||
|
RejectReason: "不想发布了",
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
if article != nil {
|
||||||
|
t.Fatalf("problem article with different order code should not match: %#v", article)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeijiequanOrderCodeOnlyAcceptsWMOrderNumber(t *testing.T) {
|
||||||
|
if isLikelyMeijiequanOrderCode("U6210") {
|
||||||
|
t.Fatal("U6210 should not be treated as an external order number")
|
||||||
|
}
|
||||||
|
if got := firstMeijiequanOrderCode("user U6210 created WM120090"); got != "WM120090" {
|
||||||
|
t.Fatalf("unexpected order code extraction: %s", got)
|
||||||
|
}
|
||||||
|
invalidCode := "U6210"
|
||||||
|
if got := mediaSupplyOrderCodeValue(mediaSupplySubmitOrder{ExternalOrderCode: &invalidCode}); got != "" {
|
||||||
|
t.Fatalf("invalid external order code should not be used for matching: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMaskMeijiequanUsername(t *testing.T) {
|
func TestMaskMeijiequanUsername(t *testing.T) {
|
||||||
if got := maskMeijiequanUsername("17788409108"); got != "177******08" {
|
if got := maskMeijiequanUsername("17788409108"); got != "177******08" {
|
||||||
t.Fatalf("unexpected masked username: %s", got)
|
t.Fatalf("unexpected masked username: %s", got)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MediaSupplyWorker struct {
|
type MediaSupplyWorker struct {
|
||||||
@@ -46,7 +48,12 @@ var (
|
|||||||
errMediaSupplyLockedSellBelowCost = errors.New("supplier cost increased above locked sell price")
|
errMediaSupplyLockedSellBelowCost = errors.New("supplier cost increased above locked sell price")
|
||||||
)
|
)
|
||||||
|
|
||||||
var mediaSupplyOrderCodePattern = regexp.MustCompile(`(?i)\b[A-Z]{1,12}\d{3,}\b`)
|
var mediaSupplyOrderCodePattern = regexp.MustCompile(`(?i)\bWM\d{3,}\b`)
|
||||||
|
|
||||||
|
const (
|
||||||
|
mediaSupplyValidExternalOrderCodeSQL = `TRIM(COALESCE(external_order_code, '')) ~* '^WM[0-9]{3,}$'`
|
||||||
|
mediaSupplyBacklinkSyncCooldownKey = "media_supply:backlink_sync:cooldown"
|
||||||
|
)
|
||||||
|
|
||||||
func NewMediaSupplyWorker(service *MediaSupplyService, pool *pgxpool.Pool, logger *zap.Logger) *MediaSupplyWorker {
|
func NewMediaSupplyWorker(service *MediaSupplyService, pool *pgxpool.Pool, logger *zap.Logger) *MediaSupplyWorker {
|
||||||
return &MediaSupplyWorker{service: service, pool: pool, logger: logger}
|
return &MediaSupplyWorker{service: service, pool: pool, logger: logger}
|
||||||
@@ -107,27 +114,14 @@ func (w *MediaSupplyWorker) processOneSyncJob(ctx context.Context) error {
|
|||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cfg := w.service.mediaSupplyConfig()
|
|
||||||
if status, statusErr := w.service.client.SessionStatus(ctx); statusErr != nil || status == nil || !status.Available {
|
|
||||||
if loginStatus, loginErr := w.service.client.LoginWithConfiguredAccount(ctx); loginErr != nil {
|
|
||||||
return w.failSyncJob(ctx, job, loginErr)
|
|
||||||
} else if loginStatus == nil || !loginStatus.Available {
|
|
||||||
return w.failSyncJob(ctx, job, errMeijiequanSessionMissing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
modelID := 1
|
modelID := 1
|
||||||
if job.ModelID != nil && *job.ModelID > 0 {
|
if job.ModelID != nil && *job.ModelID > 0 {
|
||||||
modelID = *job.ModelID
|
modelID = *job.ModelID
|
||||||
}
|
}
|
||||||
totalSynced := 0
|
stats, syncErr := w.service.RunMediaResourceSyncOnce(ctx, modelID)
|
||||||
stats := map[string]any{"models": map[int]int{}}
|
|
||||||
modelSynced, syncErr := w.syncModel(ctx, modelID, cfg.Meijiequan.SyncPageSize, cfg.Meijiequan.MaxSyncPages, cfg.Meijiequan.SyncPageDelay)
|
|
||||||
if syncErr != nil {
|
if syncErr != nil {
|
||||||
return w.failSyncJob(ctx, job, syncErr)
|
return w.failSyncJob(ctx, job, syncErr)
|
||||||
}
|
}
|
||||||
totalSynced += modelSynced
|
|
||||||
stats["models"].(map[int]int)[modelID] = modelSynced
|
|
||||||
stats["synced"] = totalSynced
|
|
||||||
raw, _ := json.Marshal(stats)
|
raw, _ := json.Marshal(stats)
|
||||||
if _, err := w.pool.Exec(ctx, `
|
if _, err := w.pool.Exec(ctx, `
|
||||||
UPDATE media_supply_sync_jobs
|
UPDATE media_supply_sync_jobs
|
||||||
@@ -140,32 +134,8 @@ func (w *MediaSupplyWorker) processOneSyncJob(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *MediaSupplyWorker) syncModel(ctx context.Context, modelID, pageSize, maxPages int, pageDelay time.Duration) (int, error) {
|
func (w *MediaSupplyWorker) syncModel(ctx context.Context, modelID, pageSize, maxPages int, pageDelay time.Duration) (int, error) {
|
||||||
total := 0
|
stats, err := w.service.syncMediaResourceModel(ctx, modelID, pageSize, maxPages, pageDelay)
|
||||||
for page := 1; page <= maxPages; page++ {
|
return stats.Synced, err
|
||||||
if page > 1 && pageDelay > 0 {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return total, ctx.Err()
|
|
||||||
case <-time.After(pageDelay):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mediaPage, err := w.service.client.ListMedia(ctx, modelID, page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
return total, err
|
|
||||||
}
|
|
||||||
count, err := w.service.UpsertSyncedResources(ctx, mediaPage.Items)
|
|
||||||
if err != nil {
|
|
||||||
return total, err
|
|
||||||
}
|
|
||||||
total += count
|
|
||||||
if len(mediaPage.Items) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if mediaPage.AllPage > 0 && page >= mediaPage.AllPage {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *MediaSupplyWorker) processOneOrder(ctx context.Context) error {
|
func (w *MediaSupplyWorker) processOneOrder(ctx context.Context) error {
|
||||||
@@ -331,11 +301,46 @@ func (w *MediaSupplyWorker) refreshSelectedResourcesFromUpstream(ctx context.Con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(pending) > 0 {
|
if len(pending) > 0 {
|
||||||
return fmt.Errorf("%w for %d selected resources", errMediaSupplySelectedResourcesMissing, len(pending))
|
return w.validatePendingResourcesFromCache(ctx, modelID, items, pending)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *MediaSupplyWorker) validatePendingResourcesFromCache(ctx context.Context, modelID int, items []MediaSupplyOrderItem, pending map[string]struct{}) error {
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := w.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
missing := 0
|
||||||
|
for _, item := range items {
|
||||||
|
resourceID := strings.TrimSpace(item.SupplierResourceID)
|
||||||
|
if _, ok := pending[resourceID]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resource, _, _, loadErr := w.service.loadResourceForOrder(ctx, tx, item.ResourceID, modelID, item.PriceType)
|
||||||
|
if loadErr != nil {
|
||||||
|
missing++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currentCost := costForResourcePriceType(resource, item.PriceType)
|
||||||
|
if currentCost <= 0 {
|
||||||
|
missing++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if currentCost > item.LockedSellPriceCents {
|
||||||
|
return fmt.Errorf("%w for resource %s", errMediaSupplyLockedSellBelowCost, item.SupplierResourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if missing > 0 {
|
||||||
|
return fmt.Errorf("%w for %d selected resources", errMediaSupplySelectedResourcesMissing, missing)
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (w *MediaSupplyWorker) processBacklinkSync(ctx context.Context) error {
|
func (w *MediaSupplyWorker) processBacklinkSync(ctx context.Context) error {
|
||||||
cfg := w.service.mediaSupplyConfig()
|
cfg := w.service.mediaSupplyConfig()
|
||||||
if cfg.Worker.BacklinkSyncInterval <= 0 {
|
if cfg.Worker.BacklinkSyncInterval <= 0 {
|
||||||
@@ -375,12 +380,8 @@ func (w *MediaSupplyWorker) processBacklinkSync(ctx context.Context) error {
|
|||||||
AND NULLIF(TRIM(COALESCE(item.external_article_url, '')), '') IS NULL
|
AND NULLIF(TRIM(COALESCE(item.external_article_url, '')), '') IS NULL
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND (
|
|
||||||
supplier_status IS DISTINCT FROM $3
|
|
||||||
OR updated_at <= NOW() - $4::interval
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted, "published", pgInterval(cfg.Worker.BacklinkSyncInterval)).Scan(&due); err != nil {
|
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted).Scan(&due); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
@@ -389,7 +390,18 @@ func (w *MediaSupplyWorker) processBacklinkSync(ctx context.Context) error {
|
|||||||
if !due && cleared == 0 {
|
if !due && cleared == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return w.syncPublishedBacklinks(ctx)
|
if allowed, err := w.claimBacklinkSyncCooldown(ctx, cfg.Worker.BacklinkSyncInterval); err != nil || !allowed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.syncPublishedBacklinksNowUnthrottled(ctx, cfg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *MediaSupplyWorker) claimBacklinkSyncCooldown(ctx context.Context, interval time.Duration) (bool, error) {
|
||||||
|
if interval <= 0 || w == nil || w.service == nil || w.service.redis == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return w.service.redis.SetNX(ctx, mediaSupplyBacklinkSyncCooldownKey, time.Now().UTC().Format(time.RFC3339), interval).Result()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *MediaSupplyWorker) clearInvalidBacklinks(ctx context.Context) (int64, error) {
|
func (w *MediaSupplyWorker) clearInvalidBacklinks(ctx context.Context) (int64, error) {
|
||||||
@@ -410,16 +422,21 @@ func (w *MediaSupplyWorker) clearInvalidBacklinks(ctx context.Context) (int64, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *MediaSupplyWorker) syncPublishedBacklinks(ctx context.Context) error {
|
func (w *MediaSupplyWorker) syncPublishedBacklinks(ctx context.Context) error {
|
||||||
_, err := w.syncPublishedBacklinksNow(ctx)
|
cfg := w.service.mediaSupplyConfig()
|
||||||
|
if allowed, err := w.claimBacklinkSyncCooldown(ctx, cfg.Worker.BacklinkSyncInterval); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !allowed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := w.syncPublishedBacklinksNowUnthrottled(ctx, cfg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *MediaSupplyWorker) syncPublishedBacklinksNow(ctx context.Context) (*SyncMediaSupplyBacklinksResponse, error) {
|
func (w *MediaSupplyWorker) syncPublishedBacklinksNowUnthrottled(ctx context.Context, cfg config.MediaSupplyConfig) (*SyncMediaSupplyBacklinksResponse, error) {
|
||||||
result := &SyncMediaSupplyBacklinksResponse{}
|
result := &SyncMediaSupplyBacklinksResponse{}
|
||||||
if _, err := w.clearInvalidBacklinks(ctx); err != nil {
|
if _, err := w.clearInvalidBacklinks(ctx); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
cfg := w.service.mediaSupplyConfig()
|
|
||||||
userID := strings.TrimSpace(cfg.Meijiequan.UserID)
|
userID := strings.TrimSpace(cfg.Meijiequan.UserID)
|
||||||
if status, statusErr := w.service.client.SessionStatus(ctx); statusErr != nil || status == nil || !status.Available {
|
if status, statusErr := w.service.client.SessionStatus(ctx); statusErr != nil || status == nil || !status.Available {
|
||||||
loginStatus, loginErr := w.service.client.LoginWithConfiguredAccount(ctx)
|
loginStatus, loginErr := w.service.client.LoginWithConfiguredAccount(ctx)
|
||||||
@@ -598,7 +615,7 @@ func (w *MediaSupplyWorker) loadOrderCodeSyncCandidates(ctx context.Context, lim
|
|||||||
WHERE supplier = $1
|
WHERE supplier = $1
|
||||||
AND status = $2
|
AND status = $2
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND NULLIF(TRIM(COALESCE(external_order_code, '')), '') IS NULL
|
AND NOT (`+mediaSupplyValidExternalOrderCodeSQL+`)
|
||||||
ORDER BY submitted_at DESC NULLS LAST, id DESC
|
ORDER BY submitted_at DESC NULLS LAST, id DESC
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted, limit)
|
`, mediaSupplySupplierMeijiequan, mediaSupplyOrderStatusSubmitted, limit)
|
||||||
@@ -633,7 +650,7 @@ func (w *MediaSupplyWorker) loadOrderCodeSyncCandidates(ctx context.Context, lim
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *MediaSupplyWorker) updateOrderCode(ctx context.Context, order mediaSupplySubmitOrder, pending []MeijiequanPublishedArticle) (bool, error) {
|
func (w *MediaSupplyWorker) updateOrderCode(ctx context.Context, order mediaSupplySubmitOrder, pending []MeijiequanPublishedArticle) (bool, error) {
|
||||||
if order.ExternalOrderCode != nil && strings.TrimSpace(*order.ExternalOrderCode) != "" {
|
if order.ExternalOrderCode != nil && isLikelyMeijiequanOrderCode(*order.ExternalOrderCode) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
items, err := w.service.listOrderItems(ctx, order.ID)
|
items, err := w.service.listOrderItems(ctx, order.ID)
|
||||||
@@ -661,7 +678,7 @@ func (w *MediaSupplyWorker) updateOrderCode(ctx context.Context, order mediaSupp
|
|||||||
END,
|
END,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND NULLIF(TRIM(COALESCE(external_order_code, '')), '') IS NULL
|
AND NOT (`+mediaSupplyValidExternalOrderCodeSQL+`)
|
||||||
`, order.ID, article.OrderCode, article.OrderID, compactJSON(raw))
|
`, order.ID, article.OrderCode, article.OrderID, compactJSON(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -1185,7 +1202,7 @@ func matchMeijiequanPublishedArticles(order mediaSupplySubmitOrder, items []Medi
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
itemID := matchMeijiequanPublishedArticleItem(orderTitle, unmatched, article)
|
itemID := matchMeijiequanPublishedArticleItem(orderTitle, orderCode, unmatched, article)
|
||||||
if itemID <= 0 {
|
if itemID <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1205,6 +1222,7 @@ func matchMeijiequanOrderCodeArticle(order mediaSupplySubmitOrder, items []Media
|
|||||||
return &articles[idx]
|
return &articles[idx]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
orderTitle := normalizeMeijiequanMatchText(order.Title)
|
orderTitle := normalizeMeijiequanMatchText(order.Title)
|
||||||
for idx := range articles {
|
for idx := range articles {
|
||||||
@@ -1212,11 +1230,8 @@ func matchMeijiequanOrderCodeArticle(order mediaSupplySubmitOrder, items []Media
|
|||||||
if strings.TrimSpace(article.OrderCode) == "" {
|
if strings.TrimSpace(article.OrderCode) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
articleTitle := normalizeMeijiequanMatchText(article.Title)
|
if articleMatchesOrderFallback(orderTitle, *article, items) {
|
||||||
if orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle)) {
|
return article
|
||||||
if len(items) <= 1 || articleMatchesAnyOrderItem(*article, items) {
|
|
||||||
return article
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1236,6 +1251,7 @@ func matchMeijiequanProblemArticle(order mediaSupplySubmitOrder, items []MediaSu
|
|||||||
return article
|
return article
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
orderTitle := normalizeMeijiequanMatchText(order.Title)
|
orderTitle := normalizeMeijiequanMatchText(order.Title)
|
||||||
for idx := range articles {
|
for idx := range articles {
|
||||||
@@ -1243,11 +1259,8 @@ func matchMeijiequanProblemArticle(order mediaSupplySubmitOrder, items []MediaSu
|
|||||||
if !isMeijiequanProblemArticle(*article) {
|
if !isMeijiequanProblemArticle(*article) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
articleTitle := normalizeMeijiequanMatchText(article.Title)
|
if articleMatchesOrderFallback(orderTitle, *article, items) {
|
||||||
if orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle)) {
|
return article
|
||||||
if len(items) <= 1 || articleMatchesAnyOrderItem(*article, items) {
|
|
||||||
return article
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1255,7 +1268,7 @@ func matchMeijiequanProblemArticle(order mediaSupplySubmitOrder, items []MediaSu
|
|||||||
|
|
||||||
func mediaSupplyOrderCodeValue(order mediaSupplySubmitOrder) string {
|
func mediaSupplyOrderCodeValue(order mediaSupplySubmitOrder) string {
|
||||||
if order.ExternalOrderCode != nil {
|
if order.ExternalOrderCode != nil {
|
||||||
if value := strings.TrimSpace(*order.ExternalOrderCode); value != "" {
|
if value := strings.TrimSpace(*order.ExternalOrderCode); isLikelyMeijiequanOrderCode(value) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1343,31 +1356,69 @@ func articleMatchesAnyOrderItem(article MeijiequanPublishedArticle, items []Medi
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func matchMeijiequanPublishedArticleItem(orderTitle string, items map[int64]MediaSupplyOrderItem, article MeijiequanPublishedArticle) int64 {
|
func articleMatchesOrderFallback(orderTitle string, article MeijiequanPublishedArticle, items []MediaSupplyOrderItem) bool {
|
||||||
resourceID := normalizeMeijiequanPublishedResourceID(article.ResourceID)
|
if !articleTitleMatchesOrder(orderTitle, article) {
|
||||||
if resourceID != "" {
|
return false
|
||||||
for itemID, item := range items {
|
}
|
||||||
if strings.EqualFold(strings.TrimSpace(item.SupplierResourceID), resourceID) {
|
if len(items) == 0 {
|
||||||
return itemID
|
return false
|
||||||
}
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if articleMatchesOrderItemFallback(article, item) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func articleTitleMatchesOrder(orderTitle string, article MeijiequanPublishedArticle) bool {
|
||||||
articleTitle := normalizeMeijiequanMatchText(article.Title)
|
articleTitle := normalizeMeijiequanMatchText(article.Title)
|
||||||
if orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle)) {
|
return orderTitle != "" && articleTitle != "" && (strings.Contains(articleTitle, orderTitle) || strings.Contains(orderTitle, articleTitle))
|
||||||
if len(items) == 1 {
|
}
|
||||||
for itemID := range items {
|
|
||||||
return itemID
|
func articleMatchesOrderItemFallback(article MeijiequanPublishedArticle, item MediaSupplyOrderItem) bool {
|
||||||
}
|
return articleMatchesOrderItemResource(article, item) && articleMatchesOrderItemPrice(article, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func articleMatchesOrderItemResource(article MeijiequanPublishedArticle, item MediaSupplyOrderItem) bool {
|
||||||
|
resourceID := normalizeMeijiequanPublishedResourceID(article.ResourceID)
|
||||||
|
if resourceID != "" && strings.EqualFold(strings.TrimSpace(item.SupplierResourceID), resourceID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
resourceName := normalizeMeijiequanMatchText(article.ResourceName)
|
||||||
|
itemName := normalizeMeijiequanMatchText(item.ResourceNameSnapshot)
|
||||||
|
return resourceName != "" && itemName != "" && (strings.Contains(itemName, resourceName) || strings.Contains(resourceName, itemName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func articleMatchesOrderItemPrice(article MeijiequanPublishedArticle, item MediaSupplyOrderItem) bool {
|
||||||
|
if article.PriceCents <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return article.PriceCents == item.LockedCostPriceCents
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchMeijiequanPublishedArticleItem(orderTitle, orderCode string, items map[int64]MediaSupplyOrderItem, article MeijiequanPublishedArticle) int64 {
|
||||||
|
if orderCode != "" {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(article.OrderCode), orderCode) {
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
resourceName := normalizeMeijiequanMatchText(article.ResourceName)
|
for itemID := range items {
|
||||||
if resourceName != "" {
|
return itemID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if articleTitleMatchesOrder(orderTitle, article) {
|
||||||
|
if len(items) == 1 {
|
||||||
for itemID, item := range items {
|
for itemID, item := range items {
|
||||||
itemName := normalizeMeijiequanMatchText(item.ResourceNameSnapshot)
|
if articleMatchesOrderItemFallback(article, item) {
|
||||||
if itemName != "" && (strings.Contains(itemName, resourceName) || strings.Contains(resourceName, itemName)) {
|
|
||||||
return itemID
|
return itemID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for itemID, item := range items {
|
||||||
|
if articleMatchesOrderItemFallback(article, item) {
|
||||||
|
return itemID
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -1463,6 +1514,8 @@ func extractMeijiequanOrderIDFromValue(value any) (string, string) {
|
|||||||
if code != "" && !isLikelyMeijiequanOrderCode(code) {
|
if code != "" && !isLikelyMeijiequanOrderCode(code) {
|
||||||
if extracted := firstMeijiequanOrderCode(code); extracted != "" {
|
if extracted := firstMeijiequanOrderCode(code); extracted != "" {
|
||||||
code = extracted
|
code = extracted
|
||||||
|
} else {
|
||||||
|
code = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, key := range []string{"data", "result", "order", "article", "info", "message", "msg"} {
|
for _, key := range []string{"data", "result", "order", "article", "info", "message", "msg"} {
|
||||||
@@ -1542,4 +1595,3 @@ func (w *MediaSupplyWorker) logWarn(message string, fields ...zap.Field) {
|
|||||||
w.logger.Warn(message, fields...)
|
w.logger.Warn(message, fields...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ type MeijiequanPublishedArticle struct {
|
|||||||
Title string
|
Title string
|
||||||
ResourceID string
|
ResourceID string
|
||||||
ResourceName string
|
ResourceName string
|
||||||
|
PriceCents int64
|
||||||
ExternalArticleURL string
|
ExternalArticleURL string
|
||||||
Status string
|
Status string
|
||||||
RejectReason string
|
RejectReason string
|
||||||
@@ -1600,13 +1601,18 @@ func parseMeijiequanPublishedArticleMap(item map[string]any) (MeijiequanPublishe
|
|||||||
if title == "" && articleURL == "" {
|
if title == "" && articleURL == "" {
|
||||||
return MeijiequanPublishedArticle{}, false
|
return MeijiequanPublishedArticle{}, false
|
||||||
}
|
}
|
||||||
|
orderCode := strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "order_code", "code", "sn", "order_sn", "dingdanhao", "order_no", "orderno", "danhao")))
|
||||||
|
if !isLikelyMeijiequanOrderCode(orderCode) {
|
||||||
|
orderCode = firstMeijiequanOrderCode(orderCode)
|
||||||
|
}
|
||||||
raw, _ := json.Marshal(item)
|
raw, _ := json.Marshal(item)
|
||||||
return MeijiequanPublishedArticle{
|
return MeijiequanPublishedArticle{
|
||||||
OrderID: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "id", "order_id", "article_id"))),
|
OrderID: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "id", "order_id", "article_id"))),
|
||||||
OrderCode: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "order_code", "code", "sn", "order_sn", "dingdanhao", "order_no", "orderno", "danhao"))),
|
OrderCode: orderCode,
|
||||||
Title: title,
|
Title: title,
|
||||||
ResourceID: normalizeMeijiequanPublishedResourceID(meijiequanAnyString(firstMapValue(item, "resource_id", "media_id", "uid", "mid"))),
|
ResourceID: normalizeMeijiequanPublishedResourceID(meijiequanAnyString(firstMapValue(item, "resource_id", "media_id", "uid", "mid"))),
|
||||||
ResourceName: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "meiti", "media_name", "resource_name", "name"))),
|
ResourceName: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "meiti", "media_name", "resource_name", "name"))),
|
||||||
|
PriceCents: firstMeijiequanArticlePriceCents(item),
|
||||||
ExternalArticleURL: articleURL,
|
ExternalArticleURL: articleURL,
|
||||||
Status: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "status", "status_text", "state"))),
|
Status: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "status", "status_text", "state"))),
|
||||||
RejectReason: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "reject_reason", "reason", "tuigao_reason", "tuigaoyuanyin", "refund_reason", "error_message"))),
|
RejectReason: strings.TrimSpace(meijiequanAnyString(firstMapValue(item, "reject_reason", "reason", "tuigao_reason", "tuigaoyuanyin", "refund_reason", "error_message"))),
|
||||||
@@ -1732,6 +1738,7 @@ func parseMeijiequanPublishedArticleHTMLRow(row *nethtml.Node, cells []*nethtml.
|
|||||||
if status == "" {
|
if status == "" {
|
||||||
status = extractMeijiequanPublishedStatus(rowText)
|
status = extractMeijiequanPublishedStatus(rowText)
|
||||||
}
|
}
|
||||||
|
priceCents, _ := parsePriceCents(columnValues["价格"])
|
||||||
rejectReason := strings.TrimSpace(columnValues["退稿原因"])
|
rejectReason := strings.TrimSpace(columnValues["退稿原因"])
|
||||||
return MeijiequanPublishedArticle{
|
return MeijiequanPublishedArticle{
|
||||||
OrderID: strings.TrimSpace(firstMeijiequanHTMLAttr(row, "data-id", "data-order-id", "data-article-id")),
|
OrderID: strings.TrimSpace(firstMeijiequanHTMLAttr(row, "data-id", "data-order-id", "data-article-id")),
|
||||||
@@ -1739,6 +1746,7 @@ func parseMeijiequanPublishedArticleHTMLRow(row *nethtml.Node, cells []*nethtml.
|
|||||||
Title: strings.TrimSpace(title),
|
Title: strings.TrimSpace(title),
|
||||||
ResourceID: resourceID,
|
ResourceID: resourceID,
|
||||||
ResourceName: resourceName,
|
ResourceName: resourceName,
|
||||||
|
PriceCents: priceCents,
|
||||||
ExternalArticleURL: articleURL,
|
ExternalArticleURL: articleURL,
|
||||||
Status: status,
|
Status: status,
|
||||||
RejectReason: rejectReason,
|
RejectReason: rejectReason,
|
||||||
@@ -1814,6 +1822,8 @@ func normalizeMeijiequanHeaderName(value string) string {
|
|||||||
return "订单号"
|
return "订单号"
|
||||||
case strings.Contains(value, "资源名称"):
|
case strings.Contains(value, "资源名称"):
|
||||||
return "资源名称"
|
return "资源名称"
|
||||||
|
case strings.Contains(value, "价格") || strings.Contains(value, "金额"):
|
||||||
|
return "价格"
|
||||||
case strings.Contains(value, "退稿原因"):
|
case strings.Contains(value, "退稿原因"):
|
||||||
return "退稿原因"
|
return "退稿原因"
|
||||||
case strings.Contains(value, "状态"):
|
case strings.Contains(value, "状态"):
|
||||||
@@ -2091,7 +2101,10 @@ func extractMeijiequanOrderCodeFromText(value string) string {
|
|||||||
for idx, field := range fields {
|
for idx, field := range fields {
|
||||||
if strings.Contains(field, "单号") || strings.Contains(strings.ToLower(field), "order") {
|
if strings.Contains(field, "单号") || strings.Contains(strings.ToLower(field), "order") {
|
||||||
if idx+1 < len(fields) {
|
if idx+1 < len(fields) {
|
||||||
return strings.Trim(fields[idx+1], " ::,,;;")
|
token := strings.Trim(fields[idx+1], " ::,,;;")
|
||||||
|
if isLikelyMeijiequanOrderCode(token) {
|
||||||
|
return token
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2115,25 +2128,21 @@ func extractMeijiequanOrderCodeFromTexts(texts []string) string {
|
|||||||
|
|
||||||
func isLikelyMeijiequanOrderCode(value string) bool {
|
func isLikelyMeijiequanOrderCode(value string) bool {
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
if len(value) < 4 || len(value) > 64 {
|
if len(value) < 5 || len(value) > 64 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if strings.Contains(value, "HTTP") || strings.Contains(value, "://") {
|
if strings.Contains(value, "HTTP") || strings.Contains(value, "://") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
hasDigit := false
|
if !strings.HasPrefix(value, "WM") {
|
||||||
hasLetter := false
|
return false
|
||||||
for _, r := range value {
|
}
|
||||||
switch {
|
for _, r := range strings.TrimPrefix(value, "WM") {
|
||||||
case r >= '0' && r <= '9':
|
if r < '0' || r > '9' {
|
||||||
hasDigit = true
|
|
||||||
case r >= 'A' && r <= 'Z':
|
|
||||||
hasLetter = true
|
|
||||||
default:
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hasDigit && hasLetter
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractMeijiequanResourceNameFromTexts(texts []string) string {
|
func extractMeijiequanResourceNameFromTexts(texts []string) string {
|
||||||
@@ -2469,6 +2478,18 @@ func extractMeijiequanPrices(item map[string]any) map[string]int64 {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstMeijiequanArticlePriceCents(item map[string]any) int64 {
|
||||||
|
for _, key := range []string{
|
||||||
|
"price", "sale_price", "cost_price", "amount", "money", "jine", "jiage",
|
||||||
|
"media_price", "resource_price", "order_price", "total_price",
|
||||||
|
} {
|
||||||
|
if cents, ok := parsePriceCents(item[key]); ok {
|
||||||
|
return cents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func parsePriceCents(value any) (int64, bool) {
|
func parsePriceCents(value any) (int64, bool) {
|
||||||
switch typed := value.(type) {
|
switch typed := value.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
|
|||||||
@@ -718,7 +718,7 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
logCtx := promptRuleGenerationJobLogContext(job, stage, "", "failed", GenerationTaskResultFailure)
|
logCtx := promptRuleGenerationJobLogContext(job, stage, "", "failed", GenerationTaskResultFailure)
|
||||||
RecordGenerationTaskEvent(GenerationTaskEventFailure, logCtx)
|
RecordGenerationTaskEvent(GenerationTaskEventFailure, logCtx)
|
||||||
LogGenerationTaskWarn(s.logger, "prompt rule generation failed", logCtx, zap.Error(genErr))
|
LogGenerationTaskWarn(s.logger, "prompt rule generation failed", logCtx, GenerationTaskErrorFields(genErr)...)
|
||||||
failureTitle := strings.TrimSpace(extractString(job.InputParams, "task_name"))
|
failureTitle := strings.TrimSpace(extractString(job.InputParams, "task_name"))
|
||||||
if failureTitle == "" {
|
if failureTitle == "" {
|
||||||
failureTitle = title
|
failureTitle = title
|
||||||
@@ -857,41 +857,28 @@ func buildPromptRuleGenerationPrompt(rule *promptRuleGenerationRecord, params ma
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildPromptRuleKnowledgeQuery(rule *promptRuleGenerationRecord, params map[string]interface{}) string {
|
func buildPromptRuleKnowledgeQuery(rule *promptRuleGenerationRecord, params map[string]interface{}) string {
|
||||||
parts := make([]string, 0, 6)
|
intent := knowledgeQueryIntent{
|
||||||
|
TaskType: "自定义生成",
|
||||||
|
Title: firstNonEmptyPromptString(params, "task_name", "title", "topic", "subject"),
|
||||||
|
Topic: firstNonEmptyPromptString(params, "topic", "subject", "title"),
|
||||||
|
BrandName: extractString(params, "brand_name"),
|
||||||
|
PrimaryKeyword: extractString(params, "primary_keyword"),
|
||||||
|
Keywords: mergeKnowledgeQueryKeywords(params["keywords"], params["primary_keyword"]),
|
||||||
|
KeyPoints: extractString(params, "key_points"),
|
||||||
|
Extra: extractString(params, "extra_requirements"),
|
||||||
|
}
|
||||||
if rule != nil {
|
if rule != nil {
|
||||||
if text := strings.TrimSpace(rule.Name); text != "" {
|
if name := strings.TrimSpace(rule.Name); name != "" {
|
||||||
parts = append(parts, text)
|
intent.TemplateName = name
|
||||||
|
if intent.Title == "" {
|
||||||
|
intent.Title = name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if rule.Scene != nil && strings.TrimSpace(*rule.Scene) != "" {
|
if rule.Scene != nil && strings.TrimSpace(*rule.Scene) != "" {
|
||||||
parts = append(parts, strings.TrimSpace(*rule.Scene))
|
intent.TaskType = strings.TrimSpace(*rule.Scene)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if taskName := strings.TrimSpace(extractString(params, "task_name")); taskName != "" {
|
return buildKnowledgeQueryInputText(intent)
|
||||||
parts = append(parts, taskName)
|
|
||||||
}
|
|
||||||
for _, key := range []string{
|
|
||||||
"title",
|
|
||||||
"topic",
|
|
||||||
"subject",
|
|
||||||
"brand_name",
|
|
||||||
"product_name",
|
|
||||||
"primary_keyword",
|
|
||||||
"key_points",
|
|
||||||
"extra_requirements",
|
|
||||||
} {
|
|
||||||
if text := strings.TrimSpace(extractString(params, key)); text != "" {
|
|
||||||
parts = append(parts, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if keywords := extractStringList(params["keywords"], 16); len(keywords) > 0 {
|
|
||||||
parts = append(parts, strings.Join(keywords, "\n"))
|
|
||||||
}
|
|
||||||
if rule != nil {
|
|
||||||
if text := strings.TrimSpace(rule.PromptContent); text != "" {
|
|
||||||
parts = append(parts, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractGenerateCount(extra map[string]interface{}) int {
|
func extractGenerateCount(extra map[string]interface{}) int {
|
||||||
|
|||||||
@@ -48,32 +48,39 @@ func buildGenerationPrompt(
|
|||||||
return strings.Join(sections, "\n\n")
|
return strings.Join(sections, "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildGenerationKnowledgeQuery(params map[string]interface{}) string {
|
func buildGenerationKnowledgeQuery(templateKey, templateName string, params map[string]interface{}) string {
|
||||||
if len(params) == 0 {
|
if len(params) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := make([]string, 0, 8)
|
intent := knowledgeQueryIntent{
|
||||||
appendValue := func(value string) {
|
TaskType: templateKnowledgeTaskType(templateKey, templateName),
|
||||||
value = strings.TrimSpace(value)
|
TemplateName: templateName,
|
||||||
if value == "" {
|
Title: firstNonEmptyPromptString(params, "title", "topic", "product_name", "subject"),
|
||||||
return
|
Topic: firstNonEmptyPromptString(params, "topic", "subject", "product_name", "title"),
|
||||||
}
|
BrandName: stringValue(params["brand_name"]),
|
||||||
parts = append(parts, value)
|
Region: firstNonEmptyPromptString(params, "region", "city", "location", "area", "target_region", "service_area"),
|
||||||
|
PrimaryKeyword: firstNonEmptyPromptString(params, "brand_question", "primary_keyword"),
|
||||||
|
Keywords: mergeKnowledgeQueryKeywords(params["keywords"], params["supplemental_questions"], params["primary_keyword"]),
|
||||||
|
KeyPoints: firstNonEmptyPromptString(params, "key_points", "review_intro_hook"),
|
||||||
}
|
}
|
||||||
|
return buildKnowledgeQueryInputText(intent)
|
||||||
|
}
|
||||||
|
|
||||||
appendValue(stringValue(params["title"]))
|
func templateKnowledgeTaskType(templateKey, templateName string) string {
|
||||||
appendValue(stringValue(params["topic"]))
|
switch strings.TrimSpace(templateKey) {
|
||||||
appendValue(stringValue(params["product_name"]))
|
case "top_x_article":
|
||||||
appendValue(stringValue(params["subject"]))
|
return "推荐榜文章"
|
||||||
appendValue(stringValue(params["brand_name"]))
|
case "product_review":
|
||||||
appendValue(stringValue(params["brand_question"]))
|
return "产品评测文章"
|
||||||
appendValue(stringValue(params["primary_keyword"]))
|
case "research_report":
|
||||||
appendValue(formatPromptValue(params["supplemental_questions"]))
|
return "研究报告"
|
||||||
appendValue(stringValue(params["key_points"]))
|
default:
|
||||||
appendValue(stringValue(params["review_intro_hook"]))
|
if name := strings.TrimSpace(templateName); name != "" {
|
||||||
|
return name
|
||||||
return strings.Join(parts, "\n")
|
}
|
||||||
|
return "文章生成"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTemplateSpecificWritingRules(templateKey string, params map[string]interface{}) string {
|
func buildTemplateSpecificWritingRules(templateKey string, params map[string]interface{}) string {
|
||||||
|
|||||||
@@ -491,7 +491,7 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
|||||||
ctx,
|
ctx,
|
||||||
job.TenantID,
|
job.TenantID,
|
||||||
knowledgeGroupIDs,
|
knowledgeGroupIDs,
|
||||||
buildGenerationKnowledgeQuery(job.Params),
|
buildGenerationKnowledgeQuery(job.TemplateKey, job.TemplateName, job.Params),
|
||||||
)
|
)
|
||||||
if resolveErr != nil {
|
if resolveErr != nil {
|
||||||
s.failGeneration(ctx, job, title, "knowledge_resolve", resolveErr)
|
s.failGeneration(ctx, job, title, "knowledge_resolve", resolveErr)
|
||||||
@@ -604,7 +604,7 @@ func (s *TemplateService) failGeneration(ctx context.Context, job generationJob,
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
logCtx := generationJobLogContext(job, stage, "", "failed", GenerationTaskResultFailure)
|
logCtx := generationJobLogContext(job, stage, "", "failed", GenerationTaskResultFailure)
|
||||||
RecordGenerationTaskEvent(GenerationTaskEventFailure, logCtx)
|
RecordGenerationTaskEvent(GenerationTaskEventFailure, logCtx)
|
||||||
LogGenerationTaskWarn(s.logger, "template generation failed", logCtx, zap.Error(genErr))
|
LogGenerationTaskWarn(s.logger, "template generation failed", logCtx, GenerationTaskErrorFields(genErr)...)
|
||||||
|
|
||||||
auditRepo := repository.NewAuditRepository(s.pool)
|
auditRepo := repository.NewAuditRepository(s.pool)
|
||||||
articleRepo := repository.NewArticleRepository(s.pool)
|
articleRepo := repository.NewArticleRepository(s.pool)
|
||||||
|
|||||||
@@ -241,15 +241,6 @@ func (h *MediaSupplyHandler) SessionStatus(c *gin.Context) {
|
|||||||
response.Success(c, data)
|
response.Success(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MediaSupplyHandler) SyncBacklinks(c *gin.Context) {
|
|
||||||
data, err := h.svc.SyncBacklinks(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
response.Error(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.Success(c, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseOptionalIntQuery(c *gin.Context, key string) (int, error) {
|
func parseOptionalIntQuery(c *gin.Context, key string) (int, error) {
|
||||||
raw := c.Query(key)
|
raw := c.Query(key)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
mediaSupply.PUT("/resources/:id/price", mediaSupplyHandler.SetResourcePrice)
|
mediaSupply.PUT("/resources/:id/price", mediaSupplyHandler.SetResourcePrice)
|
||||||
mediaSupply.POST("/orders", middleware.RequireCurrentBrand(), mediaSupplyHandler.CreateOrder)
|
mediaSupply.POST("/orders", middleware.RequireCurrentBrand(), mediaSupplyHandler.CreateOrder)
|
||||||
mediaSupply.GET("/orders", mediaSupplyHandler.ListOrders)
|
mediaSupply.GET("/orders", mediaSupplyHandler.ListOrders)
|
||||||
mediaSupply.POST("/orders/sync-backlinks", mediaSupplyHandler.SyncBacklinks)
|
|
||||||
mediaSupply.GET("/orders/:id", mediaSupplyHandler.GetOrder)
|
mediaSupply.GET("/orders/:id", mediaSupplyHandler.GetOrder)
|
||||||
mediaSupply.GET("/wallet", mediaSupplyHandler.WalletStatus)
|
mediaSupply.GET("/wallet", mediaSupplyHandler.WalletStatus)
|
||||||
mediaSupply.GET("/wallet/ledgers", mediaSupplyHandler.ListWalletLedgers)
|
mediaSupply.GET("/wallet/ledgers", mediaSupplyHandler.ListWalletLedgers)
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ func (w *ArticleGenerationWorker) handleTaskFailure(ctx context.Context, task te
|
|||||||
task.TaskType = strings.TrimSpace(task.TaskType)
|
task.TaskType = strings.TrimSpace(task.TaskType)
|
||||||
logCtx := generationWorkerTaskLogContext(task, stage, "", "failed", tenantapp.GenerationTaskResultFailure, 0, "")
|
logCtx := generationWorkerTaskLogContext(task, stage, "", "failed", tenantapp.GenerationTaskResultFailure, 0, "")
|
||||||
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventFailure, logCtx)
|
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventFailure, logCtx)
|
||||||
tenantapp.LogGenerationTaskWarn(w.logger, "article generation task failed", logCtx, zap.Error(taskErr))
|
tenantapp.LogGenerationTaskWarn(w.logger, "article generation task failed", logCtx, tenantapp.GenerationTaskErrorFields(taskErr)...)
|
||||||
var err error
|
var err error
|
||||||
switch {
|
switch {
|
||||||
case task.TaskType == kolGenerationTaskType && w.kolWorker != nil:
|
case task.TaskType == kolGenerationTaskType && w.kolWorker != nil:
|
||||||
|
|||||||
@@ -333,15 +333,11 @@ func dedupeKolIDs(ids []int64) []int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildKolKnowledgeQuery(input map[string]interface{}) string {
|
func buildKolKnowledgeQuery(input map[string]interface{}) string {
|
||||||
parts := make([]string, 0, 8)
|
promptName := strings.TrimSpace(kolStringInput(input, "prompt_name"))
|
||||||
if promptName := strings.TrimSpace(kolStringInput(input, "prompt_name")); promptName != "" {
|
platformHint := strings.TrimSpace(kolStringInput(input, "platform_hint"))
|
||||||
parts = append(parts, promptName)
|
|
||||||
}
|
|
||||||
if platformHint := strings.TrimSpace(kolStringInput(input, "platform_hint")); platformHint != "" {
|
|
||||||
parts = append(parts, platformHint)
|
|
||||||
}
|
|
||||||
|
|
||||||
variables, _ := input["variables"].(map[string]interface{})
|
variables, _ := input["variables"].(map[string]interface{})
|
||||||
|
keywords := make([]string, 0)
|
||||||
|
keyPoints := make([]string, 0)
|
||||||
if len(variables) > 0 {
|
if len(variables) > 0 {
|
||||||
keys := make([]string, 0, len(variables))
|
keys := make([]string, 0, len(variables))
|
||||||
for key := range variables {
|
for key := range variables {
|
||||||
@@ -353,17 +349,41 @@ func buildKolKnowledgeQuery(input map[string]interface{}) string {
|
|||||||
if valueText == "" {
|
if valueText == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts = append(parts, fmt.Sprintf("%s: %s", key, valueText))
|
switch {
|
||||||
|
case strings.Contains(strings.ToLower(key), "keyword") || strings.Contains(key, "关键词"):
|
||||||
|
keywords = append(keywords, valueText)
|
||||||
|
case strings.Contains(strings.ToLower(key), "brand") || strings.Contains(key, "品牌"):
|
||||||
|
keyPoints = append(keyPoints, fmt.Sprintf("%s: %s", key, valueText))
|
||||||
|
default:
|
||||||
|
keyPoints = append(keyPoints, fmt.Sprintf("%s: %s", key, valueText))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(parts) == 0 {
|
renderedPrompt := strings.TrimSpace(kolStringInput(input, "rendered_prompt"))
|
||||||
if renderedPrompt := strings.TrimSpace(kolStringInput(input, "rendered_prompt")); renderedPrompt != "" {
|
query := tenantapp.BuildKnowledgeQuery(tenantapp.KnowledgeQueryInput{
|
||||||
parts = append(parts, renderedPrompt)
|
TaskType: firstNonEmptyKolText(platformHint, "KOL 内容生成"),
|
||||||
|
TemplateName: promptName,
|
||||||
|
Title: firstNonEmptyKolText(promptName, renderedPrompt),
|
||||||
|
Topic: firstNonEmptyKolText(promptName, renderedPrompt),
|
||||||
|
PrimaryKeyword: firstNonEmptyKolText(strings.Join(keywords, "、"), promptName),
|
||||||
|
Keywords: keywords,
|
||||||
|
KeyPoints: strings.Join(keyPoints, "\n"),
|
||||||
|
Extra: renderedPrompt,
|
||||||
|
})
|
||||||
|
if strings.TrimSpace(query) != "" {
|
||||||
|
return kolLimitText(query, 2000)
|
||||||
|
}
|
||||||
|
return kolLimitText(renderedPrompt, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyKolText(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if text := strings.TrimSpace(value); text != "" {
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
return kolLimitText(strings.Join(parts, "\n"), 2000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func kolValueString(value interface{}) string {
|
func kolValueString(value interface{}) string {
|
||||||
|
|||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
DELETE FROM ops.scheduler_job_triggers
|
||||||
|
WHERE job_key = 'media_supply_cache_sync';
|
||||||
|
|
||||||
|
DELETE FROM ops.scheduler_job_runs
|
||||||
|
WHERE job_key = 'media_supply_cache_sync';
|
||||||
|
|
||||||
|
DELETE FROM ops.scheduler_jobs
|
||||||
|
WHERE job_key = 'media_supply_cache_sync';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
INSERT INTO ops.scheduler_jobs
|
||||||
|
(job_key, display_name, category, description, enabled, schedule_type, interval_seconds, timezone, timeout_seconds, batch_size, config)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'media_supply_cache_sync',
|
||||||
|
'媒体资源缓存同步',
|
||||||
|
'media_supply',
|
||||||
|
'每日低频同步权威媒体资源缓存,并在成本价上涨超过手动售价时清理手动改价。',
|
||||||
|
true,
|
||||||
|
'interval',
|
||||||
|
86400,
|
||||||
|
'Asia/Shanghai',
|
||||||
|
3600,
|
||||||
|
1,
|
||||||
|
'{"model_id":1,"random_window_start":"01:00","random_window_end":"04:00","dedupe_manual_triggers":true}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (job_key) DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
schedule_type = EXCLUDED.schedule_type,
|
||||||
|
interval_seconds = EXCLUDED.interval_seconds,
|
||||||
|
timezone = EXCLUDED.timezone,
|
||||||
|
timeout_seconds = EXCLUDED.timeout_seconds,
|
||||||
|
batch_size = EXCLUDED.batch_size,
|
||||||
|
config = ops.scheduler_jobs.config || EXCLUDED.config,
|
||||||
|
updated_at = NOW();
|
||||||
Reference in New Issue
Block a user