Files
datahub/backend/app/Service/DashboardStatsService.php
T
2026-03-17 12:46:47 +08:00

443 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use Carbon\Carbon;
use Hyperf\DbConnection\Db;
/**
* Dashboard 统计服务
*
* 封装 overview / trend / breakdown 三类聚合查询,
* 使用原生 SQL 提高聚合效率,参数绑定防注入。
*/
class DashboardStatsService
{
/**
* data_type => 实体表映射
*/
private const TYPE_TABLE_MAP = [
'order' => 'orders',
'product' => 'products',
'refund' => 'refunds',
];
/**
* 允许的 data_type 值(含 inventory,仅统计失败数)
*/
public const VALID_DATA_TYPES = ['order', 'product', 'refund', 'inventory'];
/**
* 允许的 group_by 值
*/
public const VALID_GROUP_BY = ['day', 'week', 'month'];
/**
* 允许的 breakdown dimension 值
*/
public const VALID_DIMENSIONS = ['company', 'platform', 'store'];
/**
* dimension => 表名和名称字段映射
*/
private const DIMENSION_TABLE_MAP = [
'company' => ['table' => 'companies', 'name_field' => 'name'],
'platform' => ['table' => 'platforms', 'name_field' => 'name'],
'store' => ['table' => 'stores', 'name_field' => 'name'],
];
/**
* 获取概览统计
*
* @return array{today: array, this_week: array, this_month: array, by_type: array}
*/
public function getOverview(string $scope_type, array $scope_ids): array
{
$now = Carbon::now();
$today_start = $now->copy()->startOfDay()->toDateTimeString();
$today_end = $now->copy()->endOfDay()->toDateTimeString();
$week_start = $now->copy()->startOfWeek()->toDateTimeString();
$month_start = $now->copy()->startOfMonth()->toDateTimeString();
return [
'today' => [
'success' => $this->countAllSuccess($today_start, $today_end, $scope_type, $scope_ids),
'failed' => $this->countFailed($today_start, $today_end, $scope_type, $scope_ids),
],
'this_week' => [
'success' => $this->countAllSuccess($week_start, $today_end, $scope_type, $scope_ids),
'failed' => $this->countFailed($week_start, $today_end, $scope_type, $scope_ids),
],
'this_month' => [
'success' => $this->countAllSuccess($month_start, $today_end, $scope_type, $scope_ids),
'failed' => $this->countFailed($month_start, $today_end, $scope_type, $scope_ids),
],
'by_type' => $this->getByTypeStats($month_start, $today_end, $scope_type, $scope_ids),
];
}
/**
* 获取趋势数据
*
* @return array<array{date: string, success: int, failed: int}>
*/
public function getTrend(
string $from,
string $to,
string $group_by,
?string $data_type,
string $scope_type,
array $scope_ids,
): array {
$trunc = $this->getDateTrunc($group_by);
$from_dt = $from . ' 00:00:00';
$to_dt = $to . ' 23:59:59';
// 成功数
$success_map = $this->getTrendSuccess($trunc, $from_dt, $to_dt, $data_type, $scope_type, $scope_ids);
// 失败数
$failed_map = $this->getTrendFailed($trunc, $from_dt, $to_dt, $data_type, $scope_type, $scope_ids);
// 合并所有日期
$all_dates = array_unique(array_merge(array_keys($success_map), array_keys($failed_map)));
sort($all_dates);
$result = [];
foreach ($all_dates as $date) {
$result[] = [
'date' => $date,
'success' => $success_map[$date] ?? 0,
'failed' => $failed_map[$date] ?? 0,
];
}
return $result;
}
/**
* 获取分组统计
*
* @return array<array{id: int, name: string, success: int, failed: int}>
*/
public function getBreakdown(
string $dimension,
string $from,
string $to,
?string $data_type,
string $scope_type,
array $scope_ids,
): array {
$from_dt = $from . ' 00:00:00';
$to_dt = $to . ' 23:59:59';
$dim_col = $dimension . '_id';
$dim_config = self::DIMENSION_TABLE_MAP[$dimension];
// 成功数按维度分组
$success_map = $this->getBreakdownSuccess($dim_col, $from_dt, $to_dt, $data_type, $scope_type, $scope_ids);
// 失败数按维度分组
$failed_map = $this->getBreakdownFailed($dim_col, $from_dt, $to_dt, $data_type, $scope_type, $scope_ids);
// 合并所有维度 ID
$all_ids = array_unique(array_merge(array_keys($success_map), array_keys($failed_map)));
if (empty($all_ids)) {
return [];
}
// 查询维度名称
$placeholders = implode(',', array_fill(0, count($all_ids), '?'));
$name_rows = Db::select(
"SELECT id, {$dim_config['name_field']} as name FROM {$dim_config['table']} WHERE id IN ({$placeholders})",
array_values($all_ids)
);
$name_map = [];
foreach ($name_rows as $row) {
$name_map[$row->id] = $row->name;
}
$result = [];
foreach ($all_ids as $id) {
$result[] = [
'id' => (int) $id,
'name' => $name_map[$id] ?? '',
'success' => $success_map[$id] ?? 0,
'failed' => $failed_map[$id] ?? 0,
];
}
// 按 success 降序排列
usort($result, fn($a, $b) => $b['success'] <=> $a['success']);
return $result;
}
/**
* 统计所有实体表的成功总数
*/
private function countAllSuccess(string $from, string $to, string $scope_type, array $scope_ids): int
{
$total = 0;
foreach (self::TYPE_TABLE_MAP as $table) {
$total += $this->countSuccess($table, $from, $to, $scope_type, $scope_ids);
}
return $total;
}
/**
* 统计单个实体表的成功数
*/
private function countSuccess(string $table, string $from, string $to, string $scope_type, array $scope_ids): int
{
$bindings = [$from, $to];
$scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings);
$sql = "SELECT COUNT(*) as cnt FROM {$table} WHERE created_at BETWEEN ? AND ?{$scope_sql}";
$result = Db::select($sql, $bindings);
return (int) ($result[0]->cnt ?? 0);
}
/**
* 统计失败消息数
*/
private function countFailed(
string $from,
string $to,
string $scope_type,
array $scope_ids,
?string $data_type = null,
): int {
$bindings = [$from, $to];
$scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings);
$type_sql = '';
if ($data_type !== null) {
$type_sql = ' AND data_type = ?';
$bindings[] = $data_type;
}
$sql = "SELECT COUNT(*) as cnt FROM failed_messages WHERE failed_at BETWEEN ? AND ?{$scope_sql}{$type_sql}";
$result = Db::select($sql, $bindings);
return (int) ($result[0]->cnt ?? 0);
}
/**
* 按 data_type 分别统计成功和失败数
*/
private function getByTypeStats(string $from, string $to, string $scope_type, array $scope_ids): array
{
$stats = [];
foreach (self::VALID_DATA_TYPES as $type) {
$success = 0;
if (isset(self::TYPE_TABLE_MAP[$type])) {
$success = $this->countSuccess(self::TYPE_TABLE_MAP[$type], $from, $to, $scope_type, $scope_ids);
}
$failed = $this->countFailed($from, $to, $scope_type, $scope_ids, $type);
$stats[] = [
'data_type' => $type,
'success' => $success,
'failed' => $failed,
];
}
return $stats;
}
/**
* 趋势查询:成功数按日期分组
*
* @return array<string, int> date => count
*/
private function getTrendSuccess(
string $trunc,
string $from,
string $to,
?string $data_type,
string $scope_type,
array $scope_ids,
): array {
$tables = $data_type !== null && isset(self::TYPE_TABLE_MAP[$data_type])
? [$data_type => self::TYPE_TABLE_MAP[$data_type]]
: self::TYPE_TABLE_MAP;
// inventory 没有实体表,跳过
if ($data_type === 'inventory') {
return [];
}
$union_parts = [];
$all_bindings = [];
foreach ($tables as $table) {
$bindings = [$from, $to];
$scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings);
$union_parts[] = "SELECT {$trunc} as period FROM {$table} WHERE created_at BETWEEN ? AND ?{$scope_sql}";
$all_bindings = array_merge($all_bindings, $bindings);
}
if (empty($union_parts)) {
return [];
}
$union_sql = implode(' UNION ALL ', $union_parts);
$sql = "SELECT period::date::text as date, COUNT(*) as cnt FROM ({$union_sql}) sub GROUP BY period ORDER BY period";
$rows = Db::select($sql, $all_bindings);
$map = [];
foreach ($rows as $row) {
$map[$row->date] = (int) $row->cnt;
}
return $map;
}
/**
* 趋势查询:失败数按日期分组
*
* @return array<string, int> date => count
*/
private function getTrendFailed(
string $trunc,
string $from,
string $to,
?string $data_type,
string $scope_type,
array $scope_ids,
): array {
$bindings = [$from, $to];
$scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings);
$type_sql = '';
if ($data_type !== null) {
$type_sql = ' AND data_type = ?';
$bindings[] = $data_type;
}
$trunc_field = str_replace('created_at', 'failed_at', $trunc);
$sql = "SELECT {$trunc_field}::date::text as date, COUNT(*) as cnt FROM failed_messages WHERE failed_at BETWEEN ? AND ?{$scope_sql}{$type_sql} GROUP BY {$trunc_field} ORDER BY {$trunc_field}";
$rows = Db::select($sql, $bindings);
$map = [];
foreach ($rows as $row) {
$map[$row->date] = (int) $row->cnt;
}
return $map;
}
/**
* 分组统计:成功数按维度分组
*
* @return array<int, int> dimension_id => count
*/
private function getBreakdownSuccess(
string $dim_col,
string $from,
string $to,
?string $data_type,
string $scope_type,
array $scope_ids,
): array {
$tables = $data_type !== null && isset(self::TYPE_TABLE_MAP[$data_type])
? [$data_type => self::TYPE_TABLE_MAP[$data_type]]
: self::TYPE_TABLE_MAP;
if ($data_type === 'inventory') {
return [];
}
$union_parts = [];
$all_bindings = [];
foreach ($tables as $table) {
$bindings = [$from, $to];
$scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings);
$union_parts[] = "SELECT {$dim_col} FROM {$table} WHERE created_at BETWEEN ? AND ?{$scope_sql}";
$all_bindings = array_merge($all_bindings, $bindings);
}
if (empty($union_parts)) {
return [];
}
$union_sql = implode(' UNION ALL ', $union_parts);
$sql = "SELECT {$dim_col} as dim_id, COUNT(*) as cnt FROM ({$union_sql}) sub GROUP BY {$dim_col}";
$rows = Db::select($sql, $all_bindings);
$map = [];
foreach ($rows as $row) {
$map[(int) $row->dim_id] = (int) $row->cnt;
}
return $map;
}
/**
* 分组统计:失败数按维度分组
*
* @return array<int, int> dimension_id => count
*/
private function getBreakdownFailed(
string $dim_col,
string $from,
string $to,
?string $data_type,
string $scope_type,
array $scope_ids,
): array {
$bindings = [$from, $to];
$scope_sql = $this->buildScopeCondition($scope_type, $scope_ids, $bindings);
$type_sql = '';
if ($data_type !== null) {
$type_sql = ' AND data_type = ?';
$bindings[] = $data_type;
}
$sql = "SELECT {$dim_col} as dim_id, COUNT(*) as cnt FROM failed_messages WHERE failed_at BETWEEN ? AND ?{$scope_sql}{$type_sql} GROUP BY {$dim_col}";
$rows = Db::select($sql, $bindings);
$map = [];
foreach ($rows as $row) {
if ($row->dim_id !== null) {
$map[(int) $row->dim_id] = (int) $row->cnt;
}
}
return $map;
}
/**
* 生成 DataScope SQL 条件片段
*/
private function buildScopeCondition(string $scope_type, array $scope_ids, array &$bindings): string
{
if ($scope_type === 'all' || empty($scope_ids)) {
return '';
}
$placeholders = implode(',', array_fill(0, count($scope_ids), '?'));
if ($scope_type === 'store') {
$bindings = array_merge($bindings, $scope_ids);
return " AND store_id IN ({$placeholders})";
}
if ($scope_type === 'platform') {
$bindings = array_merge($bindings, $scope_ids);
return " AND platform_id IN ({$placeholders})";
}
return '';
}
/**
* 获取 DATE_TRUNC SQL 表达式
*/
private function getDateTrunc(string $group_by): string
{
return match ($group_by) {
'day' => "DATE_TRUNC('day', created_at)",
'week' => "DATE_TRUNC('week', created_at)",
'month' => "DATE_TRUNC('month', created_at)",
};
}
}