Files
webman-buildadmin/app/admin/controller/Dashboard.php
2026-04-18 16:14:47 +08:00

394 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\admin\controller;
use app\common\controller\Backend;
use support\think\Db;
use Webman\Http\Request;
use support\Response;
/**
* 后台首页统计:按渠道数据范围(非超管仅本渠道)汇总用户、充值、提现、投注等核心指标。
*/
class Dashboard extends Backend
{
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$scope = $this->channelScopeOrNull();
$todayStart = strtotime(date('Y-m-d'));
$todayEnd = $todayStart + 86400 - 1;
$yesterdayStart = $todayStart - 86400;
$yesterdayEnd = $todayStart - 1;
$userTotal = $this->countUsers($scope);
$newToday = $this->countUsersInRange($scope, $todayStart, $todayEnd);
$newYesterday = $this->countUsersInRange($scope, $yesterdayStart, $yesterdayEnd);
$growthPct = null;
if ($newYesterday > 0) {
$growthPct = round(($newToday - $newYesterday) / $newYesterday * 100, 1);
} elseif ($newToday > 0) {
$growthPct = 100.0;
}
$depositAgg = $this->aggregateDepositToday($scope, $todayStart, $todayEnd);
$withdrawPending = $this->countWithdrawPending($scope);
$betAgg = $this->aggregateBetToday($scope, $todayStart, $todayEnd);
$trend = $this->buildSevenDayTrend($scope);
$channelShare = $this->buildChannelShare($scope);
$depositAmountChannelShare = $this->buildDepositAmountChannelShare($scope);
$recentUsers = $this->fetchRecentUsers($scope, 10);
return $this->success('', [
'remark' => get_route_remark(),
'stats' => [
'user_total' => $userTotal,
'user_new_today' => $newToday,
'user_new_yesterday' => $newYesterday,
'user_new_growth_pct' => $growthPct,
'deposit_today_amount' => $depositAgg['amount'],
'deposit_today_count' => $depositAgg['count'],
'withdraw_pending' => $withdrawPending,
'bet_today_amount' => $betAgg['amount'],
'bet_today_count' => $betAgg['count'],
],
'trend' => $trend,
'channel_share' => $channelShare,
'deposit_amount_channel_share' => $depositAmountChannelShare,
'recent_users' => $recentUsers,
]);
}
/**
* @param int[]|null $scope null=超管不限制;非 null 时 whereIn channel_id
*/
private function countUsers(?array $scope): int
{
$q = Db::name('user');
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
return intval($q->count());
}
/**
* @param int[]|null $scope
*/
private function countUsersInRange(?array $scope, int $start, int $end): int
{
$q = Db::name('user')
->where('create_time', '>=', $start)
->where('create_time', '<=', $end);
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
return intval($q->count());
}
/**
* 今日成功充值status=1按创建日落在今日与 mock 即时成功一致)。
*
* @param int[]|null $scope
* @return array{count:int, amount:string}
*/
private function aggregateDepositToday(?array $scope, int $todayStart, int $todayEnd): array
{
$q = Db::name('deposit_order')
->where('status', 1)
->where('create_time', '>=', $todayStart)
->where('create_time', '<=', $todayEnd);
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
$rows = $q->fieldRaw('COUNT(*) AS c, COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')->find();
if (!is_array($rows)) {
$rows = [];
}
$count = isset($rows['c']) ? intval($rows['c']) : 0;
$sum = isset($rows['s']) ? strval($rows['s']) : '0';
$amount = $this->formatMoney2($sum);
return ['count' => $count, 'amount' => $amount];
}
/**
* @param int[]|null $scope
*/
private function countWithdrawPending(?array $scope): int
{
$q = Db::name('withdraw_order')->where('status', 0);
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
return intval($q->count());
}
/**
* 今日投注创建时间在今日且订单未作废status 1 或 2
*
* @param int[]|null $scope
* @return array{count:int, amount:string}
*/
private function aggregateBetToday(?array $scope, int $todayStart, int $todayEnd): array
{
$q = Db::name('bet_order')
->whereIn('status', [1, 2])
->where('create_time', '>=', $todayStart)
->where('create_time', '<=', $todayEnd);
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
$rows = $q->fieldRaw('COUNT(*) AS c, COALESCE(SUM(CAST(total_amount AS DECIMAL(18,4))),0) AS s')->find();
if (!is_array($rows)) {
$rows = [];
}
$count = isset($rows['c']) ? intval($rows['c']) : 0;
$sum = isset($rows['s']) ? strval($rows['s']) : '0';
return ['count' => $count, 'amount' => $this->formatMoney2($sum)];
}
/**
* @param int[]|null $scope
* @return array{days:string[], new_users:int[], deposit_amount:string[], bet_amount:string[]}
*/
private function buildSevenDayTrend(?array $scope): array
{
$days = [];
$newUsers = [];
$depositAmounts = [];
$betAmounts = [];
for ($i = 6; $i >= 0; $i--) {
$dayStart = strtotime(date('Y-m-d', strtotime('-' . $i . ' day')));
$dayEnd = $dayStart + 86400 - 1;
$days[] = date('m-d', $dayStart);
$newUsers[] = $this->countUsersInRange($scope, $dayStart, $dayEnd);
$dq = Db::name('deposit_order')
->where('status', 1)
->where('create_time', '>=', $dayStart)
->where('create_time', '<=', $dayEnd);
if ($scope !== null) {
$dq->whereIn('channel_id', $scope);
}
$drow = $dq->fieldRaw('COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')->find();
$dsum = is_array($drow) && isset($drow['s']) ? strval($drow['s']) : '0';
$depositAmounts[] = $this->formatMoney2($dsum);
$bq = Db::name('bet_order')
->whereIn('status', [1, 2])
->where('create_time', '>=', $dayStart)
->where('create_time', '<=', $dayEnd);
if ($scope !== null) {
$bq->whereIn('channel_id', $scope);
}
$brow = $bq->fieldRaw('COALESCE(SUM(CAST(total_amount AS DECIMAL(18,4))),0) AS s')->find();
$bsum = is_array($brow) && isset($brow['s']) ? strval($brow['s']) : '0';
$betAmounts[] = $this->formatMoney2($bsum);
}
return [
'days' => $days,
'new_users' => $newUsers,
'deposit_amount' => $depositAmounts,
'bet_amount' => $betAmounts,
];
}
/**
* 用户按渠道分布(取前 8 名,其余合并为「其他」)。
*
* @param int[]|null $scope
* @return list<array{name:string, value:int}>
*/
private function buildChannelShare(?array $scope): array
{
$q = Db::name('user')->fieldRaw('channel_id, COUNT(*) AS c')->group('channel_id');
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
$rows = $q->orderRaw('c DESC')->select()->toArray();
if ($rows === []) {
return [];
}
$channelNames = Db::name('channel')->column('name', 'id');
$list = [];
foreach ($rows as $row) {
$cid = $row['channel_id'];
$cnt = intval($row['c'] ?? 0);
if ($cid === null || $cid === '' || intval(strval($cid)) === 0) {
$name = '未分配渠道';
} else {
$id = intval(strval($cid));
$name = $channelNames[$id] ?? ('#' . strval($id));
}
$list[] = ['name' => $name, 'value' => $cnt];
}
if (count($list) <= 8) {
return $list;
}
$head = array_slice($list, 0, 8);
$rest = array_slice($list, 8);
$other = 0;
foreach ($rest as $item) {
$other += $item['value'];
}
if ($other > 0) {
$head[] = ['name' => '其他', 'value' => $other];
}
return $head;
}
/**
* 成功充值金额按订单归属渠道汇总status=1受渠道范围限制
*
* @param int[]|null $scope
* @return list<array{name:string, value:string}> value 为两位小数字符串,供前端饼图展示
*/
private function buildDepositAmountChannelShare(?array $scope): array
{
$q = Db::name('deposit_order')
->where('status', 1)
->fieldRaw('channel_id, COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')
->group('channel_id');
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
$rows = $q->select()->toArray();
if ($rows === []) {
return [];
}
$channelNames = Db::name('channel')->column('name', 'id');
$list = [];
foreach ($rows as $row) {
$cid = $row['channel_id'];
$sumRaw = isset($row['s']) ? strval($row['s']) : '0';
$amountStr = $this->formatMoney2($sumRaw);
if (bccomp($amountStr, '0', 2) <= 0) {
continue;
}
if ($cid === null || $cid === '' || intval(strval($cid)) === 0) {
$name = '未分配渠道';
} else {
$id = intval(strval($cid));
$name = $channelNames[$id] ?? ('#' . strval($id));
}
$list[] = [
'name' => $name,
'value' => $amountStr,
];
}
usort($list, static function (array $a, array $b): int {
return bccomp($b['value'], $a['value'], 2);
});
if (count($list) <= 8) {
return $list;
}
$head = array_slice($list, 0, 8);
$rest = array_slice($list, 8);
$other = '0';
foreach ($rest as $item) {
$other = bcadd($other, $item['value'], 4);
}
$otherFormatted = $this->formatMoney2($other);
if (bccomp($otherFormatted, '0', 2) > 0) {
$head[] = [
'name' => '其他',
'value' => $otherFormatted,
];
}
return $head;
}
/**
* @param int[]|null $scope
* @return list<array{id:int, username:string, create_time:int, channel_name:string, head_image:string}>
*/
private function fetchRecentUsers(?array $scope, int $limit): array
{
$q = Db::name('user')
->field(['id', 'username', 'create_time', 'channel_id', 'head_image'])
->order('id', 'desc')
->limit($limit);
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
$rows = $q->select()->toArray();
if ($rows === []) {
return [];
}
$channelNames = Db::name('channel')->column('name', 'id');
$out = [];
foreach ($rows as $row) {
$cid = $row['channel_id'] ?? null;
if ($cid === null || $cid === '' || intval(strval($cid)) === 0) {
$cname = '';
} else {
$id = intval(strval($cid));
$cname = $channelNames[$id] ?? '';
}
$out[] = [
'id' => intval($row['id'] ?? 0),
'username' => strval($row['username'] ?? ''),
'create_time' => intval($row['create_time'] ?? 0),
'channel_name' => $cname,
'head_image' => strval($row['head_image'] ?? ''),
];
}
return $out;
}
private function formatMoney2(string $amount): string
{
if (!is_numeric($amount)) {
return '0.00';
}
$normalized = bcadd($amount, '0', 4);
return bcadd($normalized, '0', 2);
}
/**
* 非超管:按管理员所属渠道过滤;未绑定渠道时按 channel_id IN (0) 与列表页一致。
* 超管:返回 null表示 SQL 不加渠道条件。
*
* @return int[]|null
*/
private function channelScopeOrNull(): ?array
{
if (!$this->auth || $this->auth->isSuperAdmin()) {
return null;
}
$admin = Db::name('admin')->field(['id', 'channel_id'])->where('id', $this->auth->id)->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
return $ids !== [] ? array_values(array_unique($ids)) : [0];
}
}