doge 1 день назад
Родитель
Сommit
67d5e28372

+ 11 - 3
app/Console/Commands/SyncPromotions.php

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
 
 use App\Services\PromotionAdminService;
 use App\Services\PromotionService;
+use App\Services\ActivityRewardService;
 use App\Support\SyncCommandLock;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\DB;
@@ -23,10 +24,17 @@ class SyncPromotions extends Command
 
     private function synchronize(PromotionService $service): int
     {
-        if (!Schema::hasTable('promotion_accounts')) return self::SUCCESS;
         $errors = 0;
-        try { $expired = PromotionAdminService::expire(); }
-        catch (\Throwable $e) { $expired = 0; ++$errors; Log::error('promotion_expire_failed', ['exception' => $e]); }
+        $expired = 0;
+        try {
+            if (Schema::hasTable('activity_rewards')) $expired += ActivityRewardService::expire();
+        } catch (\Throwable $e) { ++$errors; Log::error('activity_reward_expire_failed', ['exception' => $e]); }
+        if (!Schema::hasTable('promotion_accounts')) {
+            $this->info('活动同步完成,到期下架:' . $expired . ',失败数:' . $errors);
+            return $errors ? self::FAILURE : self::SUCCESS;
+        }
+        try { $expired += PromotionAdminService::expire(); }
+        catch (\Throwable $e) { ++$errors; Log::error('promotion_expire_failed', ['exception' => $e]); }
         DB::table('promotion_accounts as p')->join('promotion_activities as a', 'a.id', '=', 'p.activity_id')->where('a.type', 9)->where('a.status', 1)
             ->where('a.start_time', '<=', now()->timestamp)->where('a.end_time', '>=', now()->timestamp)->select('p.*')->orderBy('p.id')
             ->chunkById(100, function ($rows) use ($service, &$errors) {

+ 2 - 0
app/Http/Controllers/admin/ActivityReward.php

@@ -35,6 +35,8 @@ class ActivityReward extends Controller
             ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 10);
+            // 同步真实状态后再统计和分页,避免调度延迟时仍显示为上架。
+            ActivityRewardService::expire();
             $where = ActivityRewardService::getWhere(array_intersect_key($params, ['title' => true, 'type' => true]));
             $query = ActivityRewardModel::where($where);
             if (isset($params['status']) && $params['status'] !== '') {

+ 1 - 1
app/Http/Controllers/admin/User.php

@@ -598,7 +598,7 @@ class User extends Controller
     }
 
     /**
-     * 注册域名统计
+     * 域名登录人数统计
      */
     public function registerDomain()
     {

+ 8 - 0
app/Services/ActivityRewardService.php

@@ -20,6 +20,14 @@ class ActivityRewardService extends BaseService
 
     public static string $MODEL = ActivityReward::class;
 
+    /** 使用原始结束时间戳,避免日期展示格式截掉当天的时分秒。 */
+    public static function expire(): int
+    {
+        return static::$MODEL::where('status', ActivityReward::STATUS_UP)
+            ->where('end_time', '<', now()->timestamp)
+            ->update(['status' => ActivityReward::STATUS_DOWN]);
+    }
+
     public static function configurationDefaults(): array
     {
         return [

+ 14 - 9
app/Services/RegisterStatService.php

@@ -3,10 +3,11 @@
 namespace App\Services;
 
 use App\Models\User;
+use App\Models\UserLogin;
 use Illuminate\Support\Facades\DB;
 
 /**
- * 注册走势 / 注册域名统计
+ * 注册走势 / 域名登录人数统计
  */
 class RegisterStatService
 {
@@ -78,15 +79,18 @@ class RegisterStatService
     }
 
     /**
-     * 域名统计(SQL 分页)
+     * 域名成功登录人数统计(同域名按用户去重,SQL 分页)
      *
-     * @return array{total:int,data:list<array{register_url:string,register_count:int}>}
+     * @return array{total:int,data:list<array{login_url:string,login_count:int}>}
      */
     public static function domain(?string $startDate, ?string $endDate, int $page, int $limit): array
     {
-        $domainExpr = "IF(register_domain = '' OR register_domain IS NULL, '未知', register_domain)";
+        $domainExpr = "CASE WHEN login_domain = '' OR login_domain IS NULL THEN '未知' ELSE login_domain END";
 
-        $base = User::query();
+        $base = DB::table('user_login')
+            ->where('status', UserLogin::STATUS_SUCCESS)
+            ->whereNotNull('user_id')
+            ->where('user_id', '<>', '');
         if ($startDate) {
             $base->where('created_at', '>=', $startDate . ' 00:00:00');
         }
@@ -95,7 +99,7 @@ class RegisterStatService
         }
 
         $grouped = (clone $base)
-            ->selectRaw("{$domainExpr} as register_url, COUNT(*) as register_count")
+            ->selectRaw("{$domainExpr} as login_url, COUNT(DISTINCT user_id) as login_count")
             ->groupByRaw($domainExpr);
 
         $total = (int)DB::query()->fromSub(
@@ -105,14 +109,15 @@ class RegisterStatService
 
         $offset = max(0, ($page - 1) * $limit);
         $rows = (clone $grouped)
-            ->orderByDesc(DB::raw('register_count'))
+            ->orderByDesc('login_count')
+            ->orderBy('login_url')
             ->offset($offset)
             ->limit($limit)
             ->get();
 
         $data = $rows->map(fn ($row) => [
-            'register_url' => (string)$row->register_url,
-            'register_count' => (int)$row->register_count,
+            'login_url' => (string)$row->login_url,
+            'login_count' => (int)$row->login_count,
         ])->values()->all();
 
         return ['total' => $total, 'data' => $data];

+ 11 - 11
docs/admin-online-register-api.md

@@ -372,7 +372,7 @@ GET /admin/user/registerTrend?type=all&start_time=2026-08-01&end_time=2026-08-05
 
 ---
 
-### 2.2 注册域名统计
+### 2.2 域名登录人数统计
 
 **URL**
 
@@ -382,15 +382,15 @@ GET /admin/user/registerDomain
 
 **说明**
 
-按注册时写入的 `register_domain`(Referer/Origin 主机)聚合**注册人数**(不是登录人数)。  
-空域名归为「未知」。
+按登录日志 `user_login.login_domain` 聚合**成功登录人数**,同一用户在同一域名、筛选时间范围内重复登录只计 1 人;失败登录及无用户 ID 的记录不计入。用户登录多个域名时,各域名分别计数。
+空域名归为「未知」。接口路径保持不变,返回字段使用 `login_url`、`login_count`,页面列名应显示「登录网址」「登录人数」。
 
 **请求参数(Query)**
 
 | 参数 | 类型 | 必填 | 默认 | 说明 |
 |------|------|------|------|------|
-| `start_time` | string | 否 | - | 注册开始日 `Y-m-d` 00:00:00 |
-| `end_time` | string | 否 | - | 注册结束日 `Y-m-d` 23:59:59 |
+| `start_time` | string | 否 | - | 登录开始日 `Y-m-d` 00:00:00 |
+| `end_time` | string | 否 | - | 登录结束日 `Y-m-d` 23:59:59 |
 | `page` | int | 否 | `1` | 页码 |
 | `limit` | int | 否 | `50` | 每页条数,1~200 |
 
@@ -405,14 +405,14 @@ GET /admin/user/registerDomain?start_time=2026-07-06&end_time=2026-08-04&page=1&
 | 字段 | 类型 | 说明 |
 |------|------|------|
 | `total` | int | 域名分组总数 |
-| `data` | array | 当前页列表,按注册人数降序 |
+| `data` | array | 当前页列表,按登录人数降序,同人数按域名升序 |
 
 **`data[]` 列表项**
 
 | 字段 | 类型 | 前端列名 | 说明 |
 |------|------|----------|------|
-| `register_url` | string | 注册网址 | 域名主机;空为「未知」 |
-| `register_count` | int | 注册人数 | 该域名下注册用户数 |
+| `login_url` | string | 登录网址 | 登录域名主机;空为「未知」 |
+| `login_count` | int | 登录人数 | 该域名下成功登录的去重用户数 |
 
 **返回示例**
 
@@ -422,9 +422,9 @@ GET /admin/user/registerDomain?start_time=2026-07-06&end_time=2026-08-04&page=1&
   "data": {
     "total": 6,
     "data": [
-      { "register_url": "www.example.com", "register_count": 156 },
-      { "register_url": "com.example.app", "register_count": 89 },
-      { "register_url": "未知", "register_count": 12 }
+      { "login_url": "www.example.com", "login_count": 156 },
+      { "login_url": "com.example.app", "login_count": 89 },
+      { "login_url": "未知", "login_count": 12 }
     ]
   }
 }

+ 1 - 0
docs/前端交付/bot-28/后台/README.md

@@ -18,6 +18,7 @@
 | 报表汇总:营业/推广/分享页签及明细 | [报表汇总-前端接入](报表汇总-前端接入.md) |
 | 提现打码量与冻结金额:会员列表打码量、上下分打码量、冻结/解冻、冻结记录(2026-11-04) | [打码量与冻结金额-后台接口](打码量与冻结金额-后台接口.md) |
 | 会员彩金:统一变动类型筛选、变动前/变动金额/变动后、余额宝利息直接进余额(2026-11-05) | [会员彩金-前端接入](会员彩金-前端接入.md) |
+| 活动列表:到期自动下架、状态筛选与列表展示(2026-11-05) | [活动列表-到期自动下架](活动列表-到期自动下架.md) |
 | 会员列表:默认按注册时间倒序,钱包/三方/冻结/余额宝/投注/打码量列服务端排序(2026-11-05) | [会员列表-默认排序与列排序](会员列表-默认排序与列排序.md) |
 | 资金流动:区间总计与每日列表 | [资金流动-前端接入](资金流动-前端接入.md) |
 | 用户报表:玩过的游戏大类、盈亏(充值-提现)与充值/提现/盈亏/余额排序(2026-11-04) | [用户报表-游戏分类、盈亏与排序](用户报表-游戏分类与提充差值.md) |

+ 22 - 0
docs/前端交付/bot-28/后台/活动列表-到期自动下架.md

@@ -0,0 +1,22 @@
+# 活动列表:到期自动下架
+
+接口:`GET /admin/ActivityReward`,使用管理员认证,成功 `code=0`。
+
+超过活动结束时间后,活动自动变为下架状态。后台定时同步每分钟处理;查询活动列表时也会同步已到期活动的状态,再返回总数和分页结果。
+
+## 列表展示与筛选
+
+| 字段 / 参数 | 说明 |
+|---|---|
+| `data.data[].status` | 实际上下架状态:`1` 上架,`0` 下架。到期自动下架后返回 `0` |
+| 请求 `status=1` | 只查上架活动,已过结束时间的活动不再出现在结果中 |
+| 请求 `status=0` | 查询下架活动,包括手动下架和到期自动下架 |
+| 不传 `status` | 同时返回上架和下架活动;到期活动仍保留在列表中 |
+| `data.total` | 按同步后的状态及其他筛选条件统计,与分页列表一致 |
+
+```http
+GET /admin/ActivityReward?page=1&limit=10&status=1
+GET /admin/ActivityReward?page=1&limit=10&status=0
+```
+
+前端直接按返回的 `status` 展示上下架状态,刷新列表即可获取最新状态,无需根据日期文本自行计算。时间判断使用活动保存的完整结束时间;如果结束时间是某天零点,则超过该零点后下架。

+ 70 - 0
tests/Integration/ActivityCatalogTest.php

@@ -8,6 +8,7 @@ use App\Models\ActivityReward;
 use App\Services\ActivityRewardService;
 use App\Services\Sharing\ShareDefaults;
 use App\Support\ActivityCatalog;
+use Carbon\Carbon;
 use Illuminate\Config\Repository;
 use Illuminate\Database\Capsule\Manager;
 use Illuminate\Database\Schema\Blueprint;
@@ -71,6 +72,7 @@ class ActivityCatalogTest extends TestCase
 
     protected function tearDown(): void
     {
+        Carbon::setTestNow();
         DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
     }
 
@@ -119,6 +121,74 @@ class ActivityCatalogTest extends TestCase
         return DB::table('activity_rewards')->insertGetId($data);
     }
 
+    public function test_expiry_updates_only_ended_published_activities_and_is_repeatable(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
+        $expired = [];
+        foreach ([1, 2, 3, 4, 5, 6] as $type) {
+            $id = $this->rawActivity(['type'=>$type, 'lang'=>$type % 2 ? 'zh' : 'en']);
+            DB::table('activity_rewards')->where('id', $id)->update(['end_time'=>now()->timestamp - 1, 'rewarded_count'=>2, 'budget_used'=>'12.34']);
+            $expired[] = $id;
+        }
+        $unchanged = [];
+        foreach ([[1, 0], [1, 3600], [0, -1]] as [$status, $offset]) {
+            $id = $this->rawActivity(['status'=>$status]);
+            DB::table('activity_rewards')->where('id', $id)->update(['end_time'=>now()->timestamp + $offset]);
+            $unchanged[] = $id;
+        }
+        $before = DB::table('activity_rewards')->get()->keyBy('id');
+        $this->assertSame(6, ActivityRewardService::expire());
+        foreach ($expired as $id) {
+            $expected = (array)$before[$id];
+            $expected['status'] = 0;
+            $expected['updated_at'] = now()->format('Y-m-d H:i:s');
+            $this->assertSame($expected, (array)DB::table('activity_rewards')->where('id', $id)->first());
+        }
+        foreach ($unchanged as $id) $this->assertSame((array)$before[$id], (array)DB::table('activity_rewards')->where('id', $id)->first());
+        $this->assertSame(0, ActivityRewardService::expire());
+        Carbon::setTestNow(now()->addSecond());
+        $this->assertSame(1, ActivityRewardService::expire()); // The end timestamp itself is still inclusive.
+    }
+
+    public function test_admin_list_expires_before_status_filter_count_and_pagination(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
+        $expired = [];
+        for ($i = 0; $i < 3; ++$i) {
+            $id = $this->rawActivity(['sort'=>100]);
+            DB::table('activity_rewards')->where('id', $id)->update(['end_time'=>now()->timestamp - 1]);
+            $expired[] = $id;
+        }
+        $active = [$this->rawActivity(), $this->rawActivity()];
+        $manual = $this->rawActivity(['status'=>0]);
+        $page = $this->listing(['status'=>0, 'page'=>1, 'limit'=>2]);
+        $this->assertSame(4, $page['total']);
+        $this->assertSame([$expired[2], $expired[1]], array_column($page['data'], 'id'));
+        $second = $this->listing(['status'=>0, 'page'=>2, 'limit'=>2]);
+        $this->assertSame(4, $second['total']);
+        $this->assertSame([$expired[0], $manual], array_column($second['data'], 'id'));
+        $up = $this->listing(['status'=>1]);
+        $this->assertSame(2, $up['total']);
+        $this->assertSame(array_reverse($active), array_column($up['data'], 'id'));
+        $this->assertSame(6, $this->listing()['total']);
+        $this->assertSame(0, (int)DB::table('activity_rewards')->where('id', $expired[0])->value('status'));
+    }
+
+    public function test_scheduled_sync_expires_the_legacy_catalog_without_promotion_tables(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-11-05 12:00:00', 'Asia/Shanghai'));
+        $id = $this->rawActivity();
+        DB::table('activity_rewards')->where('id', $id)->update(['end_time'=>now()->timestamp - 1]);
+        $command = new \App\Console\Commands\SyncPromotions();
+        $command->setLaravel($this->app);
+        $tester = new \Symfony\Component\Console\Tester\CommandTester($command);
+        $this->assertSame(0, $tester->execute([]));
+        $this->assertSame(0, (int)DB::table('activity_rewards')->where('id', $id)->value('status'));
+        $this->assertStringContainsString('到期下架:1', $tester->getDisplay());
+        $this->assertSame(0, $tester->execute([]));
+        $this->assertStringContainsString('到期下架:0', $tester->getDisplay());
+    }
+
     public function test_dual_routes_round_trip_through_admin_save_and_listing_with_cover_and_detail(): void
     {
         foreach (['cover', 'detail'] as $position) {

+ 14 - 8
tests/Integration/ActivityRewardEligibilityTest.php

@@ -250,19 +250,20 @@ class ActivityRewardEligibilityTest extends TestCase
         $this->assertSame(7, $this->adminList()['total']);
     }
 
-    public function test_admin_activity_empty_filters_and_zero_status_use_saved_status_without_time_hiding(): void
+    public function test_admin_activity_empty_filters_keep_ended_rows_after_synchronizing_status(): void
     {
         $expired = $this->legacyActivity(['title' => '已结束但仍上架', 'status' => 1, 'end_time' => '2001-01-01 00:00:00']);
         $future = $this->legacyActivity(['title' => '未开始但已上架', 'status' => 1, 'start_time' => '2090-01-01 00:00:00']);
         $down = $this->legacyActivity(['title' => '已手动下架', 'status' => 0]);
         $up = $this->adminList(['status' => 1]);
-        $this->assertSame(2, $up['total']);
-        $this->assertSame([$future->id, $expired->id], array_column($up['data'], 'id'));
-        $this->assertSame([1, 1], array_map('intval', array_column($up['data'], 'status')));
+        $this->assertSame(1, $up['total']);
+        $this->assertSame([$future->id], array_column($up['data'], 'id'));
+        $this->assertSame([1], array_map('intval', array_column($up['data'], 'status')));
+        $this->assertSame(0, (int)$expired->fresh()->status);
         foreach ([0, '0'] as $status) {
             $result = $this->adminList(['status' => $status]);
-            $this->assertSame(1, $result['total']);
-            $this->assertSame([$down->id], array_column($result['data'], 'id'));
+            $this->assertSame(2, $result['total']);
+            $this->assertSame([$down->id, $expired->id], array_column($result['data'], 'id'));
         }
         foreach ([[], ['platform' => '', 'status' => '', 'lang' => ''], ['platform' => null, 'status' => null, 'lang' => null]] as $filters) {
             $this->assertSame(3, $this->adminList($filters)['total']);
@@ -318,8 +319,13 @@ class ActivityRewardEligibilityTest extends TestCase
             $this->assertStringContainsString('json_contains', $sql);
             $this->assertStringContainsString('json_length', $sql);
             $this->assertStringNotContainsString('json_each', $sql);
-            $this->assertContains('"H5"', $queries[0]['bindings']);
-            $this->assertContains('en', $queries[0]['bindings']);
+            // 到期状态 UPDATE 在前,平台和语言条件应检查统计及列表的 SELECT。
+            $selects = array_filter($queries, fn ($query) => str_starts_with(strtolower($query['query']), 'select'));
+            $this->assertCount(2, $selects);
+            foreach ($selects as $query) {
+                $this->assertContains('"H5"', $query['bindings']);
+                $this->assertContains('en', $query['bindings']);
+            }
         } finally {
             DB::disconnect('mysql_filter_preview');
             DB::setDefaultConnection($original);

+ 32 - 0
tests/Integration/PromotionActivitiesTest.php

@@ -586,6 +586,38 @@ class PromotionActivitiesTest extends TestCase
         $this->assertSame(0, (int) $admin->save(['status' => 0] + $input)['status']);
     }
 
+    public function test_sync_command_expires_both_activity_catalogs_without_repeating_changes(): void
+    {
+        $promotion = $this->activity(8);
+        DB::table('promotion_activities')->where('id', $promotion)->update(['end_time'=>now()->timestamp - 1]);
+        $legacy = DB::table('activity_rewards')->insertGetId(['type'=>3, 'status'=>1, 'end_time'=>now()->timestamp - 1]);
+        $command = new \App\Console\Commands\SyncPromotions();
+        $command->setLaravel($this->app);
+        $tester = new \Symfony\Component\Console\Tester\CommandTester($command);
+        $this->assertSame(0, $tester->execute([]));
+        $this->assertSame(0, (int)DB::table('activity_rewards')->where('id', $legacy)->value('status'));
+        $this->assertSame(0, (int)DB::table('promotion_activities')->where('id', $promotion)->value('status'));
+        $this->assertStringContainsString('到期下架:2', $tester->getDisplay());
+        $this->assertSame(0, $tester->execute([]));
+        $this->assertStringContainsString('到期下架:0', $tester->getDisplay());
+    }
+
+    public function test_catalog_expiry_failure_does_not_skip_independent_promotion_expiry(): void
+    {
+        $promotion = $this->activity(8);
+        DB::table('promotion_activities')->where('id', $promotion)->update(['end_time'=>now()->timestamp - 1]);
+        $legacy = DB::table('activity_rewards')->insertGetId(['type'=>3, 'status'=>1, 'end_time'=>now()->timestamp - 1]);
+        DB::statement("CREATE TRIGGER reject_catalog_expiry BEFORE UPDATE ON bot_activity_rewards BEGIN SELECT RAISE(ABORT, 'simulated update failure'); END");
+        $command = new \App\Console\Commands\SyncPromotions();
+        $command->setLaravel($this->app);
+        $tester = new \Symfony\Component\Console\Tester\CommandTester($command);
+        $this->assertSame(1, $tester->execute([]));
+        $this->assertSame(1, (int)DB::table('activity_rewards')->where('id', $legacy)->value('status'));
+        $this->assertSame(0, (int)DB::table('promotion_activities')->where('id', $promotion)->value('status'));
+        $this->assertStringContainsString('到期下架:1', $tester->getDisplay());
+        $this->assertStringContainsString('失败数:1', $tester->getDisplay());
+    }
+
     public function test_fixed_activities_are_initialized_disabled_without_publishing_or_creating_menus(): void
     {
         $this->assertSame(3, DB::table('promotion_activities')->count());

+ 71 - 0
tests/Integration/RegisterDomainStatTest.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Services\RegisterStatService;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+
+class RegisterDomainStatTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Facade::clearResolvedInstances();
+        $app = new Application(dirname(__DIR__, 2));
+        Facade::setFacadeApplication($app);
+        $db = new Manager($app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $app->instance('db', $db->getDatabaseManager());
+        $db->schema()->create('user_login', function (Blueprint $t) {
+            $t->id();
+            $t->string('user_id')->nullable();
+            $t->string('login_domain')->nullable();
+            $t->integer('status');
+            $t->dateTime('created_at');
+        });
+        foreach ([
+            ['1', 'a.test', 1, '2026-11-05 00:00:00'],
+            ['1', 'a.test', 1, '2026-11-05 12:00:00'],
+            ['2', 'a.test', 1, '2026-11-05 23:59:59'],
+            ['1', 'b.test', 1, '2026-11-05 12:00:00'],
+            ['3', 'b.test', 0, '2026-11-05 12:00:00'],
+            ['', 'failed.test', 1, '2026-11-05 12:00:00'],
+            [null, 'failed.test', 1, '2026-11-05 12:00:00'],
+            ['4', '', 1, '2026-11-05 12:00:00'],
+            ['4', null, 1, '2026-11-05 12:00:00'],
+            ['5', 'old.test', 1, '2026-11-04 23:59:59'],
+            ['6', 'next.test', 1, '2026-11-06 00:00:00'],
+        ] as [$user, $domain, $status, $time]) {
+            DB::table('user_login')->insert([
+                'user_id' => $user, 'login_domain' => $domain,
+                'status' => $status, 'created_at' => $time,
+            ]);
+        }
+    }
+
+    public function test_counts_distinct_successful_users_by_login_domain_and_time(): void
+    {
+        $this->assertSame(['total' => 3, 'data' => [
+            ['login_url' => 'a.test', 'login_count' => 2],
+            ['login_url' => 'b.test', 'login_count' => 1],
+            ['login_url' => '未知', 'login_count' => 1],
+        ]], RegisterStatService::domain('2026-11-05', '2026-11-05', 1, 50));
+    }
+
+    public function test_pagination_counts_groups_and_supports_optional_dates(): void
+    {
+        $this->assertSame(['total' => 3, 'data' => [
+            ['login_url' => '未知', 'login_count' => 1],
+        ]], RegisterStatService::domain('2026-11-05', '2026-11-05', 2, 2));
+        $this->assertSame(5, RegisterStatService::domain(null, null, 1, 50)['total']);
+        $this->assertSame(4, RegisterStatService::domain('2026-11-05', null, 1, 50)['total']);
+        $this->assertSame(4, RegisterStatService::domain(null, '2026-11-05', 1, 50)['total']);
+        $this->assertSame(['total' => 0, 'data' => []], RegisterStatService::domain('2026-11-07', null, 1, 50));
+    }
+}