feat: stats
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Channel;
|
||||
use App\Models\DailyStat;
|
||||
use App\Models\Device;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\DashboardStatsService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DailyReportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const CHANNEL = 'ffffffffffffffffffffffffffffffff';
|
||||
|
||||
private const OTHER = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'coruna.panel.admin_hosts' => [],
|
||||
'coruna.panel.agent_hosts' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function schedule_includes_daily_stats(): void
|
||||
{
|
||||
$this->artisan('schedule:list')
|
||||
->expectsOutputToContain('coruna:daily-stats')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_page_matches_dashboard_today_and_lists_thirty_days(): void
|
||||
{
|
||||
$this->seedTodayVisitsAndDevices();
|
||||
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$dash = app(DashboardStatsService::class)->collect(['range' => 'today']);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->get(route('admin.reports.daily'))
|
||||
->assertOk()
|
||||
->assertSee('按日期统计')
|
||||
->assertSee('总 UV')
|
||||
->assertSee('有效 UV')
|
||||
->assertSee('受控设备')
|
||||
->assertSee('受控比例')
|
||||
->assertSee('有钱包设备')
|
||||
->assertSee('钱包地址')
|
||||
->assertSee('助记词数')
|
||||
->assertSee('近 30 天')
|
||||
->assertSee('LAY-daily-date-range')
|
||||
->assertSee('LAY-daily-report')
|
||||
->assertSee('LAY-daily-report-chart')
|
||||
->assertSee('echarts')
|
||||
->assertSee("'受控设备': true", false)
|
||||
->assertSee('LAY-daily-search')
|
||||
->assertSee('代理用户')
|
||||
->assertSee(now()->toDateString())
|
||||
->assertDontSee('最近30天');
|
||||
|
||||
$payload = $this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.reports.daily.data'))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.from', now()->subDays(29)->toDateString())
|
||||
->assertJsonPath('data.to', now()->toDateString())
|
||||
->json('data');
|
||||
|
||||
$this->assertCount(30, $payload['rows']);
|
||||
$this->assertSame(now()->toDateString(), $payload['rows'][0]['date']);
|
||||
$this->assertSame($dash['uv'], $payload['rows'][0]['uv']);
|
||||
$this->assertSame($dash['effective_uv'], $payload['rows'][0]['effective_uv']);
|
||||
$this->assertSame($dash['total'], $payload['rows'][0]['devices']);
|
||||
$this->assertEquals(round($dash['total'] / $dash['effective_uv'] * 100, 1), $payload['rows'][0]['control_rate']);
|
||||
$this->assertSame($dash['wallet_count'], $payload['rows'][0]['wallet_devices']);
|
||||
$this->assertSame($dash['address_count'], $payload['rows'][0]['address_count']);
|
||||
$this->assertSame($dash['mnemonic_count'], $payload['rows'][0]['mnemonic_count']);
|
||||
$this->assertNull($payload['rows'][1]['control_rate']);
|
||||
$this->assertSame(30, DailyStat::query()->where('scope_key', DailyStat::SCOPE_ALL)->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function data_accepts_custom_date_range(): void
|
||||
{
|
||||
$this->seedTodayVisitsAndDevices();
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$from = now()->subDays(2)->toDateString();
|
||||
$to = now()->toDateString();
|
||||
|
||||
$payload = $this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.reports.daily.data', [
|
||||
'date_from' => $from,
|
||||
'date_to' => $to,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.from', $from)
|
||||
->assertJsonPath('data.to', $to)
|
||||
->json('data');
|
||||
|
||||
$this->assertCount(3, $payload['rows']);
|
||||
$this->assertSame($to, $payload['rows'][0]['date']);
|
||||
$this->assertSame($from, $payload['rows'][2]['date']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function data_can_span_more_than_thirty_days(): void
|
||||
{
|
||||
$old = now()->subDays(40);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'old-uv',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => $old,
|
||||
]);
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
|
||||
$payload = $this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.reports.daily.data', [
|
||||
'date_from' => $old->toDateString(),
|
||||
'date_to' => now()->toDateString(),
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.from', $old->toDateString())
|
||||
->assertJsonPath('data.to', now()->toDateString())
|
||||
->json('data');
|
||||
|
||||
$this->assertCount(41, $payload['rows']);
|
||||
$oldRow = collect($payload['rows'])->firstWhere('date', $old->toDateString());
|
||||
$this->assertNotNull($oldRow);
|
||||
$this->assertSame(1, $oldRow['uv']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function command_rebuilds_historical_day(): void
|
||||
{
|
||||
$yesterday = now()->subDay();
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'hist-1',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => $yesterday,
|
||||
]);
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'hist-dev',
|
||||
'channel_id' => self::CHANNEL,
|
||||
'has_wallet' => Device::WALLET_YES,
|
||||
]);
|
||||
\Illuminate\Support\Facades\DB::table('devices')->where('id', $device->id)->update([
|
||||
'created_at' => $yesterday->toDateTimeString(),
|
||||
'updated_at' => $yesterday->toDateTimeString(),
|
||||
]);
|
||||
$addr = WalletAddress::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'address' => 'THistDailyAddr1111111111111111111',
|
||||
'chain_type' => 'TRON',
|
||||
]);
|
||||
\Illuminate\Support\Facades\DB::table('wallet_addresses')->where('id', $addr->id)->update([
|
||||
'created_at' => $yesterday->toDateTimeString(),
|
||||
'updated_at' => $yesterday->toDateTimeString(),
|
||||
]);
|
||||
$mnemonic = new WalletMnemonic([
|
||||
'device_id' => $device->id,
|
||||
'source' => 'imToken',
|
||||
]);
|
||||
$mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
$mnemonic->save();
|
||||
\Illuminate\Support\Facades\DB::table('wallet_mnemonics')->where('id', $mnemonic->id)->update([
|
||||
'created_at' => $yesterday->toDateTimeString(),
|
||||
'updated_at' => $yesterday->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$this->artisan('coruna:daily-stats', ['--days' => 2])
|
||||
->expectsOutputToContain('daily-stats rebuilt=2')
|
||||
->assertSuccessful();
|
||||
|
||||
$row = DailyStat::query()
|
||||
->where('scope_key', DailyStat::SCOPE_ALL)
|
||||
->where('stat_date', $yesterday->toDateString())
|
||||
->first();
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame(1, $row->uv);
|
||||
$this->assertSame(1, $row->effective_uv);
|
||||
$this->assertSame(1, $row->devices);
|
||||
$this->assertSame(1, $row->wallet_devices);
|
||||
$this->assertSame(1, $row->address_count);
|
||||
$this->assertSame(1, $row->mnemonic_count);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function command_all_backfills_from_earliest_row(): void
|
||||
{
|
||||
$old = now()->subDays(40);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'old-uv',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => $old,
|
||||
]);
|
||||
|
||||
$this->artisan('coruna:daily-stats', ['--all' => true])
|
||||
->expectsOutputToContain('daily-stats rebuilt=')
|
||||
->assertSuccessful();
|
||||
|
||||
$row = DailyStat::query()
|
||||
->where('scope_key', DailyStat::SCOPE_ALL)
|
||||
->where('stat_date', $old->toDateString())
|
||||
->first();
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame(1, $row->uv);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_only_sees_own_channel(): void
|
||||
{
|
||||
$agent = User::query()->create(['username' => 'rep-agent', 'password' => 'secret12', 'status' => 1]);
|
||||
Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]);
|
||||
Channel::query()->create(['channel_id' => self::OTHER, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]);
|
||||
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'mine',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::OTHER,
|
||||
'client_uid' => 'other',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'mine-dev',
|
||||
'channel_id' => self::CHANNEL,
|
||||
'has_wallet' => Device::WALLET_YES,
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'other-dev',
|
||||
'channel_id' => self::OTHER,
|
||||
'has_wallet' => Device::WALLET_YES,
|
||||
]);
|
||||
|
||||
$this->actingAs($agent, 'agent')
|
||||
->get(route('user.reports.daily'))
|
||||
->assertOk()
|
||||
->assertSee('按日期统计')
|
||||
->assertSee('近 30 天')
|
||||
->assertDontSee('代理用户');
|
||||
|
||||
$payload = $this->actingAs($agent, 'agent')
|
||||
->getJson(route('user.reports.daily.data'))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->json('data');
|
||||
|
||||
$this->assertSame(1, $payload['rows'][0]['uv']);
|
||||
$this->assertSame(1, $payload['rows'][0]['devices']);
|
||||
$this->assertEquals(100, $payload['rows'][0]['control_rate']);
|
||||
$this->assertSame(1, $payload['rows'][0]['wallet_devices']);
|
||||
$this->assertSame(30, DailyStat::query()->where('scope_key', 'a:'.$agent->id)->count());
|
||||
$this->assertSame(0, (int) DailyStat::query()->where('scope_key', DailyStat::SCOPE_ALL)->sum('uv'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_filter_by_agent(): void
|
||||
{
|
||||
$agent = User::query()->create(['username' => 'filter-agent', 'password' => 'secret12', 'status' => 1]);
|
||||
Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]);
|
||||
Channel::query()->create(['channel_id' => self::OTHER, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]);
|
||||
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'mine',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::OTHER,
|
||||
'client_uid' => 'official',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'mine-dev',
|
||||
'channel_id' => self::CHANNEL,
|
||||
'has_wallet' => Device::WALLET_YES,
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'official-dev',
|
||||
'channel_id' => self::OTHER,
|
||||
'has_wallet' => Device::WALLET_NONE,
|
||||
]);
|
||||
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
|
||||
$all = $this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.reports.daily.data'))
|
||||
->assertOk()
|
||||
->json('data.rows.0');
|
||||
$this->assertSame(2, $all['uv']);
|
||||
$this->assertSame(2, $all['devices']);
|
||||
$this->assertSame(1, $all['wallet_devices']);
|
||||
|
||||
$mine = $this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.reports.daily.data', ['agent_user_id' => $agent->id]))
|
||||
->assertOk()
|
||||
->json('data.rows.0');
|
||||
$this->assertSame(1, $mine['uv']);
|
||||
$this->assertSame(1, $mine['devices']);
|
||||
$this->assertSame(1, $mine['wallet_devices']);
|
||||
|
||||
$official = $this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.reports.daily.data', ['agent_user_id' => 0]))
|
||||
->assertOk()
|
||||
->json('data.rows.0');
|
||||
$this->assertSame(1, $official['uv']);
|
||||
$this->assertSame(1, $official['devices']);
|
||||
$this->assertSame(0, $official['wallet_devices']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function command_rebuilds_agent_scopes(): void
|
||||
{
|
||||
$agent = User::query()->create(['username' => 'cron-agent', 'password' => 'secret12', 'status' => 1]);
|
||||
Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'cron-uv',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->artisan('coruna:daily-stats', ['--days' => 1])
|
||||
->expectsOutputToContain('rebuilt=1 agents=1')
|
||||
->assertSuccessful();
|
||||
|
||||
$row = DailyStat::query()
|
||||
->where('scope_key', 'a:'.$agent->id)
|
||||
->where('stat_date', now()->toDateString())
|
||||
->first();
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame(1, $row->uv);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function home_menu_includes_daily_report(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
$this->actingAs($admin, 'admin')
|
||||
->get(route('admin.home'))
|
||||
->assertOk()
|
||||
->assertSee('每日报表')
|
||||
->assertSee(route('admin.reports.daily'), false);
|
||||
}
|
||||
|
||||
private function seedTodayVisitsAndDevices(): void
|
||||
{
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'u1',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'u1',
|
||||
'os' => 'iOS',
|
||||
'os_version' => '16.6',
|
||||
'browser' => 'Safari',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL,
|
||||
'client_uid' => 'u2',
|
||||
'os' => 'Android',
|
||||
'os_version' => '13',
|
||||
'browser' => 'Chrome',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$wallet = Device::query()->create([
|
||||
'device_id' => 'dev-wallet',
|
||||
'channel_id' => self::CHANNEL,
|
||||
'has_wallet' => Device::WALLET_YES,
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'dev-empty',
|
||||
'channel_id' => self::CHANNEL,
|
||||
'has_wallet' => Device::WALLET_NONE,
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $wallet->id,
|
||||
'address' => 'TDailyReportAddr11111111111111111',
|
||||
'chain_type' => 'TRON',
|
||||
]);
|
||||
$mnemonic = new WalletMnemonic([
|
||||
'device_id' => $wallet->id,
|
||||
'source' => 'imToken',
|
||||
]);
|
||||
$mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
$mnemonic->save();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user