doge 21 часов назад
Родитель
Сommit
0c31ebb145

+ 3 - 3
app/Services/Agent/AgentPeriodReportService.php

@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\DB;
 /**
  * 代理周期报表:按日 / 周 / 月汇总,数据来自会员每日报表 member_daily_reports(每 10 分钟刷新当天,04:30 重算前 3 天)。
  * - 合营代理:自己名下会员(scope=own),带 agent_share 合营分成;
- * - 入股代理(股东):全平台会员,不含试玩账号 users.from=2(scope=platform)。
+ * - 入股代理(股东):全平台会员(真实会员 + 后台模拟会员),不含游客试玩账号(scope=platform)。
  * 只返回汇总数,不返回会员明细。周为周一至周日;首尾周期按查询日期截断。
  */
 class AgentPeriodReportService
@@ -133,9 +133,9 @@ class AgentPeriodReportService
     private function members(Agent $agent)
     {
         $query = DB::table('users');
-        // 入股股东看全平台:排除试玩账号和测试会员;代理看自己名下的会员(测试代理也照常显示自己的)
+        // 入股股东看全平台:真实会员 + 后台模拟会员,排除试玩账号和测试会员;代理看自己名下的会员(测试代理也照常显示自己的)
         return $agent->isEquity()
-            ? \App\Support\StatsScope::realUsers($query, 'users')
+            ? \App\Support\StatsScope::platformUsers($query, 'users')
             : $query->where('agent_id', $agent->id);
     }
 

+ 4 - 4
app/Services/Agent/AgentReportService.php

@@ -22,14 +22,14 @@ class AgentReportService
 
     /**
      * 代理首页汇总(GET /agent/dashboard)。合营代理:自己名下的会员;入股代理(股东):全平台会员
-     * (不含试玩账号 users.from=2,口径同总后台首页),没有合营分成,commission_amount 为 0。
+     * (真实会员 + 后台模拟会员,不含游客试玩账号;见 StatsScope::platformUsers),没有合营分成,commission_amount 为 0。
      * scope 告诉前端统计范围:own / platform。代理报表(reportRows)仍按各自名下会员统计。
      */
     public function dashboard(Agent $agent, string $startDate, string $endDate): array
     {
         if ($agent->isEquity()) {
-            // 全平台:排除试玩账号和测试会员(测试代理名下)
-            $members = StatsScope::realUsers(User::query(), 'users');
+            // 全平台:真实会员 + 后台模拟会员;排除试玩账号和测试会员(测试代理名下)
+            $members = StatsScope::platformUsers(User::query(), 'users');
             return $this->aggregate($members, 0, [], $startDate, $endDate, platform: true) + ['scope' => 'platform'];
         }
         return $this->summaryForTree($agent, $startDate, $endDate) + ['scope' => 'own'];
@@ -38,7 +38,7 @@ class AgentReportService
     public function dailyDeposits(Agent $agent, Carbon $start, Carbon $end): array
     {
         $members = $agent->isEquity()
-            ? StatsScope::realUsers(User::query(), 'users')
+            ? StatsScope::platformUsers(User::query(), 'users')
             : User::query()->where('agent_id', $agent->id);
         $deposits = $this->depositSources($members->select('member_id'), $start, $end, Schema::hasTable('payment_orders'));
         $orders = array_shift($deposits)->select('member_id', 'created_at')

+ 2 - 0
app/Services/Simulation/SimulationSettings.php

@@ -21,6 +21,8 @@ final class SimulationSettings
         'simulation_initial_balance' => ['0', 0, 1000000],
         'simulation_wager_bypass' => ['1', 0, 1],
         'simulation_default_domain' => ['sim.local', null, null],
+        // 入股代理(股东)的独立后台是否把模拟会员的数据算进全平台统计
+        'simulation_show_to_equity_agents' => ['1', 0, 1],
         // 一键启动的默认任务:注册 → 登录 → 充值 → 下注 → 结算 → 提现 → 退出
         'simulation_task_members' => ['5', 1, 200],
         'simulation_task_recharge' => ['1000', 1, 1000000],

+ 8 - 2
app/Services/TelegramChannelService.php

@@ -36,16 +36,22 @@ class TelegramChannelService
 
     public function save(#[\SensitiveParameter] array $input, bool $legacy = false): array
     {
+        if (is_string($input['name'] ?? null)) {
+            $input['name'] = trim($input['name']);
+            if ($input['name'] === '') {
+                $input['name'] = null;
+            }
+        }
         $hasMessage = array_intersect(['type', 'text', 'image', 'video', 'button'], array_keys($input)) !== [];
         $fields = Validator::make($input, [
             'id' => $legacy ? 'prohibited' : 'sometimes|required|integer|min:1',
-            'name' => 'sometimes|required|string|max:255',
+            'name' => 'nullable|string|max:255',
             'chatId' => 'required|string|max:64',
             'bot_username' => ['nullable', 'string', 'max:64', 'regex:/^@?[a-z][a-z0-9_]*$/iD'],
             'bot_token' => ['nullable', 'string', 'max:255', 'regex:/^[1-9][0-9]*:[a-z0-9_-]+$/iD'],
             'enabled' => 'sometimes|required|boolean',
             'type' => [($legacy || $hasMessage) ? 'required' : 'sometimes', 'string', 'in:text,image,video'],
-        ])->validate();
+        ], [], ['name' => '频道名称'])->validate();
 
         $target = self::normalizeTarget($fields['chatId']);
         if ($target === null) {

+ 13 - 0
app/Support/StatsScope.php

@@ -66,6 +66,19 @@ final class StatsScope
         return self::excludeTestUsers($query, $alias);
     }
 
+    /**
+     * 入股代理(股东)后台看的“全平台会员”:真实会员 + 后台模拟会员。游客和测试会员仍不算。
+     * 模拟设置里的 simulation_show_to_equity_agents 关掉后只算真实会员。总后台的统计不用它。
+     */
+    public static function platformUsers($query, string $alias = 'users')
+    {
+        $include = MemberAudience::enabled() && !MemberAudience::isSimulated()
+            && (string) (DB::table('config')->where('field', 'simulation_show_to_equity_agents')->value('val') ?? '1') === '1';
+        if (!$include) return self::realUsers($query, $alias);
+        return $query->where(fn ($q) => $q->where($alias . '.user_type', MemberAudience::TYPE_SIMULATED)
+            ->orWhere(fn ($real) => self::realUsers($real, $alias)));
+    }
+
     /** users 查询:只留游客(from = 2 且不是模拟会员)。 */
     public static function guestUsers($query, string $alias = 'users')
     {

+ 1 - 1
docs/前端交付/bot-28/后台/Telegram频道管理-前端接入.md

@@ -21,7 +21,7 @@
 - `chatId` 必填,可用 `@频道用户名` 或 `"-100…"`。新增或变更目标时需要机器人能读取该频道。
 - 绑定或更换频道机器人时填写 `bot_token`(BotFather 发放的 Token)。`bot_username` 可选,填机器人的 `@用户名`,用于核对 Token 对应账号。保存后返回 Telegram 确认的 `bot_username` 和显示名 `bot_name`。
 - Token 输入框使用密码类型,详情回读时留空。编辑时省略 `bot_token` 或传 `null` 会保留已绑定的 Token。接口只返回 `has_bot_token` 表示是否已绑定专用 Token。未绑定时沿用系统机器人。
-- `name` 可省略,新增时默认用频道名称。新增默认启用。只改名称或启停时提交 `id`、当前 `chatId` 和要修改的字段,原消息会保留。
+- `name` 是后台显示的频道名称,可留空或省略。新增时默认使用 Telegram 频道名称,编辑时留空会保留原名称。新增默认启用。只改名称或启停时提交 `id`、当前 `chatId` 和要修改的字段,原消息会保留。
 - 消息 `type` 支持 `text`、`image`、`video`。纯文字需要 `text`,图片需要 `image`,视频需要 `video`。图片和视频的说明也用 `text`。`button` 是按行排列的二维数组,每个按钮填写 `text` 和 `url`。编辑消息时提交完整消息字段,清空按钮传 `[]`。
 
 新增图片菜单示例。编辑同一频道时加上 `"id": 12`。

+ 6 - 4
tests/Integration/AgentDashboardTest.php

@@ -141,7 +141,8 @@ class AgentDashboardTest extends TestCase
         $fundFields = array_flip(['manual_credit', 'manual_debit', 'bonus_amount', 'rebate_amount']);
         $platform = $reports->dashboard($equity, $from, $to);
         $own = $reports->dashboard($joint, $from, $to);
-        $this->assertSame([3, '70.0000', '43.0000', '10.0000'], array_values(array_intersect_key($platform, $betFields)));
+        // 股东的全平台统计包含后台模拟会员(注单 999 / 999 / 999),不含游客和测试会员
+        $this->assertSame([4, '1069.0000', '1042.0000', '1009.0000'], array_values(array_intersect_key($platform, $betFields)));
         $this->assertSame([2, '30.0000', '8.0000', '3.0000'], array_values(array_intersect_key($own, $betFields)));
         $this->assertSame(['42.0000', '3.0000', '21.0000', '10.0000'], array_values(array_intersect_key($platform, $fundFields)));
         $this->assertSame(array_intersect_key($platform, $fundFields), array_intersect_key($own, $fundFields));
@@ -255,9 +256,10 @@ class AgentDashboardTest extends TestCase
 
         $charts = $this->charts($holder, ['start_date' => '2026-09-07']);
         $this->assertSame('platform', $charts['scope']);
-        $this->assertSame([1], array_column($charts['registrations'], 'new_members'));
-        $this->assertSame([1], array_column($charts['daily_deposits'], 'deposit_members'));
-        $this->assertSame(['100.0000'], array_column($charts['daily_deposits'], 'deposit_amount'));
+        // 真实会员 + 后台模拟会员;游客和测试会员不算
+        $this->assertSame([2], array_column($charts['registrations'], 'new_members'));
+        $this->assertSame([2], array_column($charts['daily_deposits'], 'deposit_members'));
+        $this->assertSame(['200.0000'], array_column($charts['daily_deposits'], 'deposit_amount'));
         $this->assertSame([
             'own_percent' => '12.3456', 'other_percent' => '7.6544', 'company_percent' => '60.5000', 'remaining_percent' => '19.5000',
         ], $charts['equity']);

+ 65 - 3
tests/Integration/SimulationTest.php

@@ -208,7 +208,7 @@ class SimulationTest extends TestCase
         [$member] = array_column(SimulationMemberService::create(1, ['initial_balance' => 1000]), 'member_id');
         $ledgerCount = DB::table('balance_logs')->count();
         LotteryOdds::setOdds('canada28', null, ['id' => 1, 'odds' => '0', 'mininum' => 1, 'maxinum' => 100]);
-        $this->assertSame([], SimulationBetService::options()['lottery']['rules']->all());
+        $this->assertSame([], SimulationBetService::options('canada28')['lottery']['rules']);
         try {
             SimulationBetService::lottery($member, '大', 10);
             $this->fail('The active default template has disabled this play');
@@ -222,8 +222,8 @@ class SimulationTest extends TestCase
         DB::table('config')->where('field', 'pc28_switch')->update(['val' => '1']);
         DB::table('pc_issues')->insert(['issue_no' => '2200001', 'start_time' => now(), 'end_time' => now()->addMinutes(5), 'status' => 1]);
         LotteryOdds::setOdds('jisu28', null, ['id' => 1, 'odds' => '3.25', 'mininum' => 10, 'maxinum' => 20]);
-        $rules = SimulationBetService::options()['lottery']['rules'];
-        $this->assertSame(['keywords' => '大', 'odds' => '3.25', 'mininum' => 10, 'maxinum' => 20], $rules->first());
+        $rules = SimulationBetService::options('jisu28')['lottery']['rules'];
+        $this->assertSame(['id' => null, 'keywords' => '大', 'odds' => '3.25', 'mininum' => 10, 'maxinum' => 20], $rules[0]);
         try {
             SimulationBetService::lottery($member, '大', 21);
             $this->fail('The selected template caps the stake at 20');
@@ -431,6 +431,68 @@ class SimulationTest extends TestCase
         $this->assertSame($before, $this->realView());
     }
 
+    /** 非 PC28 彩种:注单格式与前台一致(玩法编号、彩种 type、期号规则、流水类型),封盘时不能下。 */
+    public function test_other_lotteries_bet_on_real_issues_in_the_front_end_order_format(): void
+    {
+        foreach (['jisu_gameplay', 'lhc_number'] as $table) Schema::table($table, function (Blueprint $t) use ($table) {
+            $t->string('gameplay')->nullable(); $t->string('number')->nullable();
+            if ($table === 'lhc_number') $t->string('game')->nullable();
+        });
+        Schema::table('lhc_order', function (Blueprint $t) { $t->string('game')->nullable(); $t->string('gameplay')->nullable(); });
+        Schema::table('lhc_order_item', fn (Blueprint $t) => $t->integer('number_id')->nullable());
+        foreach (['jisu_lottery', 'lhc_lottery'] as $table) Schema::create($table, function (Blueprint $t) {
+            $t->id(); $t->string('issue'); $t->integer('type'); $t->integer('status')->default(1); $t->integer('open_time')->nullable();
+            $t->integer('next_open_time')->nullable(); $t->string('next_issue')->nullable();
+        });
+        $racing = DB::table('jisu_gameplay')->insertGetId(['type' => 1, 'gameplay' => '冠军', 'number' => '大', 'odds' => '1.98', 'mininum' => 1, 'maxinum' => 1000]);
+        $combo = DB::table('jisu_gameplay')->insertGetId(['type' => 1, 'gameplay' => '连码', 'number' => '1,2', 'odds' => '5', 'mininum' => 1, 'maxinum' => 1000]);
+        $zodiac = DB::table('lhc_number')->insertGetId(['game' => '特码', 'gameplay' => '特码A', 'number' => '08', 'odds' => '47', 'mininum' => 1, 'maxinum' => 1000]);
+        foreach ([['jisu:1', $racing, '1.98'], ['jisu:1', $combo, '5'], ['lhc:2', $zodiac, '47'], ['lhc:1', $zodiac, '47']] as [$key, $id, $odds]) {
+            LotteryOdds::setOdds($key, null, ['id' => $id, 'odds' => $odds, 'mininum' => 1, 'maxinum' => 1000]);
+        }
+        DB::table('jisu_lottery')->insert(['issue' => '20261113001', 'type' => 1, 'status' => 1, 'open_time' => time() + 120]);
+        DB::table('lhc_lottery')->insert([['issue' => '2026130', 'type' => 2, 'status' => 1, 'next_open_time' => time() + 3600, 'next_issue' => '2026131'],
+            ['issue' => '2026317', 'type' => 1, 'status' => 1, 'next_open_time' => null, 'next_issue' => null]]);
+        $before = $this->realView();
+        [$member] = array_column(SimulationMemberService::create(1, ['initial_balance' => 1000]), 'member_id');
+
+        $games = array_column(SimulationBetService::options('jisu:1')['lottery']['games'], null, 'key');
+        $this->assertSame(['canada28', 'lhc:1', 'lhc:2', 'lhc:3', 'lhc:4', 'jisu:1', 'jisu:2', 'jisu:3', 'jisu:4', 'jisu:5', 'jisu:6', 'jisu:7'], array_keys($games));
+        $this->assertSame([true, '20261113001', 7, false], [$games['jisu:1']['open'], $games['jisu:1']['issue'], $games['jisu:1']['type'], $games['jisu:2']['open']]);
+        $this->assertSame('2026131', $games['lhc:2']['issue'], 'Hong Kong bets on the next issue');
+        $rules = SimulationBetService::options('jisu:1')['lottery']['rules'];
+        $this->assertSame([[$racing, '冠军 大']], array_map(fn ($rule) => [$rule['id'], $rule['keywords']], $rules), 'plays that need several numbers are not offered');
+
+        $order = SimulationBetService::lottery($member, '', 10, 'jisu:1', $racing);
+        $this->assertSame([7, '20261113001', '冠军', 'simulation', '1.98'], [(int) $order->type, $order->issue, $order->gameplay, $order->bet_source, (string) $order->odds]);
+        $this->assertSame([$racing, '大'], [(int) DB::table('lhc_order_item')->where('ordernum', $order->ordernum)->value('number_id'), DB::table('lhc_order_item')->where('ordernum', $order->ordernum)->value('number')]);
+        $hk = SimulationBetService::lottery($member, '', 20, 'lhc:2', $zodiac);
+        $this->assertSame([2, '2026131', '特码', '特码A'], [(int) $hk->type, $hk->issue, $hk->game, $hk->gameplay]);
+        if (date('H:i') < '21:30' || date('H:i') > '21:40') {
+            $this->assertSame('2026318', SimulationBetService::lottery($member, '', 5, 'lhc:1', $zodiac)->issue, 'New Macau bets on the issue after the latest draw');
+            $this->assertSame(-5.0, (float) DB::table('balance_logs')->where('member_id', $member)->where('change_type', '新澳门六合彩投注')->sum('amount'));
+        }
+        $this->assertSame([-10.0, -20.0], [(float) DB::table('balance_logs')->where('member_id', $member)->where('change_type', '极速赛车投注')->sum('amount'),
+            (float) DB::table('balance_logs')->where('member_id', $member)->where('change_type', '香港六合彩投注')->sum('amount')]);
+
+        // 自动任务的彩票下注在所有开盘的彩种里随机选
+        $types = [];
+        for ($i = 0; $i < 40; $i++) $types[(int) SimulationBetService::lotteryRandom($member, 1)->type] = true;
+        $this->assertArrayHasKey(7, $types);
+        $this->assertArrayHasKey(2, $types);
+        $this->assertArrayHasKey(5, $types);
+
+        // 封盘:开奖前 15 秒内、状态为封盘都不能下
+        DB::table('jisu_lottery')->update(['open_time' => time() + 10]);
+        try { SimulationBetService::lottery($member, '', 10, 'jisu:1', $racing); $this->fail('closed before the draw'); }
+        catch (\Exception $e) { $this->assertStringContainsString('封盘', $e->getMessage()); }
+        try { SimulationBetService::lottery($member, '', 10, 'jisu:2', $racing); $this->fail('no issue'); }
+        catch (\Exception $e) { $this->assertStringContainsString('封盘', $e->getMessage()); }
+        try { SimulationBetService::lottery($member, '', 10, 'jisu:1', $combo + 99); $this->fail('unknown play'); }
+        catch (\Exception $e) { $this->assertStringContainsString('封盘', $e->getMessage()); }
+        $this->assertSame($before, $this->realView());
+    }
+
     public function test_guards_refuse_real_members_and_a_closed_switch(): void
     {
         foreach ([fn () => SimulationFundService::recharge('r1', 10), fn () => SimulationFundService::adjust('g1', 10, 'credit', 'x'),

+ 18 - 0
tests/Integration/TelegramChannelTest.php

@@ -281,6 +281,24 @@ class TelegramChannelTest extends TestCase
         $this->assertCount(1, $this->service->list());
     }
 
+    public function test_blank_channel_name_defaults_on_create_and_is_preserved_on_edit(): void
+    {
+        $this->migrate();
+        $this->telegramChannel(-1001000000001, 'first_channel');
+        $request = $this->request(['chatId' => '@first_channel', 'name' => '   ']);
+        $controller = new TelegramChannel();
+        $saved = $controller->save($request, $this->service)->getData(true);
+        $this->assertSame(0, $saved['code']);
+        $this->assertSame('频道', $saved['data']['name']);
+
+        $edit = ['id' => $saved['data']['id'], 'chatId' => '@first_channel'];
+        $request = $this->request($edit + ['name' => '  后台自定义名称  ']);
+        $this->assertSame('后台自定义名称', $controller->save($request, $this->service)->getData(true)['data']['name']);
+        $request = $this->request($edit + ['name' => null]);
+        $this->assertSame('后台自定义名称', $controller->save($request, $this->service)->getData(true)['data']['name']);
+        $this->assertCount(1, $this->requests);
+    }
+
     public function test_channel_tokens_are_encrypted_and_the_correct_bot_publishes_each_channel(): void
     {
         $this->migrate();

+ 29 - 0
tests/Integration/TestAgentStatsTest.php

@@ -92,6 +92,35 @@ class TestAgentStatsTest extends TestCase
         $this->assertSame(30.0, (float) StatsScope::excludeTestAgents(DB::table('agent_withdrawals'))->sum('amount'), 'test agent own money is left out');
     }
 
+    /** 入股股东的独立后台把后台模拟会员算进全平台;总后台、合营代理、游客都不受影响;开关关掉后恢复只算真实会员。 */
+    public function test_shareholder_platform_stats_include_simulated_members(): void
+    {
+        $this->seed();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_120000_add_user_type_to_users.php')->up();
+        foreach (['sim1' => 2, 'guest1' => 1] as $memberId => $type) {
+            $id = DB::table('users')->insertGetId(['member_id' => $memberId, 'username' => $memberId, 'agent_id' => null, 'from' => 2, 'user_type' => $type, 'created_at' => '2026-09-07 08:00:00']);
+            DB::table('recharges')->insert(['member_id' => $memberId, 'status' => 1, 'amount' => 100, 'created_at' => '2026-09-07 09:00:00']);
+            DB::table('member_daily_reports')->insert(['report_date' => '2026-09-07', 'user_id' => $id, 'member_id' => $memberId, 'agent_id' => null,
+                'deposit_amount' => 100, 'bet_count' => 1, 'bet_amount' => 10, 'categories' => '{}', 'created_at' => now(), 'updated_at' => now()]);
+        }
+        $reports = new AgentReportService();
+        $day = ['period' => 'day', 'start_date' => '2026-09-07', 'end_date' => '2026-09-07'];
+        $shareholder = fn () => [$reports->dashboard($this->shareholder, '2026-09-07', '2026-09-07')['member_count'],
+            $reports->dashboard($this->shareholder, '2026-09-07', '2026-09-07')['deposit_amount'],
+            (new AgentPeriodReportService())->report($this->shareholder, $day)['totals']['deposit_amount'],
+            (new AgentPeriodReportService())->report($this->shareholder, $day)['totals']['new_members']];
+        $this->assertSame([3, '300.0000', '300.0000', 3], $shareholder(), 'r1 + free + the simulated member; not the guest or the test member');
+
+        // 总后台和合营代理看到的不变
+        $daily = (new MemberDailyReportService())->paginate(['start_date' => '2026-09-07', 'end_date' => '2026-09-07']);
+        $this->assertSame(['free', 'r1'], collect($daily['list'])->pluck('member_id')->sort()->values()->all());
+        $this->assertSame([1, '100.0000'], [$reports->dashboard($this->real, '2026-09-07', '2026-09-07')['member_count'], $reports->dashboard($this->real, '2026-09-07', '2026-09-07')['deposit_amount']]);
+        $this->assertSame(['free', 'r1'], StatsScope::realUsers(DB::table('users'))->orderBy('member_id')->pluck('member_id')->all());
+
+        DB::table('config')->insert(['field' => 'simulation_show_to_equity_agents', 'val' => '0']);
+        $this->assertSame([2, '200.0000', '200.0000', 2], $shareholder(), 'switched off: real members only');
+    }
+
     public function test_switching_the_flag_moves_history_in_and_out(): void
     {
         $this->seed();