807 lines
30 KiB
TypeScript
807 lines
30 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
||
import { hostname } from 'node:os'
|
||
import type { Session } from 'next-auth'
|
||
import mssql, { type ConnectionPool } from 'mssql'
|
||
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_INTERVAL_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 SourceMetricRow = {
|
||
sourceRowId: string | null
|
||
metricDate: Date | string | null
|
||
productId: string | null
|
||
productName: string | null
|
||
sourceBrand: string | null
|
||
category1: string | null
|
||
category2: string | null
|
||
category3: string | null
|
||
paymentAmount: string | null
|
||
paymentUsers: string | null
|
||
cartUsers: string | null
|
||
visitors: string | null
|
||
conversionRate: string | null
|
||
favorites: string | null
|
||
impressions: string | null
|
||
clicks: string | null
|
||
clickRate: string | null
|
||
promotionSpend: string | null
|
||
promotionRevenue: string | null
|
||
roi: string | null
|
||
}
|
||
|
||
type CachedSourceRows = { expiresAt: number; rows: SourceMetricRow[] }
|
||
const sourceCache = new Map<string, CachedSourceRows>()
|
||
|
||
type PerformanceConversionSourceCursor = {
|
||
version: 1
|
||
lastSourceDate: 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(),
|
||
])
|
||
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 },
|
||
}
|
||
}
|
||
|
||
async function fetchSourceRows(pool: ConnectionPool, productId: string, startDate: string, endDate: string) {
|
||
const result = await pool.request()
|
||
.input('productId', mssql.NVarChar(255), productId)
|
||
.input('startDate', mssql.NVarChar(10), startDate)
|
||
.input('endDate', mssql.NVarChar(10), endDate)
|
||
.query<SourceMetricRow>(`
|
||
SELECT TOP (5000)
|
||
CONVERT(nvarchar(255), [RowId]) AS sourceRowId,
|
||
[统计日期] AS metricDate,
|
||
CONVERT(nvarchar(255), [商品ID]) AS productId,
|
||
CONVERT(nvarchar(2000), [商品名称]) AS productName,
|
||
CONVERT(nvarchar(500), [品牌名称]) AS sourceBrand,
|
||
CONVERT(nvarchar(500), [一级类目名称]) AS category1,
|
||
CONVERT(nvarchar(500), [二级类目名称]) AS category2,
|
||
CONVERT(nvarchar(500), [叶子类目名称]) AS category3,
|
||
CONVERT(nvarchar(255), [支付金额]) AS paymentAmount,
|
||
CONVERT(nvarchar(255), [支付人数]) AS paymentUsers,
|
||
CONVERT(nvarchar(255), [加购人数]) AS cartUsers,
|
||
CONVERT(nvarchar(255), [商品访客数]) AS visitors,
|
||
CONVERT(nvarchar(255), [支付转化率]) AS conversionRate,
|
||
CONVERT(nvarchar(255), [商品收藏人数]) AS favorites,
|
||
CONVERT(nvarchar(255), [展现量]) AS impressions,
|
||
CONVERT(nvarchar(255), [点击量]) AS clicks,
|
||
CONVERT(nvarchar(255), [点击率]) AS clickRate,
|
||
CONVERT(nvarchar(255), [推广消耗金额]) AS promotionSpend,
|
||
CONVERT(nvarchar(255), [推广引导总成交金额]) AS promotionRevenue,
|
||
CONVERT(nvarchar(255), [推广ROI]) AS roi
|
||
FROM [rpt].[TmallSrcBusinessRoi]
|
||
WHERE LTRIM(RTRIM(CONVERT(nvarchar(255), [商品ID]))) = @productId
|
||
AND [统计日期] >= TRY_CONVERT(date, @startDate)
|
||
AND [统计日期] <= TRY_CONVERT(date, @endDate)
|
||
ORDER BY [统计日期] ASC, [RowId] ASC
|
||
`)
|
||
return result.recordset || []
|
||
}
|
||
|
||
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 {
|
||
if (!value) return { version: 1, lastSourceDate: null }
|
||
try {
|
||
const parsed = JSON.parse(value) as Partial<PerformanceConversionSourceCursor>
|
||
return {
|
||
version: 1,
|
||
lastSourceDate: typeof parsed.lastSourceDate === 'string' && isValidDateKey(parsed.lastSourceDate)
|
||
? parsed.lastSourceDate
|
||
: null,
|
||
}
|
||
} catch {
|
||
return { version: 1, lastSourceDate: null }
|
||
}
|
||
}
|
||
|
||
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(): 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)
|
||
return {
|
||
status: normalizePerformanceConversionSyncStatus(state),
|
||
lastCompletedAt: state?.lastCompletedAt?.toISOString() || null,
|
||
lastSourceDate: cursor.lastSourceDate,
|
||
}
|
||
} catch (error) {
|
||
console.warn('[performance-conversion-sync] 同步状态读取失败,暂按首次同步处理:', error)
|
||
return { status: 'pending', lastCompletedAt: null, lastSourceDate: null }
|
||
}
|
||
}
|
||
|
||
async function readSnapshotRows(productId: string, startDate: string, endDate: string) {
|
||
const rows = await prisma.performanceConversionMetricSnapshot.findMany({
|
||
where: {
|
||
productId,
|
||
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(productId: string, startDate: string, endDate: string) {
|
||
const cacheKey = `upstream:${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 fetchSourceRows(pool, 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(productId: string, startDate: string, endDate: string) {
|
||
const sync = await getPerformanceConversionSyncMetadata()
|
||
if (sync.lastCompletedAt) {
|
||
const localRows = await readSnapshotRows(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(productId, startDate, endDate)
|
||
}
|
||
|
||
type MetricProductWindow = {
|
||
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 endDate = compareDateKeys(today, item.monitorEndDate) <= 0 ? today : item.monitorEndDate
|
||
const existing = windows.get(item.productId)
|
||
if (!existing) {
|
||
windows.set(item.productId, { 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, productId: string, syncedAt: Date, index: number) {
|
||
const metricDate = metricDateKey(row.metricDate)
|
||
if (!metricDate) return null
|
||
const sourceRowId = clean(row.sourceRowId)
|
||
return {
|
||
id: randomUUID(),
|
||
sourceKey: `${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 syncStartedAt = new Date()
|
||
|
||
for (const window of windows) {
|
||
await renewPerformanceConversionSyncLease(leaseOwner)
|
||
const existing = await prisma.performanceConversionMetricSnapshot.findFirst({
|
||
where: { productId: window.productId },
|
||
select: { id: true },
|
||
})
|
||
const incrementalStart = cursor.lastSourceDate
|
||
? shiftDateKey(cursor.lastSourceDate, -SYNC_LOOKBACK_DAYS)
|
||
: window.startDate
|
||
const startDate = existing ? maxDateKey(window.startDate, incrementalStart) : window.startDate
|
||
const endDate = window.endDate
|
||
if (compareDateKeys(startDate, endDate) > 0) continue
|
||
|
||
const sourceRows = await fetchSourceRows(pool, window.productId, startDate, endDate)
|
||
const snapshotsToWrite = sourceRows
|
||
.map((row, index) => toMetricSnapshotData(row, window.productId, syncStartedAt, index))
|
||
.filter((row): row is NonNullable<ReturnType<typeof toMetricSnapshotData>> => row !== null)
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.performanceConversionMetricSnapshot.deleteMany({
|
||
where: {
|
||
productId: window.productId,
|
||
metricDate: { gte: dateKeyToUtcDate(startDate), lte: dateKeyToUtcDate(endDate) },
|
||
},
|
||
})
|
||
if (snapshotsToWrite.length > 0) {
|
||
await tx.performanceConversionMetricSnapshot.createMany({ data: snapshotsToWrite })
|
||
}
|
||
})
|
||
rowsUpserted += snapshotsToWrite.length
|
||
productsSynced += 1
|
||
}
|
||
|
||
sourceCache.clear()
|
||
const sourceCursorRaw = serializePerformanceConversionSourceCursor({ version: 1, lastSourceDate: today })
|
||
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 interval = setInterval(runOnce, SYNC_INTERVAL_MS)
|
||
interval.unref?.()
|
||
console.info('[performance-conversion-sync] 已调度经营数据每日增量同步,周期 24 小时,按最近两天增量校准')
|
||
}
|
||
|
||
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 metricDateKey(value: Date | string | null) {
|
||
if (!value) return null
|
||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) return value.slice(0, 10)
|
||
const parsed = value instanceof Date ? value : new Date(value)
|
||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10)
|
||
}
|
||
|
||
function aggregateRows(rows: SourceMetricRow[]) {
|
||
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: 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: ratio(paymentUsers, visitors, average(dayRows.map((row) => numberValue(row.conversionRate)))),
|
||
paymentAmount: sum(dayRows.map((row) => numberValue(row.paymentAmount))),
|
||
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 rows = compareDateKeys(queryEndDate, workflow.monitorStartDate) >= 0
|
||
? await readSourceRows(productId, workflow.monitorStartDate, queryEndDate)
|
||
: []
|
||
const sourceDaily = aggregateRows(rows)
|
||
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,
|
||
},
|
||
}
|
||
}
|