gongxiangp2/lib/performance-conversion.ts

845 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { randomUUID } from 'node:crypto'
import { hostname } from 'node:os'
import type { Session } from 'next-auth'
import mssql from 'mssql'
import {
fetchPerformanceSourceRows,
metricSourceForBrand,
readTmallStoreSourceRanges,
sourceMetricDateKey as metricDateKey,
sourceSnapshotFilter,
supportsMetricPlatform,
TMALL_STORE_BRANDS,
type MetricSource,
type SourceMetricRow,
type StoreSourceRange,
type TmallStoreBrand,
} from '@/lib/performance-conversion-source'
import { assertBrandCenterDashboardAccess } from '@/lib/brand-center-dashboard-access'
import { prisma } from '@/lib/prisma'
import {
DING_WORKFLOW_ALLOWED_PROCESS_CODES,
DING_WORKFLOW_STARTED_AT_CUTOFF_ISO,
getDingWorkflowSyncMetadata,
isDingWorkflowApprovedCompletion,
} from '@/lib/ding-workflow-sync'
import {
BUSINESS_TREND_BRANDS,
addCalendarMonths,
compareDateKeys,
daysBetweenInclusive,
extractValidProductIds,
isValidDateKey,
toShanghaiDateKey,
type PerformanceConversionBrand,
type PerformanceConversionDailyMetric,
type PerformanceConversionTimeseries,
type PerformanceConversionWorkflowItem,
type PerformanceConversionWorkflowList,
} from '@/components/department-dashboard/performance-conversion'
const ART_PROCESS_CODE = DING_WORKFLOW_ALLOWED_PROCESS_CODES[0]
const VALID_BRANDS = new Set<string>(BUSINESS_TREND_BRANDS)
const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 50
const SOURCE_CACHE_TTL_MS = 5 * 60 * 1000
const PERFORMANCE_CONVERSION_SYNC_STATE_ID = 'performance-conversion-daily-v1'
const SYNC_LEASE_DURATION_MS = 20 * 60 * 1000
const SYNC_INITIAL_DELAY_MS = 30 * 1000
const SYNC_DAY_MS = 24 * 60 * 60 * 1000
const SYNC_LOOKBACK_DAYS = 2
export class PerformanceConversionError extends Error {
status: number
constructor(message: string, status = 400) {
super(message)
this.name = 'PerformanceConversionError'
this.status = status
}
}
type SnapshotRow = {
processInstanceId: string
businessId: string | null
title: string | null
result: string | null
bizAction: string | null
status: string
processCode: string | null
brand: string | null
sourceFinishedAtRaw: string | null
formFields: unknown
}
type CachedSourceRows = { expiresAt: number; rows: SourceMetricRow[] }
const sourceCache = new Map<string, CachedSourceRows>()
type PerformanceConversionSourceCursor = {
version: 2
lastSourceDate: string | null
tmallStoreDates: Partial<Record<TmallStoreBrand, string | null>>
}
export type PerformanceConversionSyncStatus = 'pending' | 'syncing' | 'ready' | 'error'
export type PerformanceConversionSyncMetadata = {
status: PerformanceConversionSyncStatus
lastCompletedAt: string | null
lastSourceDate: string | null
}
function clean(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized && normalized.toUpperCase() !== 'NULL' && normalized !== '--' ? normalized : null
}
function formRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {}
}
function workflowItems(row: SnapshotRow): PerformanceConversionWorkflowItem[] {
const brand = clean(row.brand)
const completedAt = clean(row.sourceFinishedAtRaw)
if (!brand || !VALID_BRANDS.has(brand) || !completedAt) return []
const monitorStartDate = toShanghaiDateKey(completedAt)
if (!monitorStartDate) return []
const fields = formRecord(row.formFields)
const ids = extractValidProductIds(fields.productId, fields.currentProductId)
const monitorEndDate = addCalendarMonths(monitorStartDate, 6)
const today = toShanghaiDateKey(new Date()) || monitorStartDate
return ids.map((productId) => ({
key: `${row.processInstanceId}:${productId}`,
workflowId: row.processInstanceId,
approvalNumber: clean(row.businessId),
processCode: clean(row.processCode) || ART_PROCESS_CODE,
title: clean(row.title) || '未命名流程',
brand: brand as PerformanceConversionBrand,
productId,
spu: clean(fields.spu),
platform: clean(fields.platform),
completedAt,
monitorStartDate,
monitorEndDate,
monitoringStatus: compareDateKeys(today, monitorEndDate) <= 0 ? 'monitoring' : 'complete',
}))
}
function isEligibleSnapshot(row: SnapshotRow) {
return row.processCode === ART_PROCESS_CODE
&& Boolean(row.sourceFinishedAtRaw)
&& isDingWorkflowApprovedCompletion(row.status, row.result, row.bizAction)
}
async function readEligibleSnapshots(): Promise<SnapshotRow[]> {
const rows = await prisma.dingWorkflowSnapshot.findMany({
where: {
processCode: ART_PROCESS_CODE,
sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO },
sourceFinishedAtRaw: { not: null },
},
select: {
processInstanceId: true,
businessId: true,
title: true,
result: true,
bizAction: true,
status: true,
processCode: true,
brand: true,
sourceFinishedAtRaw: true,
formFields: true,
},
orderBy: [
{ sourceFinishedAtRaw: 'desc' },
{ processInstanceId: 'desc' },
],
})
return rows.filter(isEligibleSnapshot)
}
export async function getPerformanceConversionWorkflows(
session: Session | null,
input: {
brand?: string | null
page?: number
pageSize?: number
query?: string | null
completedFrom?: string | null
completedTo?: string | null
} = {},
): Promise<PerformanceConversionWorkflowList> {
await assertBrandCenterDashboardAccess(session)
const brand = clean(input.brand) || BUSINESS_TREND_BRANDS[0]
if (!VALID_BRANDS.has(brand)) throw new PerformanceConversionError('品牌参数无效')
const page = Math.max(1, Math.floor(Number(input.page) || 1))
const pageSize = Math.max(1, Math.min(MAX_PAGE_SIZE, Math.floor(Number(input.pageSize) || DEFAULT_PAGE_SIZE)))
const query = clean(input.query)?.toLocaleLowerCase('zh-CN') || ''
const completedFrom = clean(input.completedFrom)
const completedTo = clean(input.completedTo)
if (completedFrom && !isValidDateKey(completedFrom)) throw new PerformanceConversionError('完结开始日期格式无效')
if (completedTo && !isValidDateKey(completedTo)) throw new PerformanceConversionError('完结结束日期格式无效')
if (completedFrom && completedTo && compareDateKeys(completedFrom, completedTo) > 0) {
throw new PerformanceConversionError('完结开始日期不能晚于结束日期')
}
const [snapshots, workflowSync, performanceSync] = await Promise.all([
readEligibleSnapshots(),
getDingWorkflowSyncMetadata(),
getPerformanceConversionSyncMetadata(brand as PerformanceConversionBrand),
])
const rangeSnapshots = snapshots.filter((row) => {
const completedDate = row.sourceFinishedAtRaw ? toShanghaiDateKey(row.sourceFinishedAtRaw) : null
if (!completedDate) return false
return (!completedFrom || compareDateKeys(completedDate, completedFrom) >= 0)
&& (!completedTo || compareDateKeys(completedDate, completedTo) <= 0)
})
const allItems = rangeSnapshots.flatMap(workflowItems)
const brandCounts = new Map<PerformanceConversionBrand, number>(
BUSINESS_TREND_BRANDS.map((item) => [item, 0]),
)
allItems.forEach((item) => brandCounts.set(item.brand, (brandCounts.get(item.brand) || 0) + 1))
const brandSnapshots = rangeSnapshots.filter((row) => row.brand === brand)
const invalidProductIdCount = brandSnapshots.filter((row) => workflowItems(row).length === 0).length
const filtered = allItems.filter((item) => item.brand === brand && (!query || [
item.productId,
item.spu,
item.approvalNumber,
item.workflowId,
item.title,
].some((value) => String(value || '').toLocaleLowerCase('zh-CN').includes(query))))
const offset = (page - 1) * pageSize
return {
brands: BUSINESS_TREND_BRANDS.map((item) => ({ brand: item, count: brandCounts.get(item) || 0 })),
items: filtered.slice(offset, offset + pageSize),
total: filtered.length,
invalidProductIdCount,
page,
pageSize,
hasMore: offset + pageSize < filtered.length,
workflowSync,
performanceSync,
}
}
function sourceDbConfigured() {
return Boolean(
process.env.REPORT_DB_SERVER
&& process.env.REPORT_DB_DATABASE
&& process.env.REPORT_DB_USER
&& process.env.REPORT_DB_PASSWORD,
)
}
function sourceConfig() {
if (!sourceDbConfigured()) throw new PerformanceConversionError('经营数据源尚未配置', 503)
return {
server: process.env.REPORT_DB_SERVER as string,
database: process.env.REPORT_DB_DATABASE as string,
user: process.env.REPORT_DB_USER as string,
password: process.env.REPORT_DB_PASSWORD as string,
port: Number(process.env.REPORT_DB_PORT || 1433),
options: {
encrypt: process.env.REPORT_DB_ENCRYPT === 'true',
trustServerCertificate: process.env.REPORT_DB_TRUST_SERVER_CERTIFICATE !== 'false',
enableArithAbort: true,
},
connectionTimeout: 15_000,
requestTimeout: 60_000,
pool: { max: 4, min: 0, idleTimeoutMillis: 10_000 },
}
}
function dateKeyToUtcDate(value: string) {
return new Date(`${value}T00:00:00.000Z`)
}
function shiftDateKey(value: string, days: number) {
const date = dateKeyToUtcDate(value)
date.setUTCDate(date.getUTCDate() + days)
return date.toISOString().slice(0, 10)
}
function maxDateKey(left: string, right: string) {
return compareDateKeys(left, right) >= 0 ? left : right
}
function parsePerformanceConversionSourceCursor(value: string | null): PerformanceConversionSourceCursor {
const empty: PerformanceConversionSourceCursor = { version: 2, lastSourceDate: null, tmallStoreDates: {} }
if (!value) return empty
try {
const parsed = JSON.parse(value) as Partial<PerformanceConversionSourceCursor>
const tmallStoreDates: PerformanceConversionSourceCursor['tmallStoreDates'] = {}
if (parsed.version === 2 && parsed.tmallStoreDates && typeof parsed.tmallStoreDates === 'object') {
for (const brand of TMALL_STORE_BRANDS) {
const date = parsed.tmallStoreDates[brand]
if (date === null || isValidDateKey(date)) tmallStoreDates[brand] = date
}
}
return {
version: 2,
lastSourceDate: typeof parsed.lastSourceDate === 'string' && isValidDateKey(parsed.lastSourceDate)
? parsed.lastSourceDate
: null,
tmallStoreDates,
}
} catch {
return empty
}
}
function serializePerformanceConversionSourceCursor(cursor: PerformanceConversionSourceCursor) {
return JSON.stringify(cursor)
}
function normalizePerformanceConversionSyncStatus(state: { status: string; lastCompletedAt: Date | null } | null): PerformanceConversionSyncStatus {
if (!state?.lastCompletedAt) {
return state?.status === 'syncing' ? 'syncing' : state?.status === 'error' ? 'error' : 'pending'
}
if (state.status === 'syncing') return 'syncing'
if (state.status === 'error') return 'error'
return 'ready'
}
export async function getPerformanceConversionSyncMetadata(brand?: PerformanceConversionBrand): Promise<PerformanceConversionSyncMetadata> {
// Keep isolated unit tests and pre-migration local shells readable while the
// additive table is being introduced. Production Prisma clients always have
// this delegate after the migration has been applied.
const syncState = (prisma as typeof prisma & {
performanceConversionSyncState?: typeof prisma.performanceConversionSyncState
}).performanceConversionSyncState
if (!syncState) {
return { status: 'pending', lastCompletedAt: null, lastSourceDate: null }
}
try {
const state = await syncState.findUnique({
where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID },
select: { status: true, lastCompletedAt: true, sourceCursorRaw: true },
})
const cursor = parsePerformanceConversionSourceCursor(state?.sourceCursorRaw || null)
const source = brand ? metricSourceForBrand(brand) : { kind: 'legacy' as const }
if (source.kind === 'tmall-store' && !(source.brand in cursor.tmallStoreDates)) {
return { status: state?.status === 'error' ? 'error' : state?.status === 'syncing' ? 'syncing' : 'pending', lastCompletedAt: null, lastSourceDate: null }
}
return {
status: normalizePerformanceConversionSyncStatus(state),
lastCompletedAt: state?.lastCompletedAt?.toISOString() || null,
lastSourceDate: source.kind === 'tmall-store' ? cursor.tmallStoreDates[source.brand] || null : cursor.lastSourceDate,
}
} catch (error) {
console.warn('[performance-conversion-sync] 同步状态读取失败,暂按首次同步处理:', error)
return { status: 'pending', lastCompletedAt: null, lastSourceDate: null }
}
}
async function readSnapshotRows(source: MetricSource, productId: string, startDate: string, endDate: string) {
const rows = await prisma.performanceConversionMetricSnapshot.findMany({
where: {
productId,
...sourceSnapshotFilter(source),
metricDate: {
gte: dateKeyToUtcDate(startDate),
lte: dateKeyToUtcDate(endDate),
},
},
orderBy: [{ metricDate: 'asc' }, { sourceRowId: 'asc' }],
})
return rows.map((row) => ({
sourceRowId: row.sourceRowId,
metricDate: row.metricDate,
productId: row.productId,
productName: row.productName,
sourceBrand: row.sourceBrand,
category1: row.category1,
category2: row.category2,
category3: row.category3,
paymentAmount: row.paymentAmount,
paymentUsers: row.paymentUsers,
cartUsers: row.cartUsers,
visitors: row.visitors,
conversionRate: row.conversionRate,
favorites: row.favorites,
impressions: row.impressions,
clicks: row.clicks,
clickRate: row.clickRate,
promotionSpend: row.promotionSpend,
promotionRevenue: row.promotionRevenue,
roi: row.roi,
})) satisfies SourceMetricRow[]
}
async function readUpstreamSourceRows(source: MetricSource, productId: string, startDate: string, endDate: string) {
const cacheKey = `upstream:${source.kind === 'tmall-store' ? source.keyPrefix : 'legacy'}:${productId}:${startDate}:${endDate}`
const cached = sourceCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) return cached.rows
const pool = new mssql.ConnectionPool(sourceConfig())
try {
await pool.connect()
const rows = await fetchPerformanceSourceRows(pool, source, productId, startDate, endDate)
sourceCache.set(cacheKey, { rows, expiresAt: Date.now() + SOURCE_CACHE_TTL_MS })
return rows
} finally {
await pool.close().catch(() => undefined)
}
}
async function readSourceRows(brand: PerformanceConversionBrand, productId: string, startDate: string, endDate: string) {
const source = metricSourceForBrand(brand)
const sync = await getPerformanceConversionSyncMetadata(brand)
if (sync.lastCompletedAt) {
const localRows = await readSnapshotRows(source, productId, startDate, endDate)
if (localRows.length > 0) {
return localRows
}
// A newly synced workflow may not be included in the previous daily run.
// Keep the old direct read only for that bootstrap gap; the next scheduled
// run will persist the same product into the local snapshot.
}
return readUpstreamSourceRows(source, productId, startDate, endDate)
}
type MetricProductWindow = {
source: MetricSource
productId: string
startDate: string
endDate: string
}
function getMetricProductWindows(snapshots: SnapshotRow[], today: string): MetricProductWindow[] {
const windows = new Map<string, MetricProductWindow>()
snapshots.flatMap(workflowItems).forEach((item) => {
const source = metricSourceForBrand(item.brand)
if (!supportsMetricPlatform(source, item.platform)) return
const key = `${source.kind === 'tmall-store' ? source.keyPrefix : 'legacy'}:${item.productId}`
const endDate = compareDateKeys(today, item.monitorEndDate) <= 0 ? today : item.monitorEndDate
const existing = windows.get(key)
if (!existing) {
windows.set(key, { source, productId: item.productId, startDate: item.monitorStartDate, endDate })
return
}
existing.startDate = compareDateKeys(item.monitorStartDate, existing.startDate) < 0
? item.monitorStartDate
: existing.startDate
existing.endDate = maxDateKey(existing.endDate, endDate)
})
return Array.from(windows.values()).filter((item) => compareDateKeys(item.startDate, item.endDate) <= 0)
}
async function acquirePerformanceConversionSyncLease(leaseOwner: string, startedAt: Date) {
await prisma.performanceConversionSyncState.upsert({
where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID },
create: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID },
update: {},
})
const claimed = await prisma.performanceConversionSyncState.updateMany({
where: {
id: PERFORMANCE_CONVERSION_SYNC_STATE_ID,
OR: [
{ leaseExpiresAt: null },
{ leaseExpiresAt: { lt: startedAt } },
{ leaseOwner },
],
},
data: {
status: 'syncing',
lastStartedAt: startedAt,
lastError: null,
leaseOwner,
leaseExpiresAt: new Date(startedAt.getTime() + SYNC_LEASE_DURATION_MS),
},
})
if (claimed.count !== 1) return null
return prisma.performanceConversionSyncState.findUniqueOrThrow({
where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID },
})
}
async function renewPerformanceConversionSyncLease(leaseOwner: string) {
const renewed = await prisma.performanceConversionSyncState.updateMany({
where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID, leaseOwner },
data: { leaseExpiresAt: new Date(Date.now() + SYNC_LEASE_DURATION_MS) },
})
if (renewed.count !== 1) throw new Error('经营数据同步租约已失效,本轮停止写入')
}
function toMetricSnapshotData(row: SourceMetricRow, source: MetricSource, productId: string, syncedAt: Date, index: number) {
const metricDate = metricDateKey(row.metricDate)
if (!metricDate) return null
const sourceRowId = clean(row.sourceRowId)
return {
id: randomUUID(),
sourceKey: source.kind === 'tmall-store'
? `${source.keyPrefix}${productId}:${metricDate}`
: `${productId}:${metricDate}:${sourceRowId || `row-${index}`}`,
sourceRowId,
productId,
metricDate: dateKeyToUtcDate(metricDate),
productName: clean(row.productName),
sourceBrand: clean(row.sourceBrand),
category1: clean(row.category1),
category2: clean(row.category2),
category3: clean(row.category3),
paymentAmount: clean(row.paymentAmount),
paymentUsers: clean(row.paymentUsers),
cartUsers: clean(row.cartUsers),
visitors: clean(row.visitors),
conversionRate: clean(row.conversionRate),
favorites: clean(row.favorites),
impressions: clean(row.impressions),
clicks: clean(row.clicks),
clickRate: clean(row.clickRate),
promotionSpend: clean(row.promotionSpend),
promotionRevenue: clean(row.promotionRevenue),
roi: clean(row.roi),
syncedAt,
createdAt: syncedAt,
updatedAt: syncedAt,
}
}
export async function syncPerformanceConversionMetrics() {
if (!sourceDbConfigured()) throw new Error('未配置上游报表库(REPORT_DB_*),无法同步经营数据')
const startedAt = new Date()
const leaseOwner = `${hostname()}:${process.pid}:${randomUUID()}`
const state = await acquirePerformanceConversionSyncLease(leaseOwner, startedAt)
if (!state) return { skipped: true, rowsUpserted: 0, productsSynced: 0 }
const pool = new mssql.ConnectionPool(sourceConfig())
let rowsUpserted = 0
let productsSynced = 0
try {
await pool.connect()
const cursor = parsePerformanceConversionSourceCursor(state.sourceCursorRaw)
const today = toShanghaiDateKey(new Date())
if (!today) throw new Error('无法确定上海时区的当前日期')
const snapshots = await readEligibleSnapshots()
const windows = getMetricProductWindows(snapshots, today)
const storeRanges = windows.some((window) => window.source.kind === 'tmall-store')
? await readTmallStoreSourceRanges(pool, today)
: new Map<TmallStoreBrand, StoreSourceRange>()
const tmallStoreDates = { ...cursor.tmallStoreDates }
const syncStartedAt = new Date()
for (const window of windows) {
await renewPerformanceConversionSyncLease(leaseOwner)
const source = window.source
const scopeFilter = sourceSnapshotFilter(source)
const existing = await prisma.performanceConversionMetricSnapshot.findFirst({
where: { productId: window.productId, ...scopeFilter },
select: { id: true },
})
// The legacy cursor cannot stand in for the first sync of a new source.
const lastSourceDate = source.kind === 'tmall-store' ? cursor.tmallStoreDates[source.brand] : cursor.lastSourceDate
const incrementalStart = lastSourceDate
? shiftDateKey(lastSourceDate, -SYNC_LOOKBACK_DAYS)
: window.startDate
let startDate = existing ? maxDateKey(window.startDate, incrementalStart) : window.startDate
let endDate = window.endDate
if (source.kind === 'tmall-store') {
const retainedRange = storeRanges.get(source.brand)
// An empty/missing store must not purge its previously retained history.
if (!retainedRange) continue
startDate = maxDateKey(startDate, retainedRange.firstDate)
endDate = compareDateKeys(endDate, retainedRange.lastDate) < 0 ? endDate : retainedRange.lastDate
tmallStoreDates[source.brand] = retainedRange.correctionThrough
}
if (compareDateKeys(startDate, endDate) > 0) continue
const sourceRows = await fetchPerformanceSourceRows(pool, source, window.productId, startDate, endDate)
const snapshotsToWrite = sourceRows
.map((row, index) => toMetricSnapshotData(row, source, window.productId, syncStartedAt, index))
.filter((row): row is NonNullable<ReturnType<typeof toMetricSnapshotData>> => row !== null)
await prisma.$transaction(async (tx) => {
// The supplemental ROI view can lag the Overall view or roll away a
// month. Preserve only prior supplemental values when its row is absent;
// a present row with an explicit null/zero remains authoritative.
if (source.kind === 'tmall-store' && sourceRows.some((row) => !row.promotionSourceRowId)) {
const previous = await tx.performanceConversionMetricSnapshot.findMany({
where: {
productId: window.productId, ...scopeFilter,
metricDate: { gte: dateKeyToUtcDate(startDate), lte: dateKeyToUtcDate(endDate) },
},
select: { sourceKey: true, impressions: true, promotionSpend: true, promotionRevenue: true, roi: true },
})
const byKey = new Map(previous.map((row) => [row.sourceKey, row]))
snapshotsToWrite.forEach((snapshot, index) => {
const prior = byKey.get(snapshot.sourceKey)
if (prior && !sourceRows[index].promotionSourceRowId) {
snapshot.impressions = prior.impressions
snapshot.promotionSpend = prior.promotionSpend
snapshot.promotionRevenue = prior.promotionRevenue
snapshot.roi = prior.roi
}
})
}
// Fence the replacement in the same transaction as its writes.
const lease = await tx.performanceConversionSyncState.updateMany({
where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID, leaseOwner, leaseExpiresAt: { gt: new Date() } },
data: { leaseExpiresAt: new Date(Date.now() + SYNC_LEASE_DURATION_MS) },
})
if (lease.count !== 1) throw new Error('经营数据同步租约已失效,本轮停止写入')
await tx.performanceConversionMetricSnapshot.deleteMany({
where: {
productId: window.productId,
...scopeFilter,
metricDate: { gte: dateKeyToUtcDate(startDate), lte: dateKeyToUtcDate(endDate) },
// Monthly views are not a deletion feed: missing source dates must
// not remove already ingested daily history.
...(source.kind === 'tmall-store' ? { AND: { sourceKey: { in: snapshotsToWrite.map((row) => row.sourceKey) } } } : {}),
},
})
if (snapshotsToWrite.length > 0) {
await tx.performanceConversionMetricSnapshot.createMany({ data: snapshotsToWrite })
}
})
rowsUpserted += snapshotsToWrite.length
productsSynced += 1
}
sourceCache.clear()
const sourceCursorRaw = serializePerformanceConversionSourceCursor({ version: 2, lastSourceDate: today, tmallStoreDates })
const completed = await prisma.performanceConversionSyncState.updateMany({
where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID, leaseOwner },
data: {
status: 'ready',
sourceCursorRaw,
lastCompletedAt: syncStartedAt,
lastError: null,
leaseOwner: null,
leaseExpiresAt: null,
rowsUpserted,
productsSynced,
},
})
if (completed.count !== 1) throw new Error('经营数据同步租约已失效,本轮不能标记为成功')
return { skipped: false, rowsUpserted, productsSynced }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await prisma.performanceConversionSyncState.updateMany({
where: { id: PERFORMANCE_CONVERSION_SYNC_STATE_ID, leaseOwner },
data: {
status: 'error',
lastError: message.slice(0, 2_000),
leaseOwner: null,
leaseExpiresAt: null,
},
}).catch(() => undefined)
throw error
} finally {
await pool.close().catch(() => undefined)
}
}
type PerformanceConversionSyncGlobal = typeof globalThis & {
__performanceConversionDailySyncStarted?: boolean
}
export function ensureDailyPerformanceConversionSyncStarted() {
const schedulerGlobal = globalThis as PerformanceConversionSyncGlobal
if (schedulerGlobal.__performanceConversionDailySyncStarted) return
schedulerGlobal.__performanceConversionDailySyncStarted = true
if (!sourceDbConfigured()) {
console.info('[performance-conversion-sync] 未配置上游报表库,跳过每日经营数据同步')
return
}
const runOnce = () => {
void syncPerformanceConversionMetrics()
.then((result) => {
if (result.skipped) {
console.info('[performance-conversion-sync] 另一实例正在同步,本轮跳过')
return
}
console.info(`[performance-conversion-sync] 同步完成:更新 ${result.rowsUpserted} 条,商品 ${result.productsSynced} 个`)
})
.catch((error) => console.warn('[performance-conversion-sync] 同步失败:', error))
}
const initialTimer = setTimeout(runOnce, SYNC_INITIAL_DELAY_MS)
initialTimer.unref?.()
const scheduleNext = () => {
const timer = setTimeout(() => {
runOnce()
scheduleNext()
}, nextPerformanceConversionSyncDelay(new Date()))
timer.unref?.()
}
scheduleNext()
console.info('[performance-conversion-sync] 已调度经营数据每日增量同步:上海时间 15:00(上游视图 14:30 刷新),按来源日期回看两天校准')
}
export function nextPerformanceConversionSyncDelay(now: Date) {
// 15:00 Asia/Shanghai is 07:00 UTC, independent of the host timezone.
const next = new Date(now)
next.setUTCHours(7, 0, 0, 0)
if (next.getTime() <= now.getTime()) next.setTime(next.getTime() + SYNC_DAY_MS)
return next.getTime() - now.getTime()
}
function numberValue(value: unknown) {
const normalized = clean(value)?.replace(/[,%¥¥\s]/g, '')
if (!normalized) return null
const parsed = Number(normalized)
if (!Number.isFinite(parsed)) return null
return String(value).includes('%') ? parsed / 100 : parsed
}
function sum(values: Array<number | null>) {
const present = values.filter((value): value is number => value !== null)
return present.length ? present.reduce((total, value) => total + value, 0) : null
}
function average(values: Array<number | null>) {
const present = values.filter((value): value is number => value !== null)
return present.length ? present.reduce((total, value) => total + value, 0) / present.length : null
}
function ratio(numerator: number | null, denominator: number | null, fallback: number | null) {
return denominator !== null && denominator > 0 && numerator !== null ? numerator / denominator : fallback
}
function aggregateRows(rows: SourceMetricRow[], source: MetricSource) {
const grouped = new Map<string, SourceMetricRow[]>()
rows.forEach((row) => {
const key = metricDateKey(row.metricDate)
if (key) grouped.set(key, [...(grouped.get(key) || []), row])
})
return Array.from(grouped.entries()).sort(([left], [right]) => left.localeCompare(right)).map(([date, dayRows]): PerformanceConversionDailyMetric => {
const impressions = sum(dayRows.map((row) => numberValue(row.impressions)))
const clicks = sum(dayRows.map((row) => numberValue(row.clicks)))
const visitors = sum(dayRows.map((row) => numberValue(row.visitors)))
const paymentUsers = sum(dayRows.map((row) => numberValue(row.paymentUsers)))
const promotionSpend = sum(dayRows.map((row) => numberValue(row.promotionSpend)))
const promotionRevenue = sum(dayRows.map((row) => numberValue(row.promotionRevenue)))
return {
date,
impressions,
visitors,
clickRate: source.kind === 'tmall-store'
? average(dayRows.map((row) => numberValue(row.clickRate)))
: ratio(clicks, impressions, average(dayRows.map((row) => numberValue(row.clickRate)))),
favorites: sum(dayRows.map((row) => numberValue(row.favorites))),
cartUsers: sum(dayRows.map((row) => numberValue(row.cartUsers))),
paymentConversionRate: source.kind === 'tmall-store'
? average(dayRows.map((row) => numberValue(row.conversionRate)))
: ratio(paymentUsers, visitors, average(dayRows.map((row) => numberValue(row.conversionRate)))),
paymentAmount: sum(dayRows.map((row) => numberValue(row.paymentAmount))),
roi: source.kind === 'tmall-store'
? average(dayRows.map((row) => numberValue(row.roi)))
: ratio(promotionRevenue, promotionSpend, average(dayRows.map((row) => numberValue(row.roi)))),
}
})
}
function expandDailyRange(
source: PerformanceConversionDailyMetric[],
startDate: string,
endDate: string,
) {
const byDate = new Map(source.map((item) => [item.date, item]))
const expanded: PerformanceConversionDailyMetric[] = []
const cursor = new Date(`${startDate}T00:00:00Z`)
const end = new Date(`${endDate}T00:00:00Z`)
while (cursor <= end) {
const date = cursor.toISOString().slice(0, 10)
expanded.push(byDate.get(date) || {
date,
impressions: null,
visitors: null,
clickRate: null,
favorites: null,
cartUsers: null,
paymentConversionRate: null,
paymentAmount: null,
roi: null,
})
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return expanded
}
function normalizeSourceBrand(value: unknown): PerformanceConversionBrand | null {
const normalized = clean(value)?.toLowerCase()
if (!normalized) return null
return BUSINESS_TREND_BRANDS.find((brand) => normalized.includes(brand.toLowerCase())) || null
}
function latestClean(rows: SourceMetricRow[], field: keyof SourceMetricRow) {
for (let index = rows.length - 1; index >= 0; index -= 1) {
const value = clean(rows[index][field])
if (value) return value
}
return null
}
export async function getPerformanceConversionTimeseries(
session: Session | null,
input: { workflowId?: string | null; productId?: string | null },
): Promise<PerformanceConversionTimeseries> {
await assertBrandCenterDashboardAccess(session)
const workflowId = clean(input.workflowId)
const productId = extractValidProductIds(input.productId)[0]
if (!workflowId || !productId) throw new PerformanceConversionError('流程或商品 ID 参数无效')
const row = await prisma.dingWorkflowSnapshot.findUnique({
where: { processInstanceId: workflowId },
select: {
processInstanceId: true,
businessId: true,
title: true,
result: true,
bizAction: true,
status: true,
processCode: true,
brand: true,
sourceFinishedAtRaw: true,
formFields: true,
syncedAt: true,
},
})
if (!row || !isEligibleSnapshot(row)) throw new PerformanceConversionError('未找到可监控的已完结美术流程', 404)
const workflow = workflowItems(row).find((item) => item.productId === productId)
if (!workflow) throw new PerformanceConversionError('商品 ID 不属于该流程', 403)
const today = toShanghaiDateKey(new Date()) || workflow.monitorStartDate
const queryEndDate = compareDateKeys(today, workflow.monitorEndDate) < 0 ? today : workflow.monitorEndDate
const source = metricSourceForBrand(workflow.brand)
const rows = compareDateKeys(queryEndDate, workflow.monitorStartDate) >= 0 && supportsMetricPlatform(source, workflow.platform)
? await readSourceRows(workflow.brand, productId, workflow.monitorStartDate, queryEndDate)
: []
const sourceDaily = aggregateRows(rows, source)
const daily = compareDateKeys(queryEndDate, workflow.monitorStartDate) >= 0
? expandDailyRange(sourceDaily, workflow.monitorStartDate, queryEndDate)
: []
const sourceBrand = normalizeSourceBrand(latestClean(rows, 'sourceBrand'))
const category = [latestClean(rows, 'category1'), latestClean(rows, 'category2'), latestClean(rows, 'category3')]
.filter(Boolean).join(' / ') || null
return {
workflow,
product: {
id: productId,
name: latestClean(rows, 'productName'),
sourceBrand: latestClean(rows, 'sourceBrand'),
category,
brandMatchesWorkflow: sourceBrand ? sourceBrand === workflow.brand : null,
},
latest: sourceDaily[sourceDaily.length - 1] || null,
daily,
coverage: {
sourceFirstDate: sourceDaily[0]?.date || null,
sourceLatestDate: sourceDaily[sourceDaily.length - 1]?.date || null,
coveredDays: sourceDaily.length,
expectedDaysToDate: daysBetweenInclusive(workflow.monitorStartDate, queryEndDate),
monitorStartDate: workflow.monitorStartDate,
monitorEndDate: workflow.monitorEndDate,
},
}
}