Commit 0c953e5c authored by 刘小敏's avatar 刘小敏

数据中心优化;计划任务日志优化;

parent fb3982b7
......@@ -37,100 +37,77 @@ class Dashboard extends Common
return $this->view->fetch();
}
/**
* 获取核心统计数据
*
* 该接口优先从缓存读取 DataStatsCenter 定时任务生成的统计数据,
* 缓存未命中时降级到数据库实时查询。
*
* 请求参数:
* - time_range: 时间范围,可选值:'7days'、'30days'、'90days',默认 '30days'
* - period: 周期类型,可选值:'day'、'week'、'month',默认 'day'
*
* 返回数据:
* - total_amount: 成交总额(已支付和已完成订单的支付金额总和)
* - profit_rate: 净利润率(百分比格式,如 '18.6%')
* - avg_amount: 客单价(成交总额 / 订单数)
* - order_count: 订单数(已支付和已完成订单数量)
* - visitor_count: 访客数(基于小程序行为埋点统计的去重用户数)
* - conversion_rate: 转化率(订单数 / 访客数,百分比格式)
* - register_users: 注册用户数(累计注册用户总数)
* - trend_data: 趋势数据数组,每个元素包含:
* - date: 日期标签
* - amount: 该日期的成交额
* - order_num: 该日期的订单数
* - register_num: 该日期的注册用户数
* - from_cache: 数据来源标识,true 表示来自缓存,false 表示来自实时查询
*/
public function statistics()
private function buildStatisticsResult($statsData, $period, $fromCache, $updateTime)
{
$timeRange = $this->request->param('time_range', '30days');
$period = $this->request->param('period', 'day');
$cacheKey = 'shopro:data_stats_center:' . $timeRange;
$cachedData = cache($cacheKey);
$summary = $statsData['summary'];
$trendData = $this->buildTrendDataForChart($statsData['trend'], $period);
$funnelData = $this->formatFunnelData($statsData['funnel'] ?? []);
$priceDistributionData = $this->formatPriceDistributionData($statsData['price_distribution'] ?? []);
// 获取最新统计记录的更新时间
$updateTime = Db::name('shopro_data_stats_center')
->order('created_at', 'desc')
->value('created_at');
return [
'total_amount' => $summary['total_amount']['value'] ?? 0,
'total_amount_compare_rate' => $summary['total_amount']['compare_rate'] ?? null,
'profit_rate' => $summary['profit_rate']['formatted'] ?? '0.00%',
'profit_rate_compare_rate' => $summary['profit_rate']['compare_rate'] ?? null,
'avg_amount' => $summary['avg_amount']['value'] ?? 0,
'avg_amount_compare_rate' => $summary['avg_amount']['compare_rate'] ?? null,
'order_count' => $summary['order_count']['value'] ?? 0,
'order_count_compare_rate' => $summary['order_count']['compare_rate'] ?? null,
'visitor_count' => $summary['visitor_count']['value'] ?? 0,
'visitor_count_compare_rate' => $summary['visitor_count']['compare_rate'] ?? null,
'conversion_rate' => $summary['conversion_rate']['formatted'] ?? '0.00%',
'conversion_rate_compare_rate' => $summary['conversion_rate']['compare_rate'] ?? null,
'register_users' => $summary['register_users']['value'] ?? 0,
'register_users_compare_rate' => $summary['register_users']['compare_rate'] ?? null,
'trend_data' => $trendData,
'funnel_data' => $funnelData,
'price_distribution_data' => $priceDistributionData,
'from_cache' => $fromCache,
'update_time' => $updateTime ? date('Y-m-d H:i:s', $updateTime) : date('Y-m-d H:i:s')
];
}
if ($cachedData !== false && isset($cachedData['summary']) && isset($cachedData['trend'])) {
$summary = $cachedData['summary'];
$trendData = $this->buildTrendDataForChart($cachedData['trend'], $period);
// 较上周期 效果计算
// - 上一周期和当前周期数据都是0时 → 显示"持平"
// - 上一周期是0但当前周期有数据 → 显示"+100%"
// - 上一周期有数据但当前周期是0 → 显示"-100%"
// - 两个周期都有数据时 → 显示实际百分比
$result = [
'total_amount' => $summary['total_amount']['value'],
'total_amount_compare_rate' => $summary['total_amount']['compare_rate'],
'profit_rate' => $summary['profit_rate']['formatted'],
'profit_rate_compare_rate' => $summary['profit_rate']['compare_rate'],
'avg_amount' => $summary['avg_amount']['value'],
'avg_amount_compare_rate' => $summary['avg_amount']['compare_rate'],
'order_count' => $summary['order_count']['value'],
'order_count_compare_rate' => $summary['order_count']['compare_rate'],
'visitor_count' => $summary['visitor_count']['value'],
'visitor_count_compare_rate' => $summary['visitor_count']['compare_rate'],
'conversion_rate' => $summary['conversion_rate']['formatted'],
'conversion_rate_compare_rate' => $summary['conversion_rate']['compare_rate'],
'register_users' => $summary['register_users']['value'],
'register_users_compare_rate' => $summary['register_users']['compare_rate'],
'trend_data' => $trendData,
'from_cache' => true,
'update_time' => $updateTime ? date('Y-m-d H:i:s', $updateTime) : date('Y-m-d H:i:s')
];
private function getEmptyStatisticsResult()
{
return [
'total_amount' => 0,
'total_amount_compare_rate' => null,
'profit_rate' => '0.00%',
'profit_rate_compare_rate' => null,
'avg_amount' => 0,
'avg_amount_compare_rate' => null,
'order_count' => 0,
'order_count_compare_rate' => null,
'visitor_count' => 0,
'visitor_count_compare_rate' => null,
'conversion_rate' => '0.00%',
'conversion_rate_compare_rate' => null,
'register_users' => 0,
'register_users_compare_rate' => null,
'trend_data' => [],
'funnel_data' => [],
'price_distribution_data' => [],
'from_cache' => false,
'update_time' => date('Y-m-d H:i:s')
];
}
$this->success('获取成功', null, $result);
private function formatFunnelData($funnel)
{
$result = [];
foreach ($funnel as $item) {
$result[$item['key']] = $item['value'];
}
return $result;
}
// $days = $this->getDaysByTimeRange($timeRange);
// $trendData = $this->getTrendData($days, $period);
// $result = [
// 'total_amount' => $trendData['total_amount'],
// 'total_amount_compare_rate' => $trendData['total_amount_compare_rate'] ?? 0,
// 'profit_rate' => $trendData['profit_rate'],
// 'profit_rate_compare_rate' => $trendData['profit_rate_compare_rate'] ?? 0,
// 'avg_amount' => $trendData['avg_amount'],
// 'avg_amount_compare_rate' => $trendData['avg_amount_compare_rate'] ?? 0,
// 'order_count' => $trendData['order_count'],
// 'order_count_compare_rate' => $trendData['order_count_compare_rate'] ?? 0,
// 'visitor_count' => $trendData['visitor_count'],
// 'visitor_count_compare_rate' => $trendData['visitor_count_compare_rate'] ?? 0,
// 'conversion_rate' => $trendData['conversion_rate'],
// 'conversion_rate_compare_rate' => $trendData['conversion_rate_compare_rate'] ?? 0,
// 'register_users' => $trendData['register_users'],
// 'register_users_compare_rate' => $trendData['register_users_compare_rate'] ?? 0,
// 'trend_data' => $trendData['trend_data'],
// 'from_cache' => false,
// 'update_time' => $updateTime ? date('Y-m-d H:i:s', $updateTime) : date('Y-m-d H:i:s')
// ];
// $this->success('获取成功', null, $result);
private function formatPriceDistributionData($distribution)
{
$result = [];
foreach ($distribution as $item) {
$result[$item['label']] = $item['rate'];
}
return $result;
}
/**
......@@ -298,137 +275,18 @@ class Dashboard extends Common
* @param string $timeRange 时间范围标识
* @return int 对应的天数
*/
private function getDaysByTimeRange($timeRange)
{
switch ($timeRange) {
case '7days':
return 7;
case '90days':
return 90;
case '30days':
default:
return 30;
}
}
/**
* 获取趋势数据(降级方案)
*
* 当缓存未命中时,直接从数据库查询并计算趋势数据
*
* @param int $days 统计天数
* @param string $period 周期类型
* @return array 包含各项指标和趋势数据的数组
*/
private function getTrendData($days, $period)
{
$data = [];
$totalAmount = 0;
$orderCount = 0;
$registerUsers = 0;
$visitorCount = 0;
$buyerCount = 0;
$format = $this->getDateFormat($period);
for ($i = $days - 1; $i >= 0; $i--) {
$date = date('Y-m-d', strtotime("-$i days"));
$startTime = strtotime($date . ' 00:00:00');
$endTime = strtotime($date . ' 23:59:59');
$amount = Order::whereBetween('createtime', [$startTime, $endTime])
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->sum('pay_fee') ?: 0;
$orderNum = Order::whereBetween('createtime', [$startTime, $endTime])
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->count();
$buyerNum = Order::whereBetween('createtime', [$startTime, $endTime])
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->where('user_id', '>', 0)
->distinct('user_id')
->count('user_id');
$registerNum = User::whereBetween('createtime', [$startTime, $endTime])->count();
try {
$visitorNum = $this->getVisitorCountFromTrack($startTime, $endTime);
} catch (\Exception $e) {
$visitorNum = 0;
}
$data[] = [
'date' => date($format, $startTime),
'amount' => $amount,
'order_num' => $orderNum,
'register_num' => $registerNum
];
$totalAmount += $amount;
$orderCount += $orderNum;
$registerUsers += $registerNum;
$visitorCount += $visitorNum;
$buyerCount += $buyerNum;
}
$previousStartTime = $startTime - ($days * 86400);
$previousEndTime = $startTime - 1;
$previousAmount = Order::whereBetween('createtime', [$previousStartTime, $previousEndTime])
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->sum('pay_fee') ?: 0;
$previousOrderNum = Order::whereBetween('createtime', [$previousStartTime, $previousEndTime])
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->count();
$previousBuyerNum = Order::whereBetween('createtime', [$previousStartTime, $previousEndTime])
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->where('user_id', '>', 0)
->distinct('user_id')
->count('user_id');
$previousRegisterNum = User::whereBetween('createtime', [$previousStartTime, $previousEndTime])->count();
try {
$previousVisitorNum = $this->getVisitorCountFromTrack($previousStartTime, $previousEndTime);
} catch (\Exception $e) {
$previousVisitorNum = 0;
}
$previousAvgAmount = $previousOrderNum > 0 ? bcdiv($previousAmount, $previousOrderNum, 2) : 0;
$previousConversionRate = $previousVisitorNum > 0 ? bcdiv($previousBuyerNum * 100, $previousVisitorNum, 2) : 0;
$avgAmount = $orderCount > 0 ? bcdiv($totalAmount, $orderCount, 2) : 0;
$profitRate = $totalAmount > 0 ? '18.6%' : '0%';
$conversionRate = $visitorCount > 0 ? bcdiv($buyerCount * 100, $visitorCount, 2) . '%' : '0%';
$totalAmountCompareRate = $previousAmount > 0 ? round((($totalAmount - $previousAmount) / $previousAmount) * 100, 2) : ($totalAmount > 0 ? 100 : ($previousAmount == 0 ? null : -100));
$avgAmountCompareRate = $previousAvgAmount > 0 ? round((($avgAmount - $previousAvgAmount) / $previousAvgAmount) * 100, 2) : ($avgAmount > 0 ? 100 : ($previousAvgAmount == 0 ? null : -100));
$orderCountCompareRate = $previousOrderNum > 0 ? round((($orderCount - $previousOrderNum) / $previousOrderNum) * 100, 2) : ($orderCount > 0 ? 100 : ($previousOrderNum == 0 ? null : -100));
$visitorCountCompareRate = $previousVisitorNum > 0 ? round((($visitorCount - $previousVisitorNum) / $previousVisitorNum) * 100, 2) : ($visitorCount > 0 ? 100 : ($previousVisitorNum == 0 ? null : -100));
$conversionRateCompareRate = $previousConversionRate > 0 ? round((($conversionRate - $previousConversionRate) / $previousConversionRate) * 100, 2) : ($conversionRate > 0 ? 100 : ($previousConversionRate == 0 ? null : -100));
$registerUsersCompareRate = $previousRegisterNum > 0 ? round((($registerUsers - $previousRegisterNum) / $previousRegisterNum) * 100, 2) : ($registerUsers > 0 ? 100 : ($previousRegisterNum == 0 ? null : -100));
return [
'total_amount' => $totalAmount,
'total_amount_compare_rate' => $totalAmountCompareRate,
'order_count' => $orderCount,
'order_count_compare_rate' => $orderCountCompareRate,
'avg_amount' => $avgAmount,
'avg_amount_compare_rate' => $avgAmountCompareRate,
'profit_rate' => $profitRate,
'profit_rate_compare_rate' => 0,
'visitor_count' => $visitorCount,
'visitor_count_compare_rate' => $visitorCountCompareRate,
'conversion_rate' => $conversionRate,
'conversion_rate_compare_rate' => $conversionRateCompareRate,
'register_users' => $registerUsers,
'register_users_compare_rate' => $registerUsersCompareRate,
'trend_data' => $data
];
}
// private function getDaysByTimeRange($timeRange)
// {
// switch ($timeRange) {
// case '7days':
// return 7;
// case '90days':
// return 90;
// case '30days':
// default:
// return 30;
// }
// }
/**
* 获取日期格式化字符串
......@@ -436,18 +294,18 @@ class Dashboard extends Common
* @param string $period 周期类型
* @return string PHP 日期格式化字符串
*/
private function getDateFormat($period)
{
switch ($period) {
case 'week':
return 'Y年W周';
case 'month':
return 'Y年m月';
case 'day':
default:
return 'm-d';
}
}
// private function getDateFormat($period)
// {
// switch ($period) {
// case 'week':
// return 'Y年W周';
// case 'month':
// return 'Y年m月';
// case 'day':
// default:
// return 'm-d';
// }
// }
/**
* 获取转化漏斗数据
......@@ -465,147 +323,276 @@ class Dashboard extends Common
* - submit_order: 提交订单人数(下单的去重用户数)
* - pay: 完成支付人数(已支付或已完成订单的去重用户数)
*/
public function funnel()
{
$timeRange = $this->request->param('time_range', '30days');
// public function funnel()
// {
// $timeRange = $this->request->param('time_range', '30days');
// $cacheKey = 'shopro:data_stats_center:' . $timeRange;
// $cachedData = cache($cacheKey);
// if ($cachedData !== false && isset($cachedData['funnel'])) {
// $funnel = $cachedData['funnel'];
// $result = [];
// foreach ($funnel as $item) {
// $result[$item['key']] = $item['value'];
// }
// $this->success('获取成功', null, $result);
// }
// $days = $this->getDaysByTimeRange($timeRange);
// $endTime = time();
// $startTime = strtotime("-$days days");
// $visitCount = 0;
// try {
// $visitCount = Db::name('shopro_user_goods_log')
// ->whereBetween('createtime', [$startTime, $endTime])
// ->distinct(true)
// ->count('user_id');
// } catch (\Exception $e) {
// $visitCount = 0;
// }
// $browseCount = 0;
// try {
// $browseCount = Db::name('shopro_user_goods_log')
// ->whereBetween('createtime', [$startTime, $endTime])
// ->where('type', 'browse')
// ->distinct(true)
// ->count('user_id');
// } catch (\Exception $e) {
// $browseCount = 0;
// }
// $cartCount = 0;
// try {
// $cartCount = Db::name('shopro_user_goods_log')
// ->whereBetween('createtime', [$startTime, $endTime])
// ->where('type', 'cart')
// ->distinct(true)
// ->count('user_id');
// } catch (\Exception $e) {
// $cartCount = 0;
// }
// $submitOrderCount = Order::whereBetween('createtime', [$startTime, $endTime])
// ->distinct(true)
// ->count('user_id');
// $payCount = Order::whereBetween('createtime', [$startTime, $endTime])
// ->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
// ->distinct(true)
// ->count('user_id');
// $this->success('获取成功', null, [
// 'visit' => $visitCount,
// 'browse' => $browseCount,
// 'cart' => $cartCount,
// 'submit_order' => $submitOrderCount,
// 'pay' => $payCount
// ]);
// }
$cacheKey = 'shopro:data_stats_center:' . $timeRange;
$cachedData = cache($cacheKey);
if ($cachedData !== false && isset($cachedData['funnel'])) {
$funnel = $cachedData['funnel'];
$result = [];
foreach ($funnel as $item) {
$result[$item['key']] = $item['value'];
}
$this->success('获取成功', null, $result);
}
/**
* 获取客单价分布
*
* 统计不同价格区间的订单占比
* 优先从缓存读取,缓存未命中时降级到数据库查询
*
* 请求参数:
* - time_range: 时间范围,可选值:'7days'、'30days'、'90days',默认 '30days'
*
* 返回数据:
* - '0-100': 0-100元订单占比(百分比)
* - '100-300': 100-300元订单占比(百分比)
* - '300-500': 300-500元订单占比(百分比)
* - '500-1000': 500-1000元订单占比(百分比)
* - '1000+': 1000元以上订单占比(百分比)
*/
// public function priceDistribution()
// {
// $timeRange = $this->request->param('time_range', '30days');
// $cacheKey = 'shopro:data_stats_center:' . $timeRange;
// $cachedData = cache($cacheKey);
// if ($cachedData !== false && isset($cachedData['price_distribution'])) {
// $distribution = $cachedData['price_distribution'];
// $result = [];
// foreach ($distribution as $item) {
// $result[$item['label']] = $item['rate'];
// }
// $this->success('获取成功', null, $result);
// }
// $days = $this->getDaysByTimeRange($timeRange);
// $endTime = time();
// $startTime = strtotime("-$days days");
// $orders = Order::whereBetween('createtime', [$startTime, $endTime])
// ->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
// ->field('pay_fee')
// ->select();
// $distribution = [
// '0-100' => 0,
// '100-300' => 0,
// '300-500' => 0,
// '500-1000' => 0,
// '1000+' => 0
// ];
// $total = count($orders);
// foreach ($orders as $order) {
// $amount = $order['pay_fee'];
// if ($amount < 100) {
// $distribution['0-100']++;
// } elseif ($amount < 300) {
// $distribution['100-300']++;
// } elseif ($amount < 500) {
// $distribution['300-500']++;
// } elseif ($amount < 1000) {
// $distribution['500-1000']++;
// } else {
// $distribution['1000+']++;
// }
// }
// foreach ($distribution as &$val) {
// $val = $total > 0 ? round($val / $total * 100) : 0;
// }
// $this->success('获取成功', null, $distribution);
// }
$days = $this->getDaysByTimeRange($timeRange);
$endTime = time();
$startTime = strtotime("-$days days");
$visitCount = 0;
try {
$visitCount = Db::name('shopro_user_goods_log')
->whereBetween('createtime', [$startTime, $endTime])
->distinct(true)
->count('user_id');
} catch (\Exception $e) {
$visitCount = 0;
/**
* 格式化时间为相对时间
*
* @param mixed $timestamp 时间戳或日期字符串
* @return string 格式化后的时间字符串
*/
private function formatTime($timestamp)
{
if (empty($timestamp)) {
return '未知时间';
}
$browseCount = 0;
try {
$browseCount = Db::name('shopro_user_goods_log')
->whereBetween('createtime', [$startTime, $endTime])
->where('type', 'browse')
->distinct(true)
->count('user_id');
} catch (\Exception $e) {
$browseCount = 0;
// 如果不是数字,尝试转换为时间戳
if (!is_numeric($timestamp)) {
$timestamp = strtotime($timestamp);
if ($timestamp === false) {
return '未知时间';
}
}
$cartCount = 0;
try {
$cartCount = Db::name('shopro_user_goods_log')
->whereBetween('createtime', [$startTime, $endTime])
->where('type', 'cart')
->distinct(true)
->count('user_id');
} catch (\Exception $e) {
$cartCount = 0;
$timestamp = (int)$timestamp;
// 验证时间戳是否在合理范围内(1970年到2100年)
if ($timestamp < 0 || $timestamp > 4102444800) {
return '未知时间';
}
$submitOrderCount = Order::whereBetween('createtime', [$startTime, $endTime])
->distinct(true)
->count('user_id');
$payCount = Order::whereBetween('createtime', [$startTime, $endTime])
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->distinct(true)
->count('user_id');
$now = time();
$diff = $now - $timestamp;
$this->success('获取成功', null, [
'visit' => $visitCount,
'browse' => $browseCount,
'cart' => $cartCount,
'submit_order' => $submitOrderCount,
'pay' => $payCount
]);
if ($diff < 60) {
return '刚刚';
} elseif ($diff < 3600) {
return floor($diff / 60) . '分钟前';
} elseif ($diff < 86400) {
return floor($diff / 3600) . '小时前';
} else {
return date('m-d H:i', $timestamp);
}
}
/**
* 获取客单价分布
* 从埋点表获取访客数
*
* 使用与定时任务相同的逻辑统计去重访客数
* 用户识别优先级:user_id > session_id > ip
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 去重后的访客数量
*/
// private function getVisitorCountFromTrack($startTime, $endTime)
// {
// $table = config('database.prefix') . 'shopro_miniprogram_track';
// $sql = "SELECT COUNT(DISTINCT CASE
// WHEN user_id > 0 THEN CONCAT('u_', user_id)
// WHEN session_id <> '' THEN CONCAT('s_', session_id)
// ELSE CONCAT('i_', ip)
// END) AS total
// FROM `{$table}`
// WHERE create_time BETWEEN ? AND ?";
// $result = Db::query($sql, [$startTime, $endTime]);
// return isset($result[0]['total']) ? (int)$result[0]['total'] : 0;
// }
/**
* 获取核心统计数据
*
* 统计不同价格区间的订单占比
* 优先从缓存读取,缓存未命中时降级到数据库查询
* 该接口优先从缓存读取 DataStatsCenter 定时任务生成的统计数据,
* 缓存未命中时降级到数据库实时查询。
*
* 请求参数:
* - time_range: 时间范围,可选值:'7days'、'30days'、'90days',默认 '30days'
* - period: 周期类型,可选值:'day'、'week'、'month',默认 'day'
*
* 返回数据:
* - '0-100': 0-100元订单占比(百分比)
* - '100-300': 100-300元订单占比(百分比)
* - '300-500': 300-500元订单占比(百分比)
* - '500-1000': 500-1000元订单占比(百分比)
* - '1000+': 1000元以上订单占比(百分比)
* - total_amount: 成交总额(已支付和已完成订单的支付金额总和)
* - profit_rate: 净利润率(百分比格式,如 '18.6%')
* - avg_amount: 客单价(成交总额 / 订单数)
* - order_count: 订单数(已支付和已完成订单数量)
* - visitor_count: 访客数(基于小程序行为埋点统计的去重用户数)
* - conversion_rate: 转化率(订单数 / 访客数,百分比格式)
* - register_users: 注册用户数(累计注册用户总数)
* - trend_data: 趋势数据数组,每个元素包含:
* - date: 日期标签
* - amount: 该日期的成交额
* - order_num: 该日期的订单数
* - register_num: 该日期的注册用户数
* - from_cache: 数据来源标识,true 表示来自缓存,false 表示来自实时查询
*/
public function priceDistribution()
public function statistics()
{
$timeRange = $this->request->param('time_range', '30days');
$period = $this->request->param('period', 'day');
$cacheKey = 'shopro:data_stats_center:' . $timeRange;
$cachedData = cache($cacheKey);
if ($cachedData !== false && isset($cachedData['price_distribution'])) {
$distribution = $cachedData['price_distribution'];
$result = [];
foreach ($distribution as $item) {
$result[$item['label']] = $item['rate'];
}
if ($cachedData !== false && isset($cachedData['summary']) && isset($cachedData['trend'])) {
$updateTime = Db::name('shopro_data_stats_center')
->order('updated_at', 'desc')
->value('updated_at');
$result = $this->buildStatisticsResult($cachedData, $period, true, $updateTime);
$this->success('获取成功', null, $result);
}
$days = $this->getDaysByTimeRange($timeRange);
$endTime = time();
$startTime = strtotime("-$days days");
$orders = Order::whereBetween('createtime', [$startTime, $endTime])
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->field('pay_fee')
->select();
$distribution = [
'0-100' => 0,
'100-300' => 0,
'300-500' => 0,
'500-1000' => 0,
'1000+' => 0
];
$total = count($orders);
foreach ($orders as $order) {
$amount = $order['pay_fee'];
if ($amount < 100) {
$distribution['0-100']++;
} elseif ($amount < 300) {
$distribution['100-300']++;
} elseif ($amount < 500) {
$distribution['300-500']++;
} elseif ($amount < 1000) {
$distribution['500-1000']++;
} else {
$distribution['1000+']++;
}
}
foreach ($distribution as &$val) {
$val = $total > 0 ? round($val / $total * 100) : 0;
$latestRecord = Db::name('shopro_data_stats_center')
->order('created_at', 'desc')
->find();
if ($latestRecord) {
$statsData = [
'summary' => json_decode($latestRecord['summary_json'], true) ?: [],
'trend' => json_decode($latestRecord['trend_json'], true) ?: [],
'funnel' => json_decode($latestRecord['funnel_json'], true) ?: [],
'price_distribution' => json_decode($latestRecord['distribution_json'], true) ?: []
];
cache($cacheKey, $statsData, 86400);
$result = $this->buildStatisticsResult($statsData, $period, false, $latestRecord['created_at']);
$this->success('获取成功', null, $result);
}
$this->success('获取成功', null, $distribution);
$this->success('获取成功', null, $this->getEmptyStatisticsResult());
}
/**
......@@ -657,70 +644,4 @@ class Dashboard extends Common
]);
}
/**
* 格式化时间为相对时间
*
* @param mixed $timestamp 时间戳或日期字符串
* @return string 格式化后的时间字符串
*/
private function formatTime($timestamp)
{
if (empty($timestamp)) {
return '未知时间';
}
// 如果不是数字,尝试转换为时间戳
if (!is_numeric($timestamp)) {
$timestamp = strtotime($timestamp);
if ($timestamp === false) {
return '未知时间';
}
}
$timestamp = (int)$timestamp;
// 验证时间戳是否在合理范围内(1970年到2100年)
if ($timestamp < 0 || $timestamp > 4102444800) {
return '未知时间';
}
$now = time();
$diff = $now - $timestamp;
if ($diff < 60) {
return '刚刚';
} elseif ($diff < 3600) {
return floor($diff / 60) . '分钟前';
} elseif ($diff < 86400) {
return floor($diff / 3600) . '小时前';
} else {
return date('m-d H:i', $timestamp);
}
}
/**
* 从埋点表获取访客数
*
* 使用与定时任务相同的逻辑统计去重访客数
* 用户识别优先级:user_id > session_id > ip
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 去重后的访客数量
*/
private function getVisitorCountFromTrack($startTime, $endTime)
{
$table = config('database.prefix') . 'shopro_miniprogram_track';
$sql = "SELECT COUNT(DISTINCT CASE
WHEN user_id > 0 THEN CONCAT('u_', user_id)
WHEN session_id <> '' THEN CONCAT('s_', session_id)
ELSE CONCAT('i_', ip)
END) AS total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?";
$result = Db::query($sql, [$startTime, $endTime]);
return isset($result[0]['total']) ? (int)$result[0]['total'] : 0;
}
}
\ No newline at end of file
......@@ -180,45 +180,54 @@ class DataStatsCenter extends Command
$currentRange = $this->buildRange($days, $snapshotTime);
$previousRange = $this->buildRange($days, $currentRange['start'] - 1);
// 获取当前周期和上一周期的核心交易指标
$this->customLog('info', '开始获取当前&上一期统计周期核心交易指标');
$this->customLog('info', '周期时间:[ ' . date('Y-m-d', $currentRange['start']) . '] 至 [ ' . date('Y-m-d', $currentRange['end']) . ']');
// 获取当前周期和上一周期的核心交易指标 (成交总额、净利润率,客单价,订单数)
$this->customLog('info', PHP_EOL . '核心指标-开始构建 : 成交总额、净利润率,客单价,订单数,访客数,累计注册用户数,转化率');
$currentRevenue = $this->getRevenueMetrics($currentRange['start'], $currentRange['end']);
$this->customLog('info', '核心指标-当前周期-核心交易指标 成交总额、净利润率,客单价,订单数 : ' . json_encode($currentRevenue));
$previousRevenue = $this->getRevenueMetrics($previousRange['start'], $previousRange['end']);
$this->customLog('info', '核心指标-上一周期-核心交易指标 成交总额、净利润率,客单价,订单数 : ' . json_encode($previousRevenue));
// 获取当前周期和上一周期的访客数
$this->customLog('info', '开始获取当前&上一期统计周期访客数');
$currentVisitors = $this->getVisitorCount($currentRange['start'], $currentRange['end']);
$this->customLog('info', '核心指标-当前周期-访客数 : ' . $currentVisitors);
$previousVisitors = $this->getVisitorCount($previousRange['start'], $previousRange['end']);
$this->customLog('info', '核心指标-上一周期-访客数 : ' . $previousVisitors);
// 获取累计注册用户数(截至当前周期结束和上一周期结束)
$this->customLog('info', '开始获取当前&上一期统计周期累计注册用户数');
$currentRegisterTotal = $this->getRegisterCount(0, $currentRange['end']);
$this->customLog('info', '核心指标-当前周期-累计注册用户数 : ' . $currentRegisterTotal);
$previousRegisterTotal = $this->getRegisterCount(0, $previousRange['end']);
$this->customLog('info', '核心指标-上一周期-累计注册用户数 : ' . $previousRegisterTotal);
// 计算转化率(购买用户数/访客数)
$this->customLog('info', '开始计算当前&上一期统计周期转化率');
$currentConversion = $currentVisitors > 0 ? round($currentRevenue['buyer_count'] / $currentVisitors * 100, 2) : 0;
$previousConversion = $previousVisitors > 0 ? round($previousRevenue['buyer_count'] / $previousVisitors * 100, 2) : 0;
$this->customLog('info', '核心指标-当前周期-转化率 : ' . $currentConversion . ' 核心指标-上一周期-转化率 : ' . $previousConversion . PHP_EOL);
// 构建趋势数据
$this->customLog('info', '开始构建当前统计周期趋势数据');
$this->customLog('info', '趋势数据-开始构建 : 成交总额趋势、订单数趋势、注册用户增长趋势');
$trendData = [
'amount' => $this->buildAmountTrend($days, $snapshotTime),
'orders' => $this->buildOrderTrend($days, $snapshotTime),
'register' => $this->buildRegisterTrend($days, $snapshotTime),
];
$this->customLog('info', '趋势数据-统计结果 : ' . json_encode($trendData) . PHP_EOL);
// 构建漏斗数据
$this->customLog('info', '开始构建当前统计周期转化漏斗数据');
$this->customLog('info', '转化漏斗数据-开始构建');
$funnelData = $this->buildFunnelData($currentRange['start'], $currentRange['end']);
$this->customLog('info', '转化漏斗数据-统计结果 : ' . json_encode($funnelData) . PHP_EOL);
// 构建价格分布
$this->customLog('info', '开始构建当前统计周期客单价分布');
$this->customLog('info', '客单价分布-开始构建');
$priceDistribution = $this->buildPriceDistribution($currentRange['start'], $currentRange['end']);
$this->customLog('info', '客单价分布-统计结果 : ' . json_encode($priceDistribution) . PHP_EOL);
// 获取最近订单
$this->customLog('info', '开始获取当前统计周期最近订单');
$recentOrders = $this->getRecentOrders(10);
// 获取最近订单 页面单独分页访问,不需要在快照中包含,暂时去掉
// $this->customLog('info', '开始获取当前统计周期最近订单');
// $recentOrders = $this->getRecentOrders(10);
// 组装完整的统计数据结构
return [
......@@ -244,7 +253,7 @@ class DataStatsCenter extends Command
'trend' => $trendData,
'funnel' => $funnelData,
'price_distribution' => $priceDistribution,
'recent_orders' => $recentOrders,
// 'recent_orders' => $recentOrders,
'extra' => [
'current_revenue' => $currentRevenue,
'previous_revenue' => $previousRevenue,
......@@ -271,7 +280,7 @@ class DataStatsCenter extends Command
'trend_json' => json_encode($stats['trend'], JSON_UNESCAPED_UNICODE),
'funnel_json' => json_encode($stats['funnel'], JSON_UNESCAPED_UNICODE),
'distribution_json' => json_encode($stats['price_distribution'], JSON_UNESCAPED_UNICODE),
'recent_orders_json' => json_encode($stats['recent_orders'], JSON_UNESCAPED_UNICODE),
// 'recent_orders_json' => json_encode($stats['recent_orders'], JSON_UNESCAPED_UNICODE),
'extra_json' => json_encode($stats['extra'], JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
];
......@@ -301,7 +310,7 @@ class DataStatsCenter extends Command
*/
protected function buildRange($days, $endTime)
{
$endTime = (int)$endTime;
$endTime = strtotime(date('Y-m-d 23:59:59', $endTime));
// 计算开始时间:从结束时间往前推 days-1 天的 00:00:00
$startTime = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days', $endTime)));
......@@ -332,15 +341,15 @@ class DataStatsCenter extends Command
->where('paid_time', 'between', [$startTime, $endTime])
->count();
// 统计购买用户数(去重)
// 统计购买用户数(去重)-用于计算转化率
$buyerCount = (int)Db::name('shopro_order')
->whereIn('status', ['paid', 'completed'])
->where('paid_time', 'between', [$startTime, $endTime])
->where('user_id', '>', 0)
->count('DISTINCT user_id');
// 计算客单价(成交额/订单数)
$avgAmount = $orderCount > 0 ? round($totalAmount / $orderCount, 2) : 0;
// 计算客单价(成交额/购买用户数)
$avgAmount = $buyerCount > 0 ? round($totalAmount / $buyerCount, 2) : 0;
// 统计商品成本(成本价 × 数量)
$table = $this->getFullTableName('shopro_order_item');
......@@ -396,6 +405,7 @@ class DataStatsCenter extends Command
WHERE create_time BETWEEN ? AND ?";
$result = Db::query($sql, [$startTime, $endTime]);
$count = isset($result[0]['total']) ? (int)$result[0]['total'] : 0;
return $count;
......@@ -440,7 +450,7 @@ class DataStatsCenter extends Command
{
try {
// 获取各阶段的用户数
$this->customLog('info', '开始获取当前统计周期访问用户数');
$this->customLog('info', '转化漏斗数据-开始获取当前统计周期访问用户数');
$visit = $this->getTrackVisitorCount($startTime, $endTime);
$browse = $this->getTrackGoodViewUserCount($startTime, $endTime);
$cart = $this->getTrackCartAddUserCount($startTime, $endTime);
......@@ -448,7 +458,7 @@ class DataStatsCenter extends Command
$pay = $this->getTrackPayConfirmUserCount($startTime, $endTime);
// 获取各阶段的事件数
$this->customLog('info', '开始获取当前统计周期访问事件数');
$this->customLog('info', '转化漏斗数据-开始获取当前统计周期访问事件数');
$visitCount = $this->getTrackVisitCount($startTime, $endTime);
$browseCount = $this->getTrackGoodViewCount($startTime, $endTime);
$cartCount = $this->getTrackCartAddCount($startTime, $endTime);
......@@ -468,6 +478,7 @@ class DataStatsCenter extends Command
];
} catch (\Exception $e) {
$this->customLog('warning', '使用埋点表构建转化漏斗失败' . $e->getMessage());
return [];
}
}
......@@ -529,7 +540,7 @@ class DataStatsCenter extends Command
END) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND (event_code = 'good_view' OR page_path LIKE '/pages/goods/%')";
AND (event_code = 'good_view' )";
$result = Db::query($sql, [$startTime, $endTime]);
return (int)($result[0]['total'] ?? 0);
}
......
......@@ -9,10 +9,13 @@ use think\Db;
use think\Queue;
/**
* 每日统计任务 PV UV 等指标
* 每日统计任务 PV UV 等指标
* 每天凌晨1点半执行
*
* 任务描述:每天凌晨1点半执行,统计昨日的PV UV 等指标
*
* 暂时没有业务用得到这张表,所以先不执行,待后续业务需要时,再开启执行 2026.05.25
*
*/
class StatsDaily extends Command
{
......
......@@ -9,7 +9,7 @@ use think\Db;
use think\Queue;
/**
* 重试失败的埋点数据 PV UV 等指标
* 重试失败的埋点数据 PV UV 等指标
* 每1个小时执行一次
*
* 任务描述:每1个小时执行一次,重试失败的埋点数据 PV UV 等指标
......@@ -32,7 +32,7 @@ class TrackFailedRetry extends Command
protected function customLog($level, $message) {
$this->output->writeln($message);
custom_log($message, 'stat', $level);
custom_log($message, 'stat_track_failed_retry', $level);
}
protected function execute(Input $input, Output $output) {
......
......@@ -494,14 +494,19 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'moment'], function (
return false;
});
}
// 加载转化漏斗数据
if (data.funnel_data) {
updateFunnel(data.funnel_data);
}
// 加载价格分布数据
if (data.price_distribution_data) {
updatePriceDistribution(data.price_distribution_data);
}
}
return false;
});
// 同时加载其他数据
loadFunnel(timeRange);
loadPriceDistribution(timeRange);
loadRecentOrders();
}
/**
......@@ -557,93 +562,73 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'moment'], function (
}
/**
* 加载转化漏斗数据
* @param {string} timeRange - 时间范围
* 更新转化漏斗数据
* @param {object} funnelData - 漏斗数据对象
*/
function loadFunnel(timeRange) {
Fast.api.ajax({
url: 'shopro/dashboard/funnel',
type: 'GET',
data: { time_range: timeRange }
}, function (ret, res) {
if (res.code === 1) {
var data = res.data;
var max = data.visit || 1;
function updateFunnel(funnelData) {
var max = funnelData.visit || 1;
var funnelVisit = document.getElementById('funnel-visit');
if (funnelVisit) funnelVisit.textContent = formatNumber(data.visit);
var funnelVisit = document.getElementById('funnel-visit');
if (funnelVisit) funnelVisit.textContent = formatNumber(funnelData.visit);
var funnelBrowse = document.getElementById('funnel-browse');
var browsePercent = max > 0 ? Math.round(data.browse / max * 100) : 0;
if (funnelBrowse) {
funnelBrowse.textContent = formatNumber(data.browse);
funnelBrowse.style.width = browsePercent + '%';
}
var funnelBrowse = document.getElementById('funnel-browse');
var browsePercent = max > 0 ? Math.round(funnelData.browse / max * 100) : 0;
if (funnelBrowse) {
funnelBrowse.textContent = formatNumber(funnelData.browse);
funnelBrowse.style.width = browsePercent + '%';
}
var funnelCart = document.getElementById('funnel-cart');
var cartPercent = max > 0 ? Math.round(data.cart / max * 100) : 0;
if (funnelCart) {
funnelCart.textContent = formatNumber(data.cart);
funnelCart.style.width = cartPercent + '%';
}
var funnelCart = document.getElementById('funnel-cart');
var cartPercent = max > 0 ? Math.round(funnelData.cart / max * 100) : 0;
if (funnelCart) {
funnelCart.textContent = formatNumber(funnelData.cart);
funnelCart.style.width = cartPercent + '%';
}
var funnelSubmit = document.getElementById('funnel-submit');
var submitPercent = max > 0 ? Math.round(data.submit_order / max * 100) : 0;
if (funnelSubmit) {
funnelSubmit.textContent = formatNumber(data.submit_order);
funnelSubmit.style.width = submitPercent + '%';
}
var funnelSubmit = document.getElementById('funnel-submit');
var submitPercent = max > 0 ? Math.round(funnelData.submit_order / max * 100) : 0;
if (funnelSubmit) {
funnelSubmit.textContent = formatNumber(funnelData.submit_order);
funnelSubmit.style.width = submitPercent + '%';
}
var funnelPay = document.getElementById('funnel-pay');
var payPercent = max > 0 ? Math.round(data.pay / max * 100) : 0;
if (funnelPay) {
funnelPay.textContent = formatNumber(data.pay);
funnelPay.style.width = payPercent + '%';
}
}
return false;
});
var funnelPay = document.getElementById('funnel-pay');
var payPercent = max > 0 ? Math.round(funnelData.pay / max * 100) : 0;
if (funnelPay) {
funnelPay.textContent = formatNumber(funnelData.pay);
funnelPay.style.width = payPercent + '%';
}
}
/**
* 加载价格分布数据
* @param {string} timeRange - 时间范围
* 更新价格分布数据
* @param {object} distributionData - 价格分布数据对象
*/
function loadPriceDistribution(timeRange) {
Fast.api.ajax({
url: 'shopro/dashboard/priceDistribution',
type: 'GET',
data: { time_range: timeRange }
}, function (ret, res) {
if (res.code === 1) {
var data = res.data;
var price0100 = document.getElementById('price-0-100');
var price0100Percent = document.getElementById('price-0-100-percent');
if (price0100) price0100.style.width = data['0-100'] + '%';
if (price0100Percent) price0100Percent.textContent = data['0-100'] + '%';
var price100300 = document.getElementById('price-100-300');
var price100300Percent = document.getElementById('price-100-300-percent');
if (price100300) price100300.style.width = data['100-300'] + '%';
if (price100300Percent) price100300Percent.textContent = data['100-300'] + '%';
var price300500 = document.getElementById('price-300-500');
var price300500Percent = document.getElementById('price-300-500-percent');
if (price300500) price300500.style.width = data['300-500'] + '%';
if (price300500Percent) price300500Percent.textContent = data['300-500'] + '%';
var price5001000 = document.getElementById('price-500-1000');
var price5001000Percent = document.getElementById('price-500-1000-percent');
if (price5001000) price5001000.style.width = data['500-1000'] + '%';
if (price5001000Percent) price5001000Percent.textContent = data['500-1000'] + '%';
var price1000 = document.getElementById('price-1000');
var price1000Percent = document.getElementById('price-1000-percent');
if (price1000) price1000.style.width = data['1000+'] + '%';
if (price1000Percent) price1000Percent.textContent = data['1000+'] + '%';
}
return false;
});
function updatePriceDistribution(distributionData) {
var price0100 = document.getElementById('price-0-100');
var price0100Percent = document.getElementById('price-0-100-percent');
if (price0100) price0100.style.width = (distributionData['0-100'] || 0) + '%';
if (price0100Percent) price0100Percent.textContent = (distributionData['0-100'] || 0) + '%';
var price100300 = document.getElementById('price-100-300');
var price100300Percent = document.getElementById('price-100-300-percent');
if (price100300) price100300.style.width = (distributionData['100-300'] || 0) + '%';
if (price100300Percent) price100300Percent.textContent = (distributionData['100-300'] || 0) + '%';
var price300500 = document.getElementById('price-300-500');
var price300500Percent = document.getElementById('price-300-500-percent');
if (price300500) price300500.style.width = (distributionData['300-500'] || 0) + '%';
if (price300500Percent) price300500Percent.textContent = (distributionData['300-500'] || 0) + '%';
var price5001000 = document.getElementById('price-500-1000');
var price5001000Percent = document.getElementById('price-500-1000-percent');
if (price5001000) price5001000.style.width = (distributionData['500-1000'] || 0) + '%';
if (price5001000Percent) price5001000Percent.textContent = (distributionData['500-1000'] || 0) + '%';
var price1000 = document.getElementById('price-1000');
var price1000Percent = document.getElementById('price-1000-percent');
if (price1000) price1000.style.width = (distributionData['1000+'] || 0) + '%';
if (price1000Percent) price1000Percent.textContent = (distributionData['1000+'] || 0) + '%';
}
/**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment