const express = require('express'); const database = require('../config/database'); const jianSunDb = require('../config/jianSunDatabase'); const middleDb = require('../config/middlePlatformDatabase'); const planningDb = require('../services/planningDatabase'); const { resolveQualityRefundSignal } = require('../services/productQualitySignal'); const router = express.Router(); const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily'; const PRODUCT_SPREAD_TABLE = 'pdd_ReportGoodsSpread'; const USER_PREFS_TABLE = 'product_analysis_user_prefs'; const PRODUCT_SIGNAL_PREFS_KEY = 'productAnalysisSignalThresholds'; const PRODUCT_META_CACHE_TTL_MS = 5 * 60 * 1000; const PRODUCT_SOURCE_CACHE_TTL_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_SOURCE_CACHE_TTL_MS) || 60 * 1000, 10 * 1000); const PRODUCT_SIGNAL_CACHE_TTL_MS = Math.max( Number(process.env.PRODUCT_ANALYSIS_SIGNAL_CACHE_TTL_MS) || 5 * 60 * 1000, 60 * 1000 ); const PRODUCT_SIGNAL_WINDOW_DAYS = 30; const PRODUCT_SIGNAL_OBSERVATION_DAYS = 7; const PRODUCT_SIGNAL_STATUSES = new Set(['red', 'yellow', 'green']); const DEFAULT_PRODUCT_SIGNAL_THRESHOLDS = Object.freeze({ ctr: Object.freeze({ low: 3, high: 5 }), conversion: Object.freeze({ low: 0.3, high: 0.5 }), }); const TAB_KEYS = new Set([ 'refunds', 'promotion', 'activities', 'navigator', 'service-issues', 'bad-reviews', 'deductions', ]); const PRODUCT_SORT_FIELDS = Object.freeze({ payOrdrAmt: 'payOrdrAmt', orderNum: 'orderNum', payOrdrUsrCnt: 'payOrdrUsrCnt', payOrdrGoodsQty: 'payOrdrGoodsQty', goodsUv: 'goodsUv', goodsVcr: 'goodsVcr', customerUnitPrice: 'customerUnitPrice', }); const DASHBOARD_PERIOD_DAYS = Object.freeze({ day: 1, week: 7, month: 30 }); const DASHBOARD_CACHE_TTL_MS = 60 * 1000; const DASHBOARD_QUERY_TIMEOUT_MS = Math.max(Number(process.env.PRODUCT_ANALYSIS_DASHBOARD_QUERY_TIMEOUT_MS) || 180000, 30000); const dashboardCache = new Map(); const dashboardInFlight = new Map(); const productSignalCache = new Map(); const productSignalItemCache = new Map(); const productSignalSnapshotCache = new Map(); const productSignalSnapshotInFlight = new Map(); const productSourceCache = new Map(); const productSourceInFlight = new Map(); let productMetaCache = null; let productMetaInFlight = null; let productDateCache = null; let productDateInFlight = null; let productMappingCache = null; let productMappingInFlight = null; function positiveInt(value, fallback, max = 200) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 1) return fallback; return Math.min(parsed, max); } function toNumber(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } function normalizeDate(value) { const text = String(value || '').trim(); return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''; } function toDateString(value) { if (!value) return ''; const date = new Date(value); return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10); } function toLocalDateString(value) { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${date.getFullYear()}-${month}-${day}`; } function addDays(value, offset) { const date = new Date(`${value}T00:00:00Z`); date.setUTCDate(date.getUTCDate() + offset); return date.toISOString().slice(0, 10); } function inclusiveDayCount(startDate, endDate) { const start = new Date(`${startDate}T00:00:00Z`); const end = new Date(`${endDate}T00:00:00Z`); return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1; } function percentChange(current, previous) { if (!Number.isFinite(current) || !Number.isFinite(previous) || previous === 0) return null; return ((current - previous) * 100) / Math.abs(previous); } function sumRows(rows) { return rows.reduce((result, row) => { result.payAmount += toNumber(row.payAmount); result.buyerCount += toNumber(row.buyerCount); result.visitorCount += toNumber(row.visitorCount); result.goodsPv += toNumber(row.goodsPv); result.payGoodsQty += toNumber(row.payGoodsQty); result.payOrderCount += toNumber(row.payOrderCount); result.favoriteCount += toNumber(row.favoriteCount); result.impressionCount += toNumber(row.impressionCount); return result; }, { payAmount: 0, buyerCount: 0, visitorCount: 0, goodsPv: 0, payGoodsQty: 0, payOrderCount: 0, favoriteCount: 0, impressionCount: 0, }); } async function runDashboardQuery(name, sqlText, params = {}) { try { return { name, rows: await jianSunDb.query(sqlText, params, { requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }), error: null, }; } catch (error) { console.warn(`[product-analysis/dashboard] ${name} query failed:`, error.message); return { name, rows: [], error }; } } function buildDashboardMetrics(current, previous) { const conversionRate = current.visitorCount > 0 ? (current.buyerCount * 100) / current.visitorCount : null; const previousConversionRate = previous.visitorCount > 0 ? (previous.buyerCount * 100) / previous.visitorCount : null; return [ { key: 'payAmount', label: '支付金额', value: current.payAmount, previous: previous.payAmount, change: percentChange(current.payAmount, previous.payAmount), format: 'money' }, { key: 'payOrderCount', label: '支付订单数', value: Math.round(current.payOrderCount), previous: Math.round(previous.payOrderCount), change: percentChange(current.payOrderCount, previous.payOrderCount), format: 'integer' }, { key: 'visitorCount', label: '访客数', value: Math.round(current.visitorCount), previous: Math.round(previous.visitorCount), change: percentChange(current.visitorCount, previous.visitorCount), format: 'integer' }, { key: 'buyerCount', label: '支付买家数', value: Math.round(current.buyerCount), previous: Math.round(previous.buyerCount), change: percentChange(current.buyerCount, previous.buyerCount), format: 'integer' }, { key: 'conversionRate', label: '支付转化率', value: conversionRate, previous: previousConversionRate, change: percentChange(conversionRate, previousConversionRate), format: 'percent' }, { key: 'payGoodsQty', label: '支付件数', value: Math.round(current.payGoodsQty), previous: Math.round(previous.payGoodsQty), change: percentChange(current.payGoodsQty, previous.payGoodsQty), format: 'integer' }, { key: 'favoriteCount', label: '收藏人数', value: Math.round(current.favoriteCount), previous: Math.round(previous.favoriteCount), change: percentChange(current.favoriteCount, previous.favoriteCount), format: 'integer' }, { key: 'goodsPv', label: '商品浏览量', value: Math.round(current.goodsPv), previous: Math.round(previous.goodsPv), change: percentChange(current.goodsPv, previous.goodsPv), format: 'integer' }, ]; } function buildEmptyDashboardMetrics() { return [ { key: 'payAmount', label: '支付金额', value: null, previous: null, change: null, format: 'money' }, { key: 'payOrderCount', label: '支付订单数', value: null, previous: null, change: null, format: 'integer' }, { key: 'visitorCount', label: '访客数', value: null, previous: null, change: null, format: 'integer' }, { key: 'buyerCount', label: '支付买家数', value: null, previous: null, change: null, format: 'integer' }, { key: 'conversionRate', label: '支付转化率', value: null, previous: null, change: null, format: 'percent' }, { key: 'payGoodsQty', label: '支付件数', value: null, previous: null, change: null, format: 'integer' }, { key: 'favoriteCount', label: '收藏人数', value: null, previous: null, change: null, format: 'integer' }, { key: 'goodsPv', label: '商品浏览量', value: null, previous: null, change: null, format: 'integer' }, ]; } function normalizeDepartments(...values) { const result = []; values.filter(Boolean).forEach((value) => { if (Array.isArray(value)) { result.push(...value); return; } const text = String(value).trim(); if (!text) return; try { const parsed = JSON.parse(text); if (Array.isArray(parsed)) { result.push(...parsed); return; } } catch { // Fall back to comma-separated legacy values. } result.push(...text.split(/[,,]/)); }); return [...new Set(result.map((item) => String(item || '').trim()).filter(Boolean))]; } async function getDataScope(userId) { const rows = await database.local.query( `SELECT u.id, u.role, u.department_names, d.name AS department_name FROM users u LEFT JOIN departments d ON d.id = u.department_id WHERE u.id = ? LIMIT 1`, [userId] ); const user = rows[0]; if (!user) return null; return { isAllData: user.role === 'admin', departments: normalizeDepartments(user.department_names, user.department_name), }; } function appendScope(whereParts, params, scope) { if (scope.isAllData) return; if (!scope.departments.length) { whereParts.push('1 = 0'); return; } whereParts.push(`ownerDept IN (${scope.departments.map(() => '?').join(', ')})`); params.push(...scope.departments); } async function resolveSnapshotDate(requestedDate) { const normalized = normalizeDate(requestedDate); if (normalized) return normalized; const rows = await planningDb.query( `SELECT DATE_FORMAT(MAX(statDate), '%Y-%m-%d') AS latestDate FROM ${PRODUCT_SNAPSHOT_TABLE}` ); return rows[0]?.latestDate || ''; } async function resolveDateRange(startValue, endValue) { const requestedStartDate = normalizeDate(startValue); const requestedEndDate = normalizeDate(endValue); const dateRange = requestedStartDate && requestedEndDate ? null : await loadProductDateRange(); const latestDate = dateRange?.latestDate || requestedEndDate || requestedStartDate || ''; const minDate = dateRange?.minDate || requestedStartDate || latestDate; const startDate = requestedStartDate || latestDate; const endDate = requestedEndDate || latestDate; return { minDate, latestDate, startDate: startDate <= endDate ? startDate : endDate, endDate: startDate <= endDate ? endDate : startDate, }; } function getRequestUserId(req) { const userId = Number(req.user?.sub || req.user?.id || req.user?.userId || 0); return Number.isSafeInteger(userId) && userId > 0 ? userId : null; } function normalizeProductSignalThresholds(value) { const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const normalizeMetric = (metric, defaults) => { const low = Number(metric?.low); const high = Number(metric?.high); return { low: Number.isFinite(low) ? Math.min(100, Math.max(0, low)) : defaults.low, high: Number.isFinite(high) ? Math.min(100, Math.max(0, high)) : defaults.high, }; }; return { ctr: normalizeMetric(source.ctr, DEFAULT_PRODUCT_SIGNAL_THRESHOLDS.ctr), conversion: normalizeMetric(source.conversion, DEFAULT_PRODUCT_SIGNAL_THRESHOLDS.conversion), }; } function parseUserPrefsStorage(value) { try { const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value; return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch { return {}; } } function readProductSignalThresholds(value) { const storage = parseUserPrefsStorage(value); const saved = storage[PRODUCT_SIGNAL_PREFS_KEY]; const configured = Boolean(saved && typeof saved === 'object' && !Array.isArray(saved)); return { configured, thresholds: normalizeProductSignalThresholds(configured ? saved : null), }; } function validateProductSignalThresholds(thresholds) { if (thresholds.ctr.low >= thresholds.ctr.high) { return '点击率的黄绿分界值必须大于红黄分界值'; } if (thresholds.conversion.low >= thresholds.conversion.high) { return '转化率的黄绿分界值必须大于红黄分界值'; } return ''; } async function persistProductSignalThresholds(userId, thresholds) { const patch = JSON.stringify({ [PRODUCT_SIGNAL_PREFS_KEY]: thresholds }); try { await database.local.query( `INSERT INTO ${USER_PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?) ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH( IF(JSON_VALID(prefs_json), prefs_json, '{}'), VALUES(prefs_json) )`, [userId, patch] ); } catch (error) { const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET'] .includes(error.code); if (!canFallback) throw error; const rows = await database.local.query( `SELECT prefs_json FROM ${USER_PREFS_TABLE} WHERE user_id = ? LIMIT 1`, [userId] ); const storage = parseUserPrefsStorage(rows[0]?.prefs_json); storage[PRODUCT_SIGNAL_PREFS_KEY] = thresholds; await database.local.query( `INSERT INTO ${USER_PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?) ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`, [userId, JSON.stringify(storage)] ); } } async function loadUserProductSignalThresholds(userId) { const rows = await database.local.query( `SELECT prefs_json FROM ${USER_PREFS_TABLE} WHERE user_id = ? LIMIT 1`, [userId] ); return readProductSignalThresholds(rows[0]?.prefs_json); } function resolveProductSignalStatus(signal, thresholds) { const statuses = []; if (signal?.signalWindowEndDate && !signal.signalIsObserving) { const ctr = toNumber(signal.signalCtr); const conversion = toNumber(signal.signalClickConversionRate); statuses.push(ctr < thresholds.ctr.low ? 'red' : ctr < thresholds.ctr.high ? 'yellow' : 'green'); statuses.push(conversion < thresholds.conversion.low ? 'red' : conversion < thresholds.conversion.high ? 'yellow' : 'green'); } if (signal?.signalQualityStatus) statuses.push(signal.signalQualityStatus); if (statuses.includes('red')) return 'red'; if (statuses.includes('yellow')) return 'yellow'; return statuses.includes('green') ? 'green' : ''; } async function loadProductDateRange() { if (productDateCache?.expiresAt > Date.now()) return productDateCache.data; if (productDateInFlight) return productDateInFlight; productDateInFlight = middleDb.query( `SELECT MIN(createTime) AS minDate, MAX(createTime) AS latestDate FROM dbo.pdd_DataGoods` ).then((rows) => { const latestDate = normalizeDate(rows[0]?.latestDate); const data = { latestDate, minDate: normalizeDate(rows[0]?.minDate) || latestDate, }; productDateCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS }; return data; }).finally(() => { productDateInFlight = null; }); return productDateInFlight; } async function loadProductMeta() { if (productMetaCache?.expiresAt > Date.now()) return productMetaCache.data; if (productMetaInFlight) return productMetaInFlight; productMetaInFlight = Promise.all([ loadProductDateRange(), middleDb.query( `SELECT DISTINCT shopName FROM dbo.pdd_DataGoods WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' ORDER BY shopName` ), loadProductMapping(), ]).then(([dateRange, shopRows, mappingRows]) => { const owners = [...new Set([...mappingRows.values()] .map((row) => String(row.idOwner || '').trim()) .filter(Boolean))] .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN')); const data = { platform: 'pdd', ...dateRange, shops: shopRows.map((row) => row.shopName), owners, }; productMetaCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS }; return data; }).finally(() => { productMetaInFlight = null; }); return productMetaInFlight; } async function loadProductMapping() { if (productMappingCache?.expiresAt > Date.now()) return productMappingCache.data; if (productMappingInFlight) return productMappingInFlight; productMappingInFlight = planningDb.query( `SELECT m.goodsId, m.spu, m.skuCode, m.idOwner, m.ownerDept, m.spend, m.isPriceBreak, f.first_created_at AS firstCreatedAt FROM ${PRODUCT_SNAPSHOT_TABLE} m LEFT JOIN pdd_goods_first_seen f ON f.goods_id = m.goodsId WHERE m.statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})` ).then((rows) => { const data = new Map(rows.map((row) => [String(row.goodsId), { ...row, firstCreatedAt: toLocalDateString(row.firstCreatedAt), }])); productMappingCache = { data, expiresAt: Date.now() + PRODUCT_META_CACHE_TTL_MS }; return data; }).finally(() => { productMappingInFlight = null; }); return productMappingInFlight; } function pruneProductSourceCache() { if (productSourceCache.size <= 30) return; const now = Date.now(); for (const [key, entry] of productSourceCache) { if (entry.expiresAt <= now || productSourceCache.size > 20) productSourceCache.delete(key); } } async function loadProductSourceRows(startDate, endDate, shopName) { const cacheKey = JSON.stringify({ startDate, endDate, shopName }); const cached = productSourceCache.get(cacheKey); if (cached?.expiresAt > Date.now()) return cached.data; if (productSourceInFlight.has(cacheKey)) return productSourceInFlight.get(cacheKey); const whereParts = [ 'createTime >= @startDate', 'createTime <= @endDate', 'goodsId IS NOT NULL', ]; const params = { startDate, endDate }; if (shopName) { whereParts.push('shopName = @shopName'); params.shopName = shopName; } const pending = middleDb.query( `SELECT CONVERT(varchar(50), goodsId) AS goodsId, shopName, MAX(goodsName) AS goodsName, MAX(hdThumbUrl) AS hdThumbUrl, MAX(cate1Name) AS cate1Name, MAX(cate2Name) AS cate2Name, MAX(cate3Name) AS cate3Name, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsUv), 0)) AS goodsUv, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsPv), 0)) AS goodsPv, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrAmt), 0)) AS payOrdrAmt, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrGoodsQty), 0)) AS payOrdrGoodsQty, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrCnt), 0)) AS orderNum, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), payOrdrUsrCnt), 0)) AS payOrdrUsrCnt, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), goodsFavCnt), 0)) AS goodsFavCnt, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), imprUsrCnt), 0)) AS imprUsrCnt FROM dbo.pdd_DataGoods WHERE ${whereParts.join(' AND ')} GROUP BY shopName, goodsId`, params ).then((rows) => { productSourceCache.set(cacheKey, { data: rows, expiresAt: Date.now() + PRODUCT_SOURCE_CACHE_TTL_MS, }); pruneProductSourceCache(); return rows; }).finally(() => { productSourceInFlight.delete(cacheKey); }); productSourceInFlight.set(cacheKey, pending); return pending; } function buildProductSignalRecord(firstObservedDate, spread, signalStartDate, signalEndDate) { const normalizedFirstObservedDate = normalizeDate(firstObservedDate); const observedDays = normalizedFirstObservedDate ? Math.max(1, inclusiveDayCount(normalizedFirstObservedDate, signalEndDate)) : null; const clicks = toNumber(spread?.clicks); const impressions = toNumber(spread?.impressions); const orders = toNumber(spread?.orders); return { signalWindowStartDate: signalStartDate, signalWindowEndDate: signalEndDate, signalFirstObservedDate: normalizedFirstObservedDate || null, signalObservedDays: observedDays, signalIsObserving: observedDays !== null && observedDays <= PRODUCT_SIGNAL_OBSERVATION_DAYS, signalCtr: impressions > 0 ? (clicks * 100) / impressions : 0, signalClickConversionRate: clicks > 0 ? (orders * 100) / clicks : 0, signalImpressions: impressions, signalClicks: clicks, signalOrders: orders, }; } function buildProductQualitySignalRecord(row) { if (!row) return {}; const ranking = resolveQualityRefundSignal(row.secondaryStage, row.tertiaryStage); const refundRate = Number(row.qualityRefundRate); return { signalQualityStatDate: normalizeDate(row.qualityStatDate) || null, signalQualityRefundRate: Number.isFinite(refundRate) ? refundRate : null, signalQualitySecondaryStage: Number(row.secondaryStage) || null, signalQualityTertiaryStage: Number(row.tertiaryStage) || null, signalQualitySecondaryLabel: ranking.secondaryLabel, signalQualityTertiaryLabel: ranking.tertiaryLabel, signalQualityMainCategoryLabel: ranking.mainCategoryLabel, signalQualityStatus: ranking.status, signalQualityReason: ranking.reason, }; } function mergeProductQualitySignals(data, qualityRows) { qualityRows.forEach((row) => { const goodsId = String(row.goodsId); data.set(goodsId, { ...(data.get(goodsId) || {}), ...buildProductQualitySignalRecord(row), }); }); return data; } async function loadProductSignalSnapshot(endDate) { const normalizedEndDate = normalizeDate(endDate); if (!normalizedEndDate) return new Map(); const signalStartDate = addDays(normalizedEndDate, -(PRODUCT_SIGNAL_WINDOW_DAYS - 1)); const cacheKey = `${signalStartDate}:${normalizedEndDate}`; const cached = productSignalSnapshotCache.get(cacheKey); if (cached?.expiresAt > Date.now()) return cached.data; if (productSignalSnapshotInFlight.has(cacheKey)) return productSignalSnapshotInFlight.get(cacheKey); const params = { signalStartDate, signalEndDate: normalizedEndDate }; const qualityEndExclusive = addDays(normalizedEndDate, 1); const pending = Promise.all([ middleDb.query( `SELECT CONVERT(varchar(50), goodsId) AS goodsId, MIN(createTime) AS firstObservedDate FROM dbo.pdd_DataGoods WHERE createTime >= @signalStartDate AND createTime <= @signalEndDate AND goodsId IS NOT NULL GROUP BY goodsId`, params ), middleDb.query( `SELECT CONVERT(varchar(50), goodsId) AS goodsId, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), [click]), 0)) AS clicks, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), impression), 0)) AS impressions, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), orderNum), 0)) AS orders FROM dbo.${PRODUCT_SPREAD_TABLE} WHERE createTime >= @signalStartDate AND createTime <= @signalEndDate AND goodsId IS NOT NULL GROUP BY goodsId`, params ), jianSunDb.query( `WITH qualityGoods AS ( SELECT DISTINCT goodsId FROM dbo.pdd_GoodsQualities WHERE goodsId IS NOT NULL ) SELECT CONVERT(varchar(50), qualityGoods.goodsId) AS goodsId, latestQuality.createTime AS qualityStatDate, latestQuality.qltyRfndOrdrCntRto1m AS qualityRefundRate, latestQuality.rankQltyRfndOrdrCntRto1mStage AS secondaryStage, latestQuality.rankQltyRfndOrdrCntRto1mStplStage AS tertiaryStage FROM qualityGoods CROSS APPLY ( SELECT TOP (1) createTime, qltyRfndOrdrCntRto1m, rankQltyRfndOrdrCntRto1mStage, rankQltyRfndOrdrCntRto1mStplStage FROM dbo.pdd_GoodsQualities WHERE goodsId = qualityGoods.goodsId AND createTime < @qualityEndExclusive ORDER BY createTime DESC, id DESC ) AS latestQuality`, { qualityEndExclusive } ), ]).then(([listingRows, spreadRows, qualityRows]) => { const listingMap = new Map(listingRows.map((row) => [String(row.goodsId), row.firstObservedDate])); const spreadMap = new Map(spreadRows.map((row) => [String(row.goodsId), row])); const goodsIds = new Set([...listingMap.keys(), ...spreadMap.keys()]); const data = new Map([...goodsIds].map((goodsId) => [ goodsId, buildProductSignalRecord( listingMap.get(goodsId), spreadMap.get(goodsId), signalStartDate, normalizedEndDate ), ])); mergeProductQualitySignals(data, qualityRows); productSignalSnapshotCache.set(cacheKey, { data, expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS, }); if (productSignalSnapshotCache.size > 5) { const now = Date.now(); for (const [key, entry] of productSignalSnapshotCache) { if (entry.expiresAt <= now || productSignalSnapshotCache.size > 3) { productSignalSnapshotCache.delete(key); } } } return data; }).finally(() => { productSignalSnapshotInFlight.delete(cacheKey); }); productSignalSnapshotInFlight.set(cacheKey, pending); return pending; } async function loadProductSignals(goodsIds, endDate) { const normalizedGoodsIds = [...new Set(goodsIds.map((value) => String(value || '').trim()).filter(Boolean))]; const normalizedEndDate = normalizeDate(endDate); if (!normalizedGoodsIds.length || !normalizedEndDate) return new Map(); const signalStartDate = addDays(normalizedEndDate, -(PRODUCT_SIGNAL_WINDOW_DAYS - 1)); const result = new Map(); const missingGoodsIds = []; const now = Date.now(); normalizedGoodsIds.forEach((goodsId) => { const itemCacheKey = `${signalStartDate}:${normalizedEndDate}:${goodsId}`; const cachedItem = productSignalItemCache.get(itemCacheKey); if (cachedItem?.expiresAt > now) { result.set(goodsId, cachedItem.data); } else { missingGoodsIds.push(goodsId); } }); if (!missingGoodsIds.length) return result; const cacheKey = `${signalStartDate}:${normalizedEndDate}:${[...missingGoodsIds].sort().join(',')}`; const cached = productSignalCache.get(cacheKey); if (cached?.expiresAt > now) { cached.data.forEach((value, key) => { productSignalItemCache.set(`${signalStartDate}:${normalizedEndDate}:${key}`, { data: value, expiresAt: cached.expiresAt, }); result.set(key, value); }); return result; } const params = { signalStartDate, signalEndDate: normalizedEndDate }; const qualityParams = { qualityEndExclusive: addDays(normalizedEndDate, 1) }; const placeholders = missingGoodsIds.map((goodsId, index) => { const key = `signalGoodsId${index}`; params[key] = goodsId; qualityParams[key] = goodsId; return `@${key}`; }).join(', '); const [listingRows, spreadRows, qualityRows] = await Promise.all([ middleDb.query( `SELECT CONVERT(varchar(50), goodsId) AS goodsId, MIN(createTime) AS firstObservedDate FROM dbo.pdd_DataGoods WHERE createTime >= @signalStartDate AND createTime <= @signalEndDate AND goodsId IN (${placeholders}) GROUP BY goodsId`, params ), middleDb.query( `SELECT CONVERT(varchar(50), goodsId) AS goodsId, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), [click]), 0)) AS clicks, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), impression), 0)) AS impressions, SUM(COALESCE(TRY_CONVERT(decimal(18, 2), orderNum), 0)) AS orders FROM dbo.${PRODUCT_SPREAD_TABLE} WHERE createTime >= @signalStartDate AND createTime <= @signalEndDate AND goodsId IN (${placeholders}) GROUP BY goodsId`, params ), jianSunDb.query( `WITH latestQuality AS ( SELECT CONVERT(varchar(50), goodsId) AS goodsId, createTime AS qualityStatDate, qltyRfndOrdrCntRto1m AS qualityRefundRate, rankQltyRfndOrdrCntRto1mStage AS secondaryStage, rankQltyRfndOrdrCntRto1mStplStage AS tertiaryStage, ROW_NUMBER() OVER ( PARTITION BY goodsId ORDER BY createTime DESC, id DESC ) AS rowNumber FROM dbo.pdd_GoodsQualities WHERE goodsId IN (${placeholders}) AND createTime < @qualityEndExclusive ) SELECT goodsId, qualityStatDate, qualityRefundRate, secondaryStage, tertiaryStage FROM latestQuality WHERE rowNumber = 1`, qualityParams ), ]); const listingMap = new Map(listingRows.map((row) => [String(row.goodsId), normalizeDate(row.firstObservedDate)])); const spreadMap = new Map(spreadRows.map((row) => [String(row.goodsId), row])); const data = new Map(missingGoodsIds.map((goodsId) => [ goodsId, buildProductSignalRecord( listingMap.get(goodsId), spreadMap.get(goodsId), signalStartDate, normalizedEndDate ), ])); mergeProductQualitySignals(data, qualityRows); productSignalCache.set(cacheKey, { data, expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS }); if (productSignalCache.size > 100) { const now = Date.now(); for (const [key, entry] of productSignalCache) { if (entry.expiresAt <= now || productSignalCache.size > 80) productSignalCache.delete(key); } } data.forEach((value, key) => { productSignalItemCache.set(`${signalStartDate}:${normalizedEndDate}:${key}`, { data: value, expiresAt: Date.now() + PRODUCT_SIGNAL_CACHE_TTL_MS, }); result.set(key, value); }); if (productSignalItemCache.size > 500) { const itemNow = Date.now(); for (const [key, entry] of productSignalItemCache) { if (entry.expiresAt <= itemNow || productSignalItemCache.size > 400) productSignalItemCache.delete(key); } } return result; } function asNumericRow(row) { const numericKeys = [ 'goodsUv', 'goodsPv', 'payOrdrAmt', 'payOrdrGoodsQty', 'orderNum', 'payOrdrUsrCnt', 'goodsVcr', 'goodsFavCnt', 'imprUsrCnt', ]; const result = { ...row }; numericKeys.forEach((key) => { result[key] = Number(row[key] || 0); }); return result; } function buildTabPayload(tabKey, product, history) { const historyRows = [...history].reverse(); const common = { tabKey, product, source: PRODUCT_SNAPSHOT_TABLE, }; if (tabKey === 'activities') { return { ...common, status: 'empty', message: '店铺后台宝贝活动数据源待接入', metrics: [], trend: [], columns: [], rows: [], }; } if (tabKey === 'deductions') { return { ...common, status: 'empty', message: '店铺后台账单订单扣款数据源待接入', metrics: [], trend: [], columns: [], rows: [], }; } if (tabKey === 'refunds') { return { ...common, status: 'ready', metrics: [ { key: 'afterSaleCount', label: '售后单量', value: product.afterSaleCount }, { key: 'afterSaleRate', label: '售后率', value: product.afterSaleRate, unit: '%' }, { key: 'payOrdrAmt', label: '销售额', value: product.payOrdrAmt, unit: '¥' }, { key: 'orderNum', label: '订单量', value: product.orderNum }, ], trend: historyRows.map((row) => ({ date: row.statDate, primary: row.afterSaleCount, secondary: row.afterSaleRate, })), trendLabels: { primary: '售后单量', secondary: '售后率' }, columns: [ { key: 'statDate', title: '数据日期' }, { key: 'afterSaleCount', title: '售后单量' }, { key: 'afterSaleRate', title: '售后率' }, { key: 'payOrdrAmt', title: '销售额' }, { key: 'orderNum', title: '订单量' }, ], rows: history, }; } if (tabKey === 'promotion') { return { ...common, status: 'ready', metrics: [ { key: 'spend', label: '推广花费', value: product.spend, unit: '¥' }, { key: 'feeRatio', label: '真实费比', value: product.feeRatio, unit: '%' }, { key: 'payOrdrAmt', label: '销售额', value: product.payOrdrAmt, unit: '¥' }, { key: 'goodsVcr', label: '成交转化率', value: product.goodsVcr, unit: '%' }, ], trend: historyRows.map((row) => ({ date: row.statDate, primary: row.spend, secondary: row.payOrdrAmt, })), trendLabels: { primary: '推广花费', secondary: '销售额' }, columns: [ { key: 'statDate', title: '数据日期' }, { key: 'spend', title: '推广花费' }, { key: 'feeRatio', title: '真实费比' }, { key: 'payOrdrAmt', title: '销售额' }, { key: 'goodsVcr', title: '成交转化率' }, ], rows: history, }; } if (tabKey === 'navigator') { return { ...common, status: 'ready', metrics: [ { key: 'goodsUv', label: '访客数', value: product.goodsUv }, { key: 'ctr', label: '点击率', value: product.ctr, unit: '%' }, { key: 'clickConversionRate', label: '点击转化率', value: product.clickConversionRate, unit: '%' }, { key: 'goodsVcr', label: '成交转化率', value: product.goodsVcr, unit: '%' }, ], trend: historyRows.map((row) => ({ date: row.statDate, primary: row.goodsUv, secondary: row.goodsVcr, })), trendLabels: { primary: '访客数', secondary: '成交转化率' }, columns: [ { key: 'statDate', title: '数据日期' }, { key: 'goodsUv', title: '访客数' }, { key: 'ctr', title: '点击率' }, { key: 'clickConversionRate', title: '点击转化率' }, { key: 'goodsVcr', title: '成交转化率' }, { key: 'imprUsrCnt', title: '流失指数' }, { key: 'unpaidUserCount', title: '未支付人数' }, ], rows: history, }; } return { ...common, status: 'ready', metrics: [ { key: 'badReviewCount', label: '差评数量', value: product.badReviewCount }, { key: 'afterSaleCount', label: '售后单量', value: product.afterSaleCount }, { key: 'afterSaleRate', label: '售后率', value: product.afterSaleRate, unit: '%' }, { key: 'payOrdrUsrCnt', label: '成交买家数', value: product.payOrdrUsrCnt }, ], trend: historyRows.map((row) => ({ date: row.statDate, primary: row.badReviewCount, secondary: row.afterSaleCount, })), trendLabels: { primary: '差评数量', secondary: '售后单量' }, columns: [ { key: 'statDate', title: '数据日期' }, { key: 'badReviewCount', title: '差评数量' }, { key: 'afterSaleCount', title: '售后单量' }, { key: 'afterSaleRate', title: '售后率' }, { key: 'payOrdrUsrCnt', title: '成交买家数' }, ], rows: history, }; } async function buildActivityPayload(product, startDate, endDate) { const startTimestamp = new Date(`${startDate}T00:00:00+08:00`).getTime(); const endTimestamp = new Date(`${endDate}T23:59:59.999+08:00`).getTime(); const rows = await jianSunDb.query( `SELECT TOP (200) id, business_type_str AS activityType, activity_name AS activityName, activity_id AS activityId, CONVERT(varchar(19), DATEADD( SECOND, (TRY_CONVERT(bigint, [enroll_time ]) / 1000) % 86400, DATEADD(DAY, (TRY_CONVERT(bigint, [enroll_time ]) / 1000) / 86400, CONVERT(datetime2, '1970-01-01')) ), 120) AS enrollTime, TRY_CONVERT(decimal(18, 2), min_activity_sku_price) / 100.0 AS minPrice, TRY_CONVERT(decimal(18, 2), max_activity_sku_price) / 100.0 AS maxPrice, CONVERT(varchar(19), DATEADD( SECOND, (TRY_CONVERT(bigint, activity_goods_start_time) / 1000) % 86400, DATEADD(DAY, (TRY_CONVERT(bigint, activity_goods_start_time) / 1000) / 86400, CONVERT(datetime2, '1970-01-01')) ), 120) AS activityStartTime, CONVERT(varchar(19), DATEADD( SECOND, (TRY_CONVERT(bigint, activity_goods_end_time) / 1000) % 86400, DATEADD(DAY, (TRY_CONVERT(bigint, activity_goods_end_time) / 1000) / 86400, CONVERT(datetime2, '1970-01-01')) ), 120) AS activityEndTime, status, CASE WHEN status = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', status) END AS statusLabel FROM dbo.pdd_DataActivityRecords WHERE TRY_CONVERT(bigint, goods_id) = @goodsId AND ( (TRY_CONVERT(bigint, activity_goods_end_time) >= @startTimestamp AND TRY_CONVERT(bigint, activity_goods_start_time) <= @endTimestamp) OR TRY_CONVERT(date, createTime) BETWEEN @startDate AND @endDate ) ORDER BY TRY_CONVERT(bigint, activity_goods_start_time) DESC, id DESC`, { goodsId: product.goodsId, startTimestamp, endTimestamp, startDate, endDate, } ); if (!rows.length) { return { tabKey: 'activities', product, source: 'JianSun.dbo.pdd_DataActivityRecords', status: 'empty', message: '当前商品在所选区间暂无活动报名数据', metrics: [], trend: [], columns: [], rows: [], }; } const successCount = rows.filter((row) => Number(row.status) === 501).length; const activityTypes = new Set(rows.map((row) => row.activityType).filter(Boolean)).size; return { tabKey: 'activities', product, source: 'JianSun.dbo.pdd_DataActivityRecords', status: 'ready', metrics: [ { key: 'activityCount', label: '活动记录', value: rows.length }, { key: 'successCount', label: '报名成功', value: successCount }, { key: 'activityTypes', label: '活动类型', value: activityTypes }, { key: 'successRate', label: '报名成功率', value: (successCount * 100) / rows.length, unit: '%' }, ], trend: [], columns: [ { key: 'activityName', title: '活动名称' }, { key: 'activityType', title: '活动类型' }, { key: 'enrollTime', title: '提交时间' }, { key: 'activityStartTime', title: '活动开始时间' }, { key: 'activityEndTime', title: '活动结束时间' }, { key: 'minPrice', title: '活动最低价' }, { key: 'maxPrice', title: '活动最高价' }, { key: 'statusLabel', title: '状态' }, ], rows, }; } async function buildQualityPayload(product, endDate) { const rows = await jianSunDb.query( `SELECT TOP (30) id, CONVERT(varchar(10), TRY_CONVERT(date, createTime), 23) AS statDate, avgDescRevScr1m, qltyRfndOrdrCntRto1m, ptHelpPzRate30d, midRto30d, displayMallScoreRk, rankAvgDescRevScr1mStage, rankQltyRfndOrdrCntRto1mStage, rankQltyRfndOrdrCntRto1mStplStage, rankPtHelpPzRate30dStage, [rankComscoreStage ] AS rankComscoreStage, goodsScoreRkStage FROM dbo.pdd_GoodsQualities WHERE goodsId = @goodsId AND createTime < @qualityEndExclusive ORDER BY createTime DESC, id DESC`, { goodsId: product.goodsId, qualityEndExclusive: addDays(endDate, 1) } ); if (!rows.length) { return { tabKey: 'navigator', product, source: 'JianSun.dbo.pdd_GoodsQualities', status: 'empty', message: '当前商品暂无品质领航员数据', metrics: [], trend: [], columns: [], rows: [], }; } const latest = rows[0]; return { tabKey: 'navigator', product, source: 'JianSun.dbo.pdd_GoodsQualities', status: 'ready', metrics: [ { key: 'avgDescRevScr1m', label: '品质评价得分', value: latest.avgDescRevScr1m }, { key: 'qltyRfndOrdrCntRto1m', label: '品质退款率', value: latest.qltyRfndOrdrCntRto1m, unit: '%' }, { key: 'ptHelpPzRate30d', label: '品质平台求助率', value: latest.ptHelpPzRate30d, unit: '%' }, { key: 'midRto30d', label: '品质文本情感分', value: latest.midRto30d }, ], trend: [...rows].reverse().map((row) => ({ date: row.statDate, primary: row.avgDescRevScr1m, secondary: row.qltyRfndOrdrCntRto1m, })), trendLabels: { primary: '品质评价得分', secondary: '品质退款率' }, columns: [ { key: 'statDate', title: '数据日期' }, { key: 'avgDescRevScr1m', title: '品质评价得分' }, { key: 'qltyRfndOrdrCntRto1m', title: '品质退款率' }, { key: 'ptHelpPzRate30d', title: '品质平台求助率' }, { key: 'midRto30d', title: '品质文本情感分' }, { key: 'goodsScoreRkStage', title: '综合得分表现' }, ], rows, }; } async function buildServiceIssuesPayload(product, statDate) { const rows = await jianSunDb.query( `SELECT id, COALESCE(NULLIF(LTRIM(RTRIM(title)), N''), N'其他问题') AS issueType, NULLIF(LTRIM(RTRIM(orderSn)), '') AS orderSn, CASE WHEN isNeg = 1 THEN 1 ELSE 0 END AS isNegative, CONVERT(varchar(19), TRY_CONVERT(datetime2, seekHelpTime), 120) AS seekHelpTime, NULLIF(LTRIM(RTRIM(probDetail)), N'') AS problemDetail, NULLIF(LTRIM(RTRIM(clfy2Desc)), N'') AS categoryDescription FROM dbo.pdd_ServiceProblemOrders WITH (NOLOCK) WHERE goodsId = TRY_CONVERT(bigint, @goodsId) AND createTime = @statDate ORDER BY TRY_CONVERT(datetime2, seekHelpTime) DESC, id DESC`, { goodsId: product.goodsId, statDate, } ); const detailMap = new Map(); rows.forEach((row) => { const orderSn = String(row.orderSn || '').trim(); if (!orderSn) return; const issueType = String(row.issueType || '其他问题').trim() || '其他问题'; const key = `${issueType}\u0000${orderSn}`; const current = detailMap.get(key); if (current) { current.isNegative = current.isNegative || Number(row.isNegative) === 1; if (!current.problemDetail && row.problemDetail) current.problemDetail = row.problemDetail; if (!current.categoryDescription && row.categoryDescription) { current.categoryDescription = row.categoryDescription; } return; } detailMap.set(key, { id: Number(row.id) || key, issueType, orderSn, isNegative: Number(row.isNegative) === 1, seekHelpTime: row.seekHelpTime || '', problemDetail: row.problemDetail || '', categoryDescription: row.categoryDescription || '', }); }); const orderDetails = [...detailMap.values()]; if (!orderDetails.length) { return { tabKey: 'service-issues', product, source: 'JianSun.dbo.pdd_ServiceProblemOrders', status: 'empty', message: `当前商品在 ${statDate} 暂无体验问题数据`, metrics: [], trend: [], columns: [], rows: [], orderDetails: [], }; } const issueGroups = new Map(); const allOrders = new Map(); orderDetails.forEach((row) => { if (!issueGroups.has(row.issueType)) issueGroups.set(row.issueType, []); issueGroups.get(row.issueType).push(row); const existing = allOrders.get(row.orderSn); allOrders.set(row.orderSn, Boolean(existing || row.isNegative)); }); const latestSummary = [...issueGroups.entries()] .map(([issueType, issueRows]) => ({ id: `${statDate}:${issueType}`, statDate, issueType, issueOrderCount: issueRows.length, negativeOrderCount: issueRows.filter((row) => row.isNegative).length, latestSeekHelpTime: issueRows[0]?.seekHelpTime || '', viewOrders: '查看订单', })) .sort((left, right) => ( right.issueOrderCount - left.issueOrderCount || left.issueType.localeCompare(right.issueType, 'zh-CN') )); const productIssueCount = latestSummary .filter((row) => row.issueType === '商品问题') .reduce((sum, row) => sum + row.issueOrderCount, 0); const customerServiceIssueCount = latestSummary .filter((row) => row.issueType.includes('客服')) .reduce((sum, row) => sum + row.issueOrderCount, 0); return { tabKey: 'service-issues', product, source: 'JianSun.dbo.pdd_ServiceProblemOrders', status: 'ready', latestDate: statDate, metrics: [ { key: 'issueOrderCount', label: '问题订单', value: allOrders.size }, { key: 'productIssueCount', label: '商品问题', value: productIssueCount }, { key: 'customerServiceIssueCount', label: '客服问题', value: customerServiceIssueCount }, { key: 'negativeOrderCount', label: '负向体验', value: [...allOrders.values()].filter(Boolean).length }, ], trend: [], tableTitle: `问题汇总 · ${statDate}`, columns: [ { key: 'issueType', title: '问题类型', align: 'left' }, { key: 'issueOrderCount', title: '问题订单数', align: 'right' }, { key: 'negativeOrderCount', title: '负向体验订单', align: 'right' }, { key: 'latestSeekHelpTime', title: '最近求助时间', align: 'left' }, { key: 'viewOrders', title: '操作', align: 'left' }, ], rows: latestSummary, orderDetails, }; } router.get('/signal-thresholds', async (req, res) => { try { const userId = getRequestUserId(req); if (!userId) { return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); } res.json({ success: true, data: await loadUserProductSignalThresholds(userId), }); } catch (error) { res.status(error.statusCode || 500).json({ success: false, error: error.message }); } }); router.put('/signal-thresholds', async (req, res) => { try { const userId = getRequestUserId(req); if (!userId) { return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); } const thresholds = normalizeProductSignalThresholds(req.body?.thresholds ?? req.body); const validationError = validateProductSignalThresholds(thresholds); if (validationError) { return res.status(400).json({ success: false, error: validationError }); } await persistProductSignalThresholds(userId, thresholds); res.json({ success: true, data: { configured: true, thresholds } }); } catch (error) { res.status(error.statusCode || 500).json({ success: false, error: error.message }); } }); router.get('/meta', async (req, res) => { try { const scope = await getDataScope(req.user.sub); if (!scope) return res.status(401).json({ success: false, error: 'user not found' }); res.json({ success: true, data: await loadProductMeta() }); } catch (error) { res.status(error.statusCode || 500).json({ success: false, error: error.message }); } }); router.get('/dashboard', async (req, res) => { let settleDashboard = null; let activeCacheKey = ''; try { const requestedStartDate = normalizeDate(req.query.startDate); const requestedEndDate = normalizeDate(req.query.endDate); const hasCustomDate = Boolean(requestedStartDate || requestedEndDate); if (hasCustomDate && (!requestedStartDate || !requestedEndDate)) { return res.status(400).json({ success: false, error: '自定义日期需要同时选择开始日期和结束日期' }); } const customDays = hasCustomDate ? inclusiveDayCount(requestedStartDate, requestedEndDate) : 0; if (hasCustomDate && customDays < 1) { return res.status(400).json({ success: false, error: '开始日期不能晚于结束日期' }); } if (hasCustomDate && customDays > 90) { return res.status(400).json({ success: false, error: '自定义日期范围不能超过90天' }); } const requestedPeriod = hasCustomDate ? 'custom' : DASHBOARD_PERIOD_DAYS[req.query.period] ? req.query.period : 'week'; const shopName = String(req.query.shopName || '').trim(); const cacheKey = JSON.stringify({ period: requestedPeriod, startDate: requestedStartDate, endDate: requestedEndDate, shopName, }); activeCacheKey = cacheKey; const cached = dashboardCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return res.json({ success: true, data: cached.data }); } const pending = dashboardInFlight.get(cacheKey); if (pending) { const result = await pending; if (result.error) throw result.error; return res.json({ success: true, data: result.data }); } dashboardInFlight.set(cacheKey, new Promise((resolve) => { settleDashboard = resolve; })); const period = requestedPeriod; const days = hasCustomDate ? customDays : DASHBOARD_PERIOD_DAYS[period]; const [salesResult, serviceResult, starResult] = await Promise.all([ runDashboardQuery( 'sales', `WITH source_bounds AS ( SELECT TOP (1) LEFT(createTime, 10) AS maxDate FROM dbo.pdd_DataGoods WHERE createTime >= '2000-01-01' AND createTime < '2100-01-01' AND (@shopName = '' OR shopName = @shopName) ORDER BY createTime DESC ), daily_coverage AS ( SELECT LEFT(g.createTime, 10) AS statDate, COUNT(DISTINCT g.shopName) AS shopCount FROM dbo.pdd_DataGoods g CROSS JOIN source_bounds b WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, -45, TRY_CONVERT(date, b.maxDate)), 23) AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, b.maxDate)), 23) AND g.shopName IS NOT NULL AND LTRIM(RTRIM(g.shopName)) <> '' AND (@shopName = '' OR g.shopName = @shopName) GROUP BY LEFT(g.createTime, 10) ), coverage_target AS ( SELECT MAX(shopCount) AS maxShopCount FROM daily_coverage ), available_end AS ( SELECT MAX(c.statDate) AS latestDate FROM daily_coverage c CROSS JOIN coverage_target t WHERE c.shopCount >= t.maxShopCount * 0.8 ), selected_end AS ( SELECT CASE WHEN @requestedEndDate <> '' AND @requestedEndDate < latestDate THEN @requestedEndDate ELSE latestDate END AS latestDate FROM available_end ) SELECT LEFT(g.createTime, 10) AS statDate, e.latestDate AS latestDate, SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrAmt), 0)) AS payAmount, SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrUsrCnt), 0)) AS buyerCount, SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsUv), 0)) AS visitorCount, SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsPv), 0)) AS goodsPv, SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrGoodsQty), 0)) AS payGoodsQty, SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrCnt), 0)) AS payOrderCount, SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsFavCnt), 0)) AS favoriteCount, SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.imprUsrCnt), 0)) AS impressionCount FROM dbo.pdd_DataGoods g CROSS JOIN selected_end e WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, 1 - @historyDays, TRY_CONVERT(date, e.latestDate)), 23) AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, e.latestDate)), 23) AND (@shopName = '' OR g.shopName = @shopName) GROUP BY LEFT(g.createTime, 10), e.latestDate ORDER BY LEFT(g.createTime, 10)`, { historyDays: days * 2, requestedEndDate: requestedEndDate || '', shopName } ), runDashboardQuery( 'service-score', `WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber FROM dbo.pdd_MallServiceScore WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' AND createTime LIKE '[12][0-9][0-9][0-9]-%' AND (@shopName = '' OR shopName = @shopName) ) SELECT MAX(LEFT(createTime, 10)) AS createTime, COUNT(*) AS shopCount, AVG(TRY_CONVERT(decimal(18, 6), cstmrServScore)) AS cstmrServScore, AVG(TRY_CONVERT(decimal(18, 6), spLmScore)) AS spLmScore, AVG(TRY_CONVERT(decimal(18, 6), fhLmScore)) AS fhLmScore, AVG(TRY_CONVERT(decimal(18, 6), wlLmScore)) AS wlLmScore FROM ranked WHERE rowNumber = 1`, { shopName } ), runDashboardQuery( 'shop-stars', `WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber FROM dbo.pdd_ShopStars WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> '' AND createTime LIKE '[12][0-9][0-9][0-9]-%' AND (@shopName = '' OR shopName = @shopName) ) SELECT MAX(LEFT(createTime, 10)) AS createTime, COUNT(*) AS shopCount, AVG(TRY_CONVERT(decimal(18, 6), mallStarTomms)) AS mallStarTomms, AVG(TRY_CONVERT(decimal(18, 6), gmvRk)) AS gmvRk, AVG(TRY_CONVERT(decimal(18, 6), scoreRk)) AS scoreRk FROM ranked WHERE rowNumber = 1`, { shopName } ), ]); const salesRows = salesResult.rows; const serviceRows = serviceResult.rows; const starRows = starResult.rows; const warnings = [salesResult, serviceResult, starResult] .filter((result) => result.error) .map((result) => result.name); const normalizedSales = salesRows .map((row) => ({ date: String(row.statDate || ''), payAmount: toNumber(row.payAmount), buyerCount: toNumber(row.buyerCount), visitorCount: toNumber(row.visitorCount), goodsPv: toNumber(row.goodsPv), payGoodsQty: toNumber(row.payGoodsQty), payOrderCount: toNumber(row.payOrderCount), favoriteCount: toNumber(row.favoriteCount), impressionCount: toNumber(row.impressionCount), })) .filter((row) => normalizeDate(row.date)); const latestDate = String(salesRows[0]?.latestDate || normalizedSales.at(-1)?.date || ''); const currentStart = hasCustomDate ? requestedStartDate : latestDate ? addDays(latestDate, 1 - days) : ''; const previousEnd = currentStart ? addDays(currentStart, -1) : ''; const previousStart = previousEnd ? addDays(previousEnd, 1 - days) : ''; const currentRows = normalizedSales.filter((row) => row.date >= currentStart && row.date <= latestDate); const previousRows = normalizedSales.filter((row) => row.date >= previousStart && row.date <= previousEnd); const current = sumRows(currentRows); const previous = sumRows(previousRows); const metrics = latestDate && !salesResult.error ? buildDashboardMetrics(current, previous) : buildEmptyDashboardMetrics(); const spend = null; const gmv = null; const roi = null; const responseData = { platform: 'pdd', source: 'jiansun_database', scope: shopName ? 'single-shop' : 'all-shops', shopName: shopName || '全部店铺', warnings, period, days, latestDate, startDate: currentStart, previousStart, previousEnd, metrics, trend: currentRows, promotion: { spend, gmv, roi, clickCount: null, orderCount: null, }, refunds: { amount: null, rate: null, products: [], }, store: { serviceDate: toDateString(serviceRows[0]?.createTime), starDate: toDateString(starRows[0]?.createTime), shopCount: Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount)), star: starRows[0]?.mallStarTomms == null ? null : toNumber(starRows[0].mallStarTomms), peerDescription: shopName ? shopName : `已汇总 ${Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount))} 家店铺`, activityRank: starRows[0]?.gmvRk == null ? null : toNumber(starRows[0].gmvRk), scoreRank: starRows[0]?.scoreRk == null ? null : toNumber(starRows[0].scoreRk), scores: [ { key: 'experience', label: '服务体验', value: serviceRows[0]?.cstmrServScore == null ? null : toNumber(serviceRows[0].cstmrServScore) }, { key: 'product', label: '商品服务', value: serviceRows[0]?.spLmScore == null ? null : toNumber(serviceRows[0].spLmScore) }, { key: 'logistics', label: '物流服务', value: serviceRows[0]?.wlLmScore == null ? null : toNumber(serviceRows[0].wlLmScore) }, { key: 'shipping', label: '发货服务', value: serviceRows[0]?.fhLmScore == null ? null : toNumber(serviceRows[0].fhLmScore) }, ], }, }; dashboardCache.set(cacheKey, { data: responseData, expiresAt: Date.now() + DASHBOARD_CACHE_TTL_MS }); settleDashboard({ data: responseData }); dashboardInFlight.delete(cacheKey); res.json({ success: true, data: responseData }); } catch (error) { settleDashboard?.({ error }); if (activeCacheKey) dashboardInFlight.delete(activeCacheKey); res.status(error.statusCode || 500).json({ success: false, error: error.message }); } }); router.get('/products', async (req, res) => { try { const userId = getRequestUserId(req); if (!userId) { return res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' }); } const scope = await getDataScope(userId); if (!scope) return res.status(401).json({ success: false, error: 'user not found' }); const page = positiveInt(req.query.page, 1, 1000000); const pageSize = positiveInt(req.query.pageSize, 20, 100); const requestedStartDate = req.query.startDate || req.query.statDate; const requestedEndDate = req.query.endDate || req.query.statDate; const range = await resolveDateRange(requestedStartDate, requestedEndDate); const rangeDays = positiveInt(req.query.rangeDays, 1, 90); if (!normalizeDate(requestedStartDate) && !normalizeDate(requestedEndDate) && rangeDays > 1) { range.startDate = addDays(range.endDate, -(rangeDays - 1)); } const shopName = String(req.query.shopName || '').trim(); const ider = String(req.query.ider || '').trim(); const keyword = String(req.query.keyword || '').trim(); const listingFrom = normalizeDate(req.query.listingFrom); const listingTo = normalizeDate(req.query.listingTo); const onlyActive = ['1', 'true', 'yes'].includes(String(req.query.onlyActive || '').toLowerCase()); const requestedSignalStatus = String(req.query.signalStatus || '').trim().toLowerCase(); const signalStatus = PRODUCT_SIGNAL_STATUSES.has(requestedSignalStatus) ? requestedSignalStatus : ''; const sortField = PRODUCT_SORT_FIELDS[req.query.sortField] || 'payOrdrAmt'; const sortOrder = String(req.query.sortOrder || '').toLowerCase() === 'ascend' ? 'ASC' : 'DESC'; const [sourceRows, mappingRows] = await Promise.all([ loadProductSourceRows(range.startDate, range.endDate, shopName), loadProductMapping(), ]); const mapping = mappingRows; const normalizedKeyword = keyword.toLowerCase(); let rows = sourceRows.map((sourceRow) => { const row = asNumericRow(sourceRow); const extra = mapping.get(String(row.goodsId)) || {}; const goodsUv = toNumber(row.goodsUv); const buyers = toNumber(row.payOrdrUsrCnt); const quantity = toNumber(row.payOrdrGoodsQty); const amount = toNumber(row.payOrdrAmt); return { ...row, ...extra, statDate: range.endDate, startDate: range.startDate, endDate: range.endDate, goodsVcr: goodsUv > 0 ? (buyers * 100) / goodsUv : 0, customerUnitPrice: buyers > 0 ? amount / buyers : 0, pieceUnitPrice: quantity > 0 ? amount / quantity : 0, }; }); if (!scope.isAllData) { const allowed = new Set(scope.departments); rows = rows.filter((row) => row.ownerDept && allowed.has(row.ownerDept)); } if (ider) { rows = rows.filter((row) => String(row.idOwner || '').trim() === ider); } if (listingFrom || listingTo) { rows = rows.filter((row) => { if (!row.firstCreatedAt) return false; if (listingFrom && row.firstCreatedAt < listingFrom) return false; if (listingTo && row.firstCreatedAt > listingTo) return false; return true; }); } if (normalizedKeyword) { rows = rows.filter((row) => [row.goodsName, row.goodsId, row.skuCode, row.spu] .some((value) => String(value || '').toLowerCase().includes(normalizedKeyword))); } if (onlyActive) { rows = rows.filter((row) => toNumber(row.payOrdrAmt) > 0 || toNumber(row.spend) > 0); } if (signalStatus && rows.length) { const [signalSnapshot, userSignalPrefs] = await Promise.all([ loadProductSignalSnapshot(range.endDate), loadUserProductSignalThresholds(userId), ]); rows = rows .map((row) => ({ ...row, ...(signalSnapshot.get(String(row.goodsId)) || {}), })) .filter((row) => resolveProductSignalStatus(row, userSignalPrefs.thresholds) === signalStatus); } const direction = sortOrder === 'ASC' ? 1 : -1; rows.sort((left, right) => { const difference = toNumber(left[sortField]) - toNumber(right[sortField]); return difference === 0 ? String(left.goodsId).localeCompare(String(right.goodsId)) : difference * direction; }); const summary = rows.reduce((result, row) => { result.payOrdrAmt += toNumber(row.payOrdrAmt); result.orderNum += toNumber(row.orderNum); result.payOrdrUsrCnt += toNumber(row.payOrdrUsrCnt); result.payOrdrGoodsQty += toNumber(row.payOrdrGoodsQty); result.goodsUv += toNumber(row.goodsUv); result.spend += toNumber(row.spend); if (toNumber(row.payOrdrAmt) > 0) result.activeGoods += 1; return result; }, { total: rows.length, activeGoods: 0, payOrdrAmt: 0, orderNum: 0, payOrdrUsrCnt: 0, payOrdrGoodsQty: 0, goodsUv: 0, spend: 0, startDate: range.startDate, endDate: range.endDate, }); summary.goodsVcr = summary.goodsUv > 0 ? (summary.payOrdrUsrCnt * 100) / summary.goodsUv : 0; const pagedRows = rows.slice((page - 1) * pageSize, page * pageSize); let responseRows = pagedRows; if (!signalStatus) { let productSignals = new Map(); try { productSignals = await loadProductSignals(pagedRows.map((row) => row.goodsId), range.endDate); } catch (error) { console.warn('[product-analysis/products] traffic signal query failed:', error.message); } responseRows = pagedRows.map((row) => ({ ...row, ...(productSignals.get(String(row.goodsId)) || {}), })); } res.json({ success: true, data: { page, pageSize, total: Number(summary.total || 0), statDate: range.endDate, startDate: range.startDate, endDate: range.endDate, signalStatus: signalStatus || 'all', metrics: summary, rows: responseRows, }, }); } catch (error) { res.status(error.statusCode || 500).json({ success: false, error: error.message }); } }); router.get('/products/:goodsId/tabs/:tabKey', async (req, res) => { try { const { goodsId, tabKey } = req.params; if (!TAB_KEYS.has(tabKey)) { return res.status(404).json({ success: false, error: 'unknown product analysis tab' }); } const scope = await getDataScope(req.user.sub); if (!scope) return res.status(401).json({ success: false, error: 'user not found' }); const range = await resolveDateRange( req.query.startDate || req.query.statDate, req.query.endDate || req.query.statDate ); const scopeWhere = []; const scopeParams = []; appendScope(scopeWhere, scopeParams, scope); const scopeSql = scopeWhere.length ? ` AND ${scopeWhere.join(' AND ')}` : ''; const [sourceProductRows, productRows] = await Promise.all([ middleDb.query( `SELECT TOP (1) CONVERT(varchar(50), goodsId) AS goodsId, goodsName, hdThumbUrl, shopName, cate1Name, cate2Name, cate3Name FROM dbo.pdd_DataGoods WHERE TRY_CONVERT(bigint, goodsId) = @goodsId AND TRY_CONVERT(date, createTime) <= @endDate ORDER BY TRY_CONVERT(date, createTime) DESC, createdAt DESC`, { goodsId, endDate: range.endDate } ), planningDb.query( `SELECT DATE_FORMAT(statDate, '%Y-%m-%d') AS statDate, DATE_FORMAT(startDate, '%Y-%m-%d') AS startDate, DATE_FORMAT(endDate, '%Y-%m-%d') AS endDate, spu, skuCode, goodsId, goodsName, hdThumbUrl, idOwner, ownerDept, shopName, goodsUv, ctr, clickConversionRate, goodsVcr, ordrVstrRto, payOrdrRto, imprUsrCnt, unpaidUserCount, payOrdrAmt, orderNum, payOrdrUsrCnt, payOrdrGoodsQty, customerUnitPrice, pieceUnitPrice, spend, feeRatio, afterSaleRate, afterSaleCount, badReviewCount, isPriceBreak FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE goodsId = ? AND statDate <= ?${scopeSql} ORDER BY statDate DESC LIMIT 1`, [goodsId, range.endDate, ...scopeParams] ), ]); const product = { ...(sourceProductRows[0] || {}), ...(productRows[0] || {}), goodsId, startDate: range.startDate, endDate: range.endDate, }; if (!scope.isAllData && !productRows[0]) { return res.status(403).json({ success: false, error: 'product access denied' }); } if (!sourceProductRows[0] && !productRows[0]) { return res.status(404).json({ success: false, error: 'product not found' }); } if (tabKey === 'activities') { return res.json({ success: true, data: await buildActivityPayload(product, range.startDate, range.endDate) }); } if (tabKey === 'navigator') { return res.json({ success: true, data: await buildQualityPayload(product, range.endDate) }); } if (tabKey === 'service-issues') { return res.json({ success: true, data: await buildServiceIssuesPayload(product, range.endDate), }); } const history = await planningDb.query( `SELECT DATE_FORMAT(statDate, '%Y-%m-%d') AS statDate, goodsUv, ctr, clickConversionRate, goodsVcr, imprUsrCnt, unpaidUserCount, payOrdrAmt, orderNum, payOrdrUsrCnt, payOrdrGoodsQty, spend, feeRatio, afterSaleRate, afterSaleCount, badReviewCount FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE goodsId = ? AND statDate >= ? AND statDate <= ?${scopeSql} ORDER BY statDate DESC LIMIT 60`, [goodsId, range.startDate, range.endDate, ...scopeParams] ); res.json({ success: true, data: buildTabPayload(tabKey, product, history) }); } catch (error) { res.status(error.statusCode || 500).json({ success: false, error: error.message }); } }); module.exports = router;