doge 14 часов назад
Родитель
Сommit
553ea7313e

+ 56 - 77
app/Http/Controllers/admin/Config.php

@@ -10,10 +10,9 @@ use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\ValidationException;
 use Exception;
-use Telegram\Bot\Api;
-use Telegram\Bot\Exceptions\TelegramSDKException;
-use Telegram\Bot\FileUpload\InputFile;
 use App\Services\ConfigService;
+use App\Services\MenuService;
+use App\Services\TelegramChannelService;
 use App\Services\UsdtRechargeConfigService;
 
 class Config extends Controller
@@ -43,6 +42,10 @@ class Config extends Controller
                 'remark' => ['nullable'],
             ]);
             $val = $params['val'] ?? '';
+            if (strcasecmp($params['field'], TelegramChannelService::LEGACY_FIELD) === 0) {
+                $this->saveLegacyChannelValue($val);
+                return $this->success();
+            }
             if ($params['field'] === 'sport_list') $val = self::normalizeSportList($val);
             $update_data=[
                 'val' => $val,
@@ -217,6 +220,11 @@ class Config extends Controller
 
 
             $params = request()->validate($validator);
+            $field = isset($params['id']) ? ConfigModel::whereKey($params['id'])->value('field') : $params['field'];
+            if (strcasecmp($field ?? '', TelegramChannelService::LEGACY_FIELD) === 0) {
+                $this->saveLegacyChannelValue($params['val']);
+                return $this->success();
+            }
             if (!isset($params['id'])) $params['group_id'] = 3;
 
 
@@ -246,7 +254,7 @@ class Config extends Controller
         if (!$info) {
             return $this->error(HttpStatus::CUSTOM_ERROR, '配置不存在');
         }
-        if ($info->group_id !== 3) {
+        if ($info->field === TelegramChannelService::LEGACY_FIELD || $info->group_id !== 3) {
             return $this->error(HttpStatus::CUSTOM_ERROR, '禁止删除');
         }
         $info->delete();
@@ -312,12 +320,15 @@ class Config extends Controller
                 'field' => ['required', 'string', 'min:1'],
             ]);
             $field = request()->input('field');
+            if ($field === TelegramChannelService::LEGACY_FIELD) {
+                $channel = app(TelegramChannelService::class)->legacyMessage();
+                return $this->success($channel === null ? [] : [$field => $channel]);
+            }
             $config = ConfigModel::where('field', $field)->first();
             $res = [];
             if ($config) {
                 $val = $config->val;
                 switch ($field) {
-                    case "channel_message":
                     case "base_score":
                         $val = json_decode($config->val);
                         break;
@@ -347,12 +358,11 @@ class Config extends Controller
     public
     function getAll()
     {
-        $list = ConfigModel::where('id', '>', 0)->get();
+        $list = ConfigModel::where('id', '>', 0)->where('field', '!=', TelegramChannelService::LEGACY_FIELD)->get();
         $arr = [];
         foreach ($list as $item) {
             $val = $item['val'];
             switch ($item['field']) {
-                case "channel_message":
                 case "base_score":
                     $val = json_decode($item['val'], true);
                     break;
@@ -363,6 +373,10 @@ class Config extends Controller
             }
             $arr[$item['field']] = $val;
         }
+        $channel = app(TelegramChannelService::class)->legacyMessage();
+        if ($channel !== null) {
+            $arr[TelegramChannelService::LEGACY_FIELD] = $channel;
+        }
         return $this->success($arr);
     }
 
@@ -382,86 +396,51 @@ class Config extends Controller
      * @apiParam {Array} button 消息中的按钮 具体结构请看示例代码
      * @apiParam {String} video 视频
      * @apiParam {String} video_caption 视频文案
-     * @apiParam {Boolean} [isSend=true] 是否发送
-     * @apiParam {Boolean} [isTop=true] 是否置顶
+     * @apiParam {Boolean} [isSend=false] 是否发送
+     * @apiParam {Boolean} [isTop=false] 是否置顶
      */
-    public function sendChannelMessage()
+    public function sendChannelMessage(TelegramChannelService $channels): JsonResponse
     {
-        set_time_limit(0);
-        DB::beginTransaction();
         try {
+            $data = request()->validate(['isSend' => 'nullable|boolean', 'isTop' => 'nullable|boolean']);
+            $send = (bool) ($data['isSend'] ?? false);
+            $this->requireChannelPermission(TelegramChannelService::MANAGE);
+            if ($send) {
+                $this->requireChannelPermission(TelegramChannelService::PUBLISH);
+            }
 
-            $validate = [
-                'chatId' => ['required', 'string', 'min:1'],
-                'type' => ['required', 'string', 'in:image,video,text'],
-                'text' => ['nullable', 'string'],
-                'isSend' => ['nullable', 'boolean'],
-                'isTop' => ['nullable', 'boolean'],
-                'button' => ['array'],
-                'button.*' => ['required', 'array'],
-                'button.*.*.text' => ['required', 'string'],
-                'button.*.*.url' => ['required', 'url'],
-            ];
-            $type = request()->input('type');
-            if (in_array($type, ['image', 'video'])) $validate[$type] = ['required', 'url'];
-            $params = request()->validate($validate);
-            $params['image'] = request()->input('image', '');
-            $params['video'] = request()->input('video', '');
-            $isSend = request()->input('isSend', false);
-            $isTop = request()->input('isTop', false);
-            unset($params['isTop'], $params['isSend']);
-            ConfigModel::where('field', 'channel_message')->update(['val' => json_encode($params)]);
-            DB::commit();
+            $channel = $channels->save(request()->all(), legacy: true);
+            if (!$send) {
+                return $this->success();
+            }
+
+            $result = $channels->publish($channel, (bool) ($data['isTop'] ?? false));
+            if ($result['sent'] !== true) {
+                return $this->error(HttpStatus::CUSTOM_ERROR, '配置已保存,' . $result['error'], $result);
+            }
+            return $this->success($result);
         } catch (ValidationException $e) {
-            DB::rollBack();
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
-        } catch (Exception $e) {
-            DB::rollBack();
-            return $this->error(intval($e->getCode()), $e->getMessage());
         }
+    }
 
-        if ($isSend) {
-            try {
-                $config = ConfigModel::where('field', 'channel_message')->first()->val;
-                $config = json_decode($config, true);
-                $telegram = new Api(config('services.telegram.token'));
-                $msg = [];
-                $msg['chat_id'] = "@{$config['chatId']}";
-                $msg['protect_content'] = false;
-                if (!empty($config['button'])) {
-                    $msg['reply_markup'] = json_encode(['inline_keyboard' => $config['button']]);
-                }
+    private function saveLegacyChannelValue(mixed $value): void
+    {
+        $this->requireChannelPermission(TelegramChannelService::MANAGE);
+        $value = is_string($value) ? json_decode($value, true) : $value;
+        if (!is_array($value)) {
+            throw ValidationException::withMessages(['val' => '频道消息配置格式错误']);
+        }
+        $value['type'] ??= !empty($value['video']) ? 'video' : (!empty($value['image']) ? 'image' : 'text');
+        app(TelegramChannelService::class)->save($value, legacy: true);
+    }
 
-                switch ($type) {
-                    case 'image':
-                        $msg['photo'] = InputFile::create($config['image']);
-                        $msg['caption'] = $config['text'];
-                        $response = $telegram->sendPhoto($msg);
-                        break;
-                    case 'video':
-                        $msg['video'] = InputFile::create($config['video']);
-                        $msg['caption'] = $config['text'];
-                        $response = $telegram->sendVideo($msg);
-                        break;
-                    case 'text':
-                        $msg['text'] = $config['text'];
-                        $response = $telegram->sendMessage($msg);
-                        break;
-                    default:
-                        throw new Exception("保存成功,发送失败", HttpStatus::CUSTOM_ERROR);
-                }
-                // 置顶消息
-                if ($isTop) {
-                    $messageId = $response->get('message_id');
-                    $telegram->pinChatMessage(['chat_id' => "@{$config['chatId']}", 'message_id' => $messageId]);
-                }
-            } catch (TelegramSDKException $e) {
-                return $this->error(HttpStatus::CUSTOM_ERROR, '保存成功,发送失败', $e->getMessage());
-            } catch (Exception $e) {
-                return $this->error($e->getCode(), '保存成功,发送失败');
-            }
+    private function requireChannelPermission(string $permission): void
+    {
+        $userId = request()->user->id ?? 0;
+        if (!$userId || !MenuService::checkMenu($userId, $permission)) {
+            throw ValidationException::withMessages(['permission' => '无权限访问']);
         }
-        return $this->success();
     }
 
     /**

+ 28 - 4
app/Http/Controllers/admin/Simulation.php

@@ -84,7 +84,11 @@ class Simulation extends Controller
                     'wallet_available' => bcadd((string) $wallet->a, '0', 2), 'wallet_frozen' => bcadd((string) $wallet->f, '0', 2)],
                 'bets' => ['bet_amount' => $bet, 'payout' => $payout, 'win_loss' => bcsub($bet, $payout, 2),
                     'unsettled' => DB::table('lhc_order')->whereIn('member_id', $members)->where('lottery_status', 0)->count()
-                        + SimulationBetService::sportQuery()->where('sport_order.settlement_status', 0)->where('sport_order.status', 1)->count()],
+                        + SimulationBetService::sportQuery()->where('sport_order.settlement_status', 0)->where('sport_order.status', 1)->count(),
+                    // 第三方游戏的输赢不记“投注/中奖”流水(与真实三方游戏一致),单独按注单统计
+                    'third' => (array) DB::table('third_game_orders')->whereIn('user_id', MemberAudience::simulatedMembers('id'))
+                        ->where('bet_time', '>=', $start)->where('bet_time', '<', $end)
+                        ->selectRaw('COUNT(*) AS orders, COALESCE(SUM(bet_amount),0) AS bet_amount, COALESCE(SUM(settled_amount),0) AS member_win_loss')->first()],
                 'runs' => $runs->map(fn ($run) => SimulationTaskService::present($run))->all(),
             ];
         });
@@ -231,15 +235,18 @@ class Simulation extends Controller
 
     // ---------- 注单 ----------
 
-    public function betOptions() { return $this->run(fn () => SimulationBetService::options()); }
+    public function betOptions() { return $this->run(fn () => SimulationBetService::options(request()->input('game') ?: null)); }
 
     public function lotteryBets() { return $this->mirror(LhcOrder::class, 'list'); }
 
     public function lotteryBet()
     {
         return $this->run(function () {
-            $params = request()->validate(['member_id' => ['required', 'string'], 'keywords' => ['required', 'string', 'max:32'], 'amount' => ['required', 'numeric', 'min:0.01']]);
-            $order = SimulationBetService::lottery($params['member_id'], $params['keywords'], $params['amount']);
+            // PC28 按 keywords 下注;其他彩种按玩法编号 item_id 下注
+            $params = request()->validate(['member_id' => ['required', 'string'], 'game' => ['nullable', 'string', 'max:16'], 'keywords' => ['nullable', 'string', 'max:64'],
+                'item_id' => ['nullable', 'integer', 'min:1'], 'amount' => ['required', 'numeric', 'min:0.01']]);
+            $order = SimulationBetService::lottery($params['member_id'], (string) ($params['keywords'] ?? ''), $params['amount'], $params['game'] ?? null,
+                isset($params['item_id']) ? (int) $params['item_id'] : null);
             $this->audit('bet', $order->id, 'lottery_bet', $params);
             return $order;
         });
@@ -294,6 +301,18 @@ class Simulation extends Controller
 
     public function thirdBets() { return $this->mirror(ThirdGameOrder::class, 'index'); }
 
+    /** 第三方游戏模拟注单:只在本地生成,不向三方平台发请求。 */
+    public function thirdBet()
+    {
+        return $this->run(function () {
+            $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'],
+                'game_type' => ['nullable', 'integer', 'min:1', 'max:7'], 'result' => ['nullable', 'in:win,lose']]);
+            $order = SimulationBetService::third($params['member_id'], $params['amount'], isset($params['game_type']) ? (int) $params['game_type'] : null, $params['result'] ?? null);
+            $this->audit('bet', $order->id, 'third_bet', $params);
+            return $order;
+        });
+    }
+
     // ---------- 自动任务 ----------
 
     public function templates()
@@ -381,6 +400,11 @@ class Simulation extends Controller
     public function reportHome() { return $this->mirror(Home::class, 'index'); }
     public function reportGames() { return $this->mirror(Reporting::class, 'summary'); }
     public function reportFundFlow() { return $this->mirror(Reporting::class, 'funds'); }
+    // 报表页面里的筛选项、明细、注单与事件弹窗,同样只看模拟会员
+    public function reportOptions() { return $this->mirror(Reporting::class, 'options'); }
+    public function reportDetails() { return $this->mirror(Reporting::class, 'details'); }
+    public function reportOrders() { return $this->mirror(Reporting::class, 'orders'); }
+    public function reportEvents() { return $this->mirror(Reporting::class, 'events'); }
     public function reportRegistrations() { return $this->mirror(User::class, 'registerTrend'); }
     public function reportDomains() { return $this->mirror(User::class, 'registerDomain'); }
 

+ 64 - 0
app/Http/Controllers/admin/TelegramChannel.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\TelegramChannelService;
+use Closure;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
+
+class TelegramChannel extends Controller
+{
+    public function index(TelegramChannelService $channels): JsonResponse
+    {
+        return $this->success(['list' => $channels->list()]);
+    }
+
+    public function detail(Request $request, TelegramChannelService $channels): JsonResponse
+    {
+        return $this->respond(fn () => $channels->detail($this->id($request)));
+    }
+
+    public function save(Request $request, TelegramChannelService $channels): JsonResponse
+    {
+        return $this->respond(fn () => $channels->save($request->all()));
+    }
+
+    public function setDefault(Request $request, TelegramChannelService $channels): JsonResponse
+    {
+        return $this->respond(fn () => $channels->setDefault($this->id($request)));
+    }
+
+    public function delete(Request $request, TelegramChannelService $channels): JsonResponse
+    {
+        return $this->respond(function () use ($request, $channels) {
+            $channels->delete($this->id($request));
+            return [];
+        });
+    }
+
+    public function publish(Request $request, TelegramChannelService $channels): JsonResponse
+    {
+        return $this->respond(function () use ($request, $channels) {
+            $data = $request->validate(['id' => 'required|integer|min:1', 'isTop' => 'sometimes|required|boolean']);
+            return $channels->publish($channels->detail((int) $data['id']), (bool) ($data['isTop'] ?? false));
+        });
+    }
+
+    private function id(Request $request): int
+    {
+        return (int) $request->validate(['id' => 'required|integer|min:1'])['id'];
+    }
+
+    private function respond(Closure $action): JsonResponse
+    {
+        try {
+            return $this->success($action());
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        }
+    }
+}

+ 6 - 6
app/Services/ChannelService.php

@@ -4,17 +4,17 @@
 namespace App\Services;
 
 
-use App\Models\Config;
 use App\Models\Room;
 
 class ChannelService
 {
 
-    public static function createRoomNotice($roomId)
+    public static function createRoomNotice($roomId): ?array
     {
-        $channelMessage = Config::where('field', 'channel_message')->first()->val;
-        $channelMessage = json_decode($channelMessage, true);
-        $chatId = $channelMessage['chatId'];
+        $chatId = app(TelegramChannelService::class)->roomNoticeTarget();
+        if ($chatId === null) {
+            return null;
+        }
         $username = config('services.telegram.username');
         $room = Room::where('room_id', $roomId)->first();
         $text = "新房间通知\n";
@@ -31,7 +31,7 @@ class ChannelService
             ['text' => "加入房间", 'callback_data' => "channelJoin@@{$roomId}"]
         ]];
         return [
-            'chat_id' => "@{$chatId}",
+            'chat_id' => $chatId,
             'text' => $text,
             'reply_markup' => json_encode(['inline_keyboard' => $keyboard])
         ];

+ 2 - 1
app/Services/PaymentOrderService.php

@@ -719,7 +719,8 @@ class PaymentOrderService extends BaseService
         if (!\App\Support\MemberAudience::isSimulatedMember($order->member_id)) {
             throw new Exception('只能对模拟会员的订单执行模拟入账', HttpStatus::CUSTOM_ERROR);
         }
-        return self::applyPayCallback($order, $order->amount, 'SIM', 'SIM', null, ['simulation' => true]);
+        // state 是整型列,用与真实成功回调相同的状态值 1
+        return self::applyPayCallback($order, $order->amount, '1', '1', null, ['simulation' => true]);
     }
 
     private static function applyPayCallback(PaymentOrder $info, $payAmount, string $status, string $successStatus, ?string $failStatus, array $params): bool

+ 202 - 23
app/Services/Simulation/SimulationBetService.php

@@ -15,10 +15,11 @@ use App\Services\LotteryOdds;
 use App\Support\MemberAudience;
 use Exception;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
 
 /**
  * 模拟会员的注单,写真实注单表并产生真实账变。
- * 彩票:PC28(加拿大 type 5 / 极速 type 6,跟随当前 pc28_switch),下在真实期号上,随真实开奖由 BetService::betSettled 结算。
+ * 彩票:全部彩种,下在真实期号上。PC28(type 5 / 6)随本项目开奖由 BetService::betSettled 结算;六合彩、极速彩等由前台项目的结算任务按真实开奖结算。
  * 体育:sport_order,issue 用不存在于赛事表的合成值,所以前台的结算任务取不到它,只能在这里按给定结果结算。
  */
 final class SimulationBetService
@@ -26,16 +27,98 @@ final class SimulationBetService
     public const SPORT_ISSUE_PREFIX = 'SIM';
     public const SPORT_RESULTS = ['win' => 1, 'lose' => 0, 'draw' => 2, 'half' => 3];
 
-    /** 当前可下注的 PC28 期号与可用玩法。 */
-    public static function options(): array
+    /** lhc_order.type => 彩种名(与前台一致,也是流水类型的前缀)。5、6 是 PC28,由本项目开奖结算;其余由前台项目的结算任务按真实开奖结算。 */
+    public const LOTTERY_NAMES = [1 => '新澳门六合彩', 2 => '香港六合彩', 3 => '澳门六合彩', 4 => '极速六合彩', 5 => '加拿大28', 6 => '极速28',
+        7 => '极速赛车', 8 => '极速时时彩', 9 => '极速飞艇', 10 => 'SG时时彩', 11 => 'SG飞艇', 12 => '极速快3', 13 => 'SG快3'];
+
+    /** 要一次选多个号码才成立的玩法,模拟只下单号码注单,所以不提供。 */
+    private const MULTI_NUMBER_PLAYS = ['连', '合肖', '不中', '过关', '中一', '二中', '三中', '特串'];
+
+    private static function lotteryType(string $key): int
+    {
+        [$game, $type] = array_pad(explode(':', $key, 2), 2, null);
+        return match ($game) { 'canada28' => 5, 'jisu28' => 6, 'lhc' => (int) $type, 'jisu' => (int) $type + 6 };
+    }
+
+    /** 全部彩种:PC28 只列当前开关启用的那一个。 */
+    public static function lotteryGames(): array
+    {
+        $pc28 = self::currentIssue()[1];
+        $games = [];
+        foreach (LotteryOdds::gameKeys() as $key) {
+            if (in_array($key, ['canada28', 'jisu28'], true) && $key !== $pc28) continue;
+            $games[$key] = self::LOTTERY_NAMES[self::lotteryType($key)];
+        }
+        return $games;
+    }
+
+    /**
+     * 某个彩种当前可下注的期号;封盘、没有期号或彩种表不存在时返回 null。封盘规则与前台下注一致。
+     * @return array{type:int,issue:string,close:?string}|null
+     */
+    public static function openIssue(string $key): ?array
     {
-        [$type, $key, $issue] = self::currentIssue();
-        $rules = DB::transaction(fn () => LotteryOdds::pricedCatalog($key, LotteryOdds::template($key)->id)
-            ->filter(fn ($rule) => $rule->odds > 0)
-            ->map(fn ($rule) => ['keywords' => $rule->keywords, 'odds' => $rule->odds,
-                'mininum' => $rule->mininum, 'maxinum' => $rule->maxinum])->values());
-        return ['lottery' => ['type' => $type, 'issue' => $issue ? $issue->issue_no : null, 'end_time' => $issue ? (string) $issue->end_time : null, 'rules' => $rules],
-            'sport' => ['results' => array_keys(self::SPORT_RESULTS)]];
+        [$game, $sub] = array_pad(explode(':', $key, 2), 2, null);
+        $type = self::lotteryType($key);
+        $time = fn ($value) => $value === null || $value === '' ? null : (is_numeric($value) ? (int) $value : strtotime((string) $value));
+        if ($game === 'canada28' || $game === 'jisu28') {
+            [, $current, $issue] = self::currentIssue();
+            if ($current !== $key || !$issue || (string) $issue->end_time < date('Y-m-d H:i:s', time() + 30)) return null;
+            return ['type' => $type, 'issue' => (string) $issue->issue_no, 'close' => (string) $issue->end_time];
+        }
+        if ($game === 'lhc') {
+            if (!Schema::hasTable('lhc_lottery')) return null;
+            // 新澳门、澳门六合彩共用新澳门的期号,下的是下一期;每天 21:30-21:40 封盘
+            $official = in_array($type, [1, 3], true);
+            $lottery = DB::table('lhc_lottery')->where('type', $official ? 1 : $type)->orderByDesc('issue')->first();
+            if (!$lottery || (int) $lottery->status === 2) return null;
+            if ($official) {
+                if (date('H:i') >= '21:30' && date('H:i') <= '21:40') return null;
+                return ['type' => $type, 'issue' => (string) ((int) $lottery->issue + 1), 'close' => null];
+            }
+            $next = $time($lottery->next_open_time);
+            if ($next !== null && time() >= $next) return null;
+            $issue = $type === 2 ? $lottery->next_issue : $lottery->issue;
+            return $issue ? ['type' => $type, 'issue' => (string) $issue, 'close' => $next ? date('Y-m-d H:i:s', $next) : null] : null;
+        }
+        if (!Schema::hasTable('jisu_lottery')) return null;
+        $lottery = DB::table('jisu_lottery')->where('type', (int) $sub)->orderByDesc('issue')->first();
+        $open = $lottery ? $time($lottery->open_time) : null;
+        $close = $open === null ? null : $open - (in_array((int) $sub, [1, 2, 3, 6], true) ? 15 : 30);
+        if (!$lottery || (int) $lottery->status === 2 || $close === null || time() >= $close) return null;
+        return ['type' => $type, 'issue' => (string) $lottery->issue, 'close' => date('Y-m-d H:i:s', $close)];
+    }
+
+    /** 某个彩种可下注的玩法:已配置赔率、赔率大于 0、单号码即可成注。PC28 的 id 为空,按 keywords 下注。 */
+    public static function lotteryRules(string $key): array
+    {
+        $pc28 = in_array($key, ['canada28', 'jisu28'], true);
+        try {
+            $rows = DB::transaction(fn () => LotteryOdds::pricedCatalog($key, (int) LotteryOdds::template($key)->id));
+        } catch (\Throwable $e) {
+            return []; // 该彩种没有赔率模板或玩法表
+        }
+        $rules = [];
+        foreach ($rows as $row) {
+            if (!is_numeric($row->odds) || $row->odds <= 0) continue;
+            $label = $pc28 ? (string) $row->keywords : trim(($row->game ?? '') . ' ' . ($row->gameplay ?? '') . ' ' . ($row->number ?? ''));
+            if (!$pc28) foreach (self::MULTI_NUMBER_PLAYS as $word) if (str_contains($label, $word)) continue 2;
+            $rules[] = ['id' => $pc28 ? null : (int) $row->id, 'keywords' => $label, 'odds' => $row->odds, 'mininum' => $row->mininum, 'maxinum' => $row->maxinum];
+        }
+        return $rules;
+    }
+
+    /** 下注表单用:各彩种及其当前期号;带 $game 时再给出该彩种的玩法。 */
+    public static function options(?string $game = null): array
+    {
+        $games = [];
+        foreach (self::lotteryGames() as $key => $name) {
+            $open = self::openIssue($key);
+            $games[] = ['key' => $key, 'name' => $name, 'type' => self::lotteryType($key), 'issue' => $open['issue'] ?? null, 'close' => $open['close'] ?? null, 'open' => $open !== null];
+        }
+        $game = $game !== null && isset(self::lotteryGames()[$game]) ? $game : null;
+        return ['lottery' => ['games' => $games, 'game' => $game, 'rules' => $game ? self::lotteryRules($game) : []],
+            'sport' => ['results' => array_keys(self::SPORT_RESULTS)], 'third' => ['games' => self::THIRD_GAMES]];
     }
 
     private static function currentIssue(): array
@@ -47,20 +130,34 @@ final class SimulationBetService
         return $switch === 1 ? [6, 'jisu28', $issue] : [5, 'canada28', $issue];
     }
 
-    /** PC28 下注,校验与 BetService::singleNote 一致,但不发任何群消息或私聊。 */
-    public static function lottery($memberId, string $keywords, $amount): LhcOrder
+    /**
+     * 彩票下注,支持全部彩种,下在真实期号上,不发任何群消息或私聊。
+     * PC28($game 为空或 canada28 / jisu28)按 $keywords 下注,随本项目开奖结算;
+     * 其他彩种按玩法编号 $itemId 下注,注单格式与前台一致,由前台项目的结算任务按真实开奖结算。
+     */
+    public static function lottery($memberId, string $keywords, $amount, ?string $game = null, ?int $itemId = null): LhcOrder
     {
         SimulationSettings::assertEnabled();
         $user = SimulationSettings::member($memberId);
         $amount = SimulationSettings::assertAmount($amount);
-        [$type, $key, $issue] = self::currentIssue();
-        if (!$issue) throw new Exception('暂无可下注期数', HttpStatus::CUSTOM_ERROR);
-        if ((string) $issue->end_time < date('Y-m-d H:i:s', time() + 30)) throw new Exception('封盘中,本次下注无效', HttpStatus::CUSTOM_ERROR);
-        return DB::transaction(function () use ($user, $keywords, $amount, $key, $type, $issue) {
+        $key = $game ?: self::currentIssue()[1];
+        if (!isset(self::lotteryGames()[$key])) throw new Exception('彩种不可用', HttpStatus::CUSTOM_ERROR);
+        $pc28 = in_array($key, ['canada28', 'jisu28'], true);
+        $open = self::openIssue($key);
+        if (!$open) throw new Exception(self::LOTTERY_NAMES[self::lotteryType($key)] . ' 当前封盘或暂无可下注期数', HttpStatus::CUSTOM_ERROR);
+        return DB::transaction(function () use ($user, $keywords, $amount, $key, $pc28, $open, $itemId) {
             $template = LotteryOdds::template($key);
-            $rule = LotteryOdds::pc28Price($template, $keywords);
-            if (!$rule) throw new Exception('玩法未配置:' . $keywords, HttpStatus::CUSTOM_ERROR);
-            if ($rule->odds <= 0) throw new Exception('该玩法赔率为 0,禁止投注', HttpStatus::CUSTOM_ERROR);
+            if ($pc28) {
+                $rule = LotteryOdds::pc28Price($template, $keywords);
+                $item = ['number' => $keywords];
+                $fields = [];
+            } else {
+                $rule = LotteryOdds::pricedCatalog($key, (int) $template->id)->firstWhere('id', (int) $itemId);
+                $item = ['number_id' => (int) $itemId, 'number' => (string) ($rule->number ?? '')];
+                $fields = ['game' => (string) ($rule->game ?? ''), 'gameplay' => (string) ($rule->gameplay ?? '')];
+            }
+            if (!$rule) throw new Exception('玩法未配置:' . ($pc28 ? $keywords : $itemId), HttpStatus::CUSTOM_ERROR);
+            if (!is_numeric($rule->odds) || $rule->odds <= 0) throw new Exception('该玩法赔率为 0,禁止投注', HttpStatus::CUSTOM_ERROR);
             if ($amount < $rule->mininum) throw new Exception('低于该玩法最小金额 ' . $rule->mininum, HttpStatus::CUSTOM_ERROR);
             if ($amount > $rule->maxinum) throw new Exception('超过该玩法最大金额 ' . $rule->maxinum, HttpStatus::CUSTOM_ERROR);
             $wallet = Wallet::where('member_id', $user->member_id)->lockForUpdate()->first();
@@ -68,18 +165,60 @@ final class SimulationBetService
             $before = (string) $wallet->available_balance;
             if (bccomp($before, $amount, 10) < 0) throw new Exception('余额不足', HttpStatus::CUSTOM_ERROR);
             $ordernum = date('YmdHis') . str_pad((string) mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
-            LhcOrderItem::create(['ordernum' => $ordernum, 'member_id' => $user->member_id, 'number' => $keywords]);
-            $order = LhcOrder::create(['type' => $type, 'bet_source' => LhcOrder::SOURCE_SIMULATION, 'issue' => $issue->issue_no, 'ordernum' => $ordernum,
+            LhcOrderItem::create(['ordernum' => $ordernum, 'member_id' => $user->member_id] + $item);
+            $order = LhcOrder::create(['type' => $open['type'], 'bet_source' => LhcOrder::SOURCE_SIMULATION, 'issue' => $open['issue'], 'ordernum' => $ordernum,
                 'member_id' => $user->member_id, 'odds' => $rule->odds, 'amount' => $amount, 'total_amount' => $amount]
-                + LotteryOdds::orderFields($template));
+                + $fields + LotteryOdds::orderFields($template));
             $after = bcsub($before, $amount, 10);
             $wallet->available_balance = $after;
             $wallet->save();
-            BalanceLogService::addLog($user->member_id, bcsub('0', $amount, 2), $before, $after, '投注', $order->id, '模拟投注');
+            // 流水类型与真实下注一致:PC28 记“投注”,其他彩种记“彩种名 + 投注”
+            $changeType = $pc28 ? '投注' : self::LOTTERY_NAMES[$open['type']] . '投注';
+            BalanceLogService::addLog($user->member_id, bcsub('0', $amount, 2), $before, $after, $changeType, $order->id, '模拟投注');
             return $order;
         });
     }
 
+    /** 随机挑一个当前可下注的彩种和玩法下一注;没有任何彩种可下时抛错。PC28 优先下大小单双。 */
+    public static function lotteryRandom($memberId, $amount): LhcOrder
+    {
+        $keys = array_keys(self::lotteryGames());
+        shuffle($keys);
+        foreach ($keys as $key) {
+            if (!self::openIssue($key)) continue;
+            $rules = array_values(array_filter(self::lotteryRules($key), fn ($rule) => $amount >= $rule['mininum'] && $amount <= $rule['maxinum']));
+            if (!$rules) continue;
+            if (in_array($key, ['canada28', 'jisu28'], true)) {
+                $basic = array_values(array_filter($rules, fn ($rule) => in_array($rule['keywords'], ['大', '小', '单', '双'], true)));
+                $rules = $basic ?: $rules;
+            }
+            $rule = $rules[mt_rand(0, count($rules) - 1)];
+            return self::lottery($memberId, (string) $rule['keywords'], $amount, $key, $rule['id']);
+        }
+        throw new Exception('当前没有可下注的彩种(都在封盘,或下注金额不在任何玩法的限额内)', HttpStatus::CUSTOM_ERROR);
+    }
+
+    /** 当前是否有任何彩种可下注(混合下注时用来决定要不要下彩票)。 */
+    public static function lotteryOpen(): bool
+    {
+        foreach (array_keys(self::lotteryGames()) as $key) if (self::openIssue($key)) return true;
+        return false;
+    }
+
+    /**
+     * 非 PC28 彩种由前台项目结算,它不会给模拟会员登记报表用的结算时间;这里把已结算的模拟注单补登记(可重复执行)。
+     * @return int 本次补登记的注单数
+     */
+    public static function sweepSettledLottery(): int
+    {
+        if (!Schema::hasTable('reporting_contexts')) return 0;
+        $ids = DB::table('lhc_order')->where('bet_source', LhcOrder::SOURCE_SIMULATION)->whereNotIn('type', [5, 6])->where('lottery_status', '<>', 0)
+            ->whereNotIn('id', DB::table('reporting_contexts')->where('source', 'lhc_order')->whereNotNull('succeeded_at')->selectRaw('CAST(source_id AS UNSIGNED)'))
+            ->orderByDesc('id')->limit(200)->pluck('id');
+        foreach ($ids as $id) \App\Services\Reporting\ReportContext::lotterySettled((int) $id);
+        return count($ids);
+    }
+
     /** 生成一张体育注单并扣款。 */
     public static function sport($memberId, $amount, $odd, string $title = '', string $selection = ''): Order
     {
@@ -108,6 +247,46 @@ final class SimulationBetService
         });
     }
 
+    public const THIRD_GAMES = [1 => '模拟视讯', 2 => '模拟老虎机', 3 => '模拟彩票', 4 => '模拟体育', 5 => '模拟电竞', 6 => '模拟捕猎', 7 => '模拟棋牌'];
+    public const THIRD_PLATFORM = 'SIM';
+
+    /**
+     * 第三方游戏的模拟注单:只在本地生成一张已完成的注单,不向任何三方平台发请求。
+     * 结果不指定时随机(约 45% 赢,赢 0.5~1.5 倍下注额;其余全输)。输赢净额以“资产转移”流水进出钱包。
+     * @param string|null $result win|lose,null 为随机
+     */
+    public static function third($memberId, $amount, ?int $gameType = null, ?string $result = null): \App\Models\ThirdGameOrder
+    {
+        SimulationSettings::assertEnabled();
+        $user = SimulationSettings::member($memberId);
+        $amount = SimulationSettings::assertAmount($amount);
+        $gameType = $gameType ?: mt_rand(1, 7);
+        if (!isset(self::THIRD_GAMES[$gameType])) throw new Exception('游戏分类不正确', HttpStatus::CUSTOM_ERROR);
+        if ($result !== null && !in_array($result, ['win', 'lose'], true)) throw new Exception('结果只能是赢或输', HttpStatus::CUSTOM_ERROR);
+        $win = $result === null ? mt_rand(1, 100) <= 45 : $result === 'win';
+        $profit = $win ? bcmul($amount, number_format(mt_rand(50, 150) / 100, 2, '.', ''), 2) : bcsub('0', $amount, 2);
+        return DB::transaction(function () use ($user, $amount, $gameType, $profit) {
+            $wallet = Wallet::where('member_id', $user->member_id)->lockForUpdate()->first();
+            if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
+            $before = (string) $wallet->available_balance;
+            if (bccomp($before, $amount, 10) < 0) throw new Exception('余额不足', HttpStatus::CUSTOM_ERROR);
+            $playerId = trim((string) config('third_game.player_prefix', 'u')) . str_pad((string) $user->id, 4, '0', STR_PAD_LEFT);
+            $gameOrderId = 'SIM' . date('YmdHis') . str_pad((string) mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
+            $now = now();
+            $order = \App\Models\ThirdGameOrder::create(['order_key' => hash('sha256', self::THIRD_PLATFORM . '|' . $playerId . '|' . $gameOrderId),
+                'user_id' => $user->id, 'member_id' => (string) $user->member_id, 'username' => (string) ($user->username ?? ''),
+                'first_name' => (string) ($user->first_name ?? ''), 'player_id' => $playerId, 'platform' => self::THIRD_PLATFORM,
+                'currency' => (string) config('third_game.currency', 'CNY'), 'game_type' => $gameType, 'game_name' => self::THIRD_GAMES[$gameType],
+                'round_no' => $gameOrderId, 'table_no' => '', 'seat' => '', 'bet_amount' => $amount, 'valid_amount' => $amount, 'settled_amount' => $profit,
+                'bet_content' => '模拟注单', 'status' => 1, 'game_order_id' => $gameOrderId, 'bet_time' => $now, 'last_update_time' => $now]);
+            $after = bcadd($before, $profit, 10);
+            $wallet->available_balance = $after;
+            $wallet->save();
+            BalanceLogService::addLog($user->member_id, $profit, $before, $after, '资产转移', $order->id, '三方游戏模拟结算:' . self::THIRD_GAMES[$gameType]);
+            return $order;
+        });
+    }
+
     /** 只允许操作模拟会员自己的、合成期号的体育注单。 */
     private static function sportOrder(int $id): Order
     {

+ 24 - 5
app/Services/Simulation/SimulationRunner.php

@@ -26,6 +26,11 @@ final class SimulationRunner
             // 持续运行这一轮没能启动(例如默认参数超过上限);不影响已有任务,下次调度再试
             \Illuminate\Support\Facades\Log::warning('simulation_schedule_failed', ['error' => $e->getMessage()]);
         }
+        try {
+            SimulationBetService::sweepSettledLottery();
+        } catch (Throwable $e) {
+            \Illuminate\Support\Facades\Log::warning('simulation_lottery_sweep_failed', ['error' => $e->getMessage()]);
+        }
         $settings = SimulationSettings::all();
         $slots = (int) $settings['simulation_max_concurrency'] - DB::table('simulation_task_runs')->where('status', 'running')->count();
         if ($slots > 0) {
@@ -127,13 +132,27 @@ final class SimulationRunner
                 SimulationFundService::withdrawAudit((int) $order->id, true);
                 return ['ref_type' => 'payment_orders', 'ref_id' => (string) $order->id, 'message' => '提现成功'];
             case 'bet':
-                if (($step['category'] ?? 'lottery') === 'sport') {
+                $category = $step['category'] ?? 'lottery';
+                $note = '';
+                if ($category === 'mixed') {
+                    // 混合:体育、彩票、第三方游戏各占约三分之一;彩票当前没有可下注的期号时改下另外两种
+                    $category = ['sport', 'lottery', 'third'][mt_rand(0, 2)];
+                    if ($category === 'lottery' && !SimulationBetService::lotteryOpen()) {
+                        $category = ['sport', 'third'][mt_rand(0, 1)];
+                        $note = '(彩票当前无可下注期号,改下其他分类)';
+                    }
+                }
+                if ($category === 'third') {
+                    $order = SimulationBetService::third($memberId, $step['amount']);
+                    return ['ref_type' => 'third_game_orders', 'ref_id' => (string) $order->id, 'message' => "第三方游戏 {$order->game_name} 输赢 " . (float) $order->settled_amount . $note];
+                }
+                if ($category === 'sport') {
                     $order = SimulationBetService::sport($memberId, $step['amount'], number_format(mt_rand(150, 250) / 100, 2, '.', ''));
-                    return ['ref_type' => 'sport_order', 'ref_id' => (string) $order->id, 'message' => '体育下注 赔率 ' . (float) $order->odd];
+                    return ['ref_type' => 'sport_order', 'ref_id' => (string) $order->id, 'message' => '体育下注 赔率 ' . (float) $order->odd . $note];
                 }
-                $keywords = ['大', '小', '单', '双'][mt_rand(0, 3)];
-                $order = SimulationBetService::lottery($memberId, $keywords, $step['amount']);
-                return ['ref_type' => 'lhc_order', 'ref_id' => (string) $order->id, 'message' => "彩票下注 {$order->issue} 期 {$keywords}"];
+                $order = SimulationBetService::lotteryRandom($memberId, $step['amount']);
+                $name = SimulationBetService::LOTTERY_NAMES[(int) $order->type] ?? '彩票';
+                return ['ref_type' => 'lhc_order', 'ref_id' => (string) $order->id, 'message' => "彩票下注 {$name} {$order->issue} 期"];
             case 'settle':
                 $pending = LhcOrder::where('member_id', $memberId)->where('bet_source', LhcOrder::SOURCE_SIMULATION)
                     ->where('lottery_status', LhcOrder::STATUS_STAY)->count();

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

@@ -24,7 +24,7 @@ final class SimulationSettings
         // 一键启动的默认任务:注册 → 登录 → 充值 → 下注 → 结算 → 提现 → 退出
         'simulation_task_members' => ['5', 1, 200],
         'simulation_task_recharge' => ['1000', 1, 1000000],
-        'simulation_task_bet_category' => ['sport', null, null],
+        'simulation_task_bet_category' => ['mixed', null, null],
         'simulation_task_bet_amount' => ['100', 1, 1000000],
         'simulation_task_bet_count' => ['3', 1, 50],
         'simulation_task_withdraw' => ['200', 0, 1000000],
@@ -57,7 +57,7 @@ final class SimulationSettings
             if (!array_key_exists($key, $input)) continue;
             $value = trim((string) $input[$key]);
             if ($key === 'simulation_task_bet_category') {
-                if (!in_array($value, ['sport', 'lottery'], true)) throw new Exception('默认任务的下注分类只能是体育或彩票', HttpStatus::CUSTOM_ERROR);
+                if (!in_array($value, SimulationTaskService::BET_CATEGORIES, true)) throw new Exception('默认任务的下注分类不正确', HttpStatus::CUSTOM_ERROR);
             } elseif (in_array($key, ['simulation_schedule_start', 'simulation_schedule_end'], true)) {
                 if ($value !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) || date('Y-m-d', (int) strtotime($value)) !== $value)) {
                     throw new Exception('持续运行的日期格式应为 YYYY-MM-DD', HttpStatus::CUSTOM_ERROR);

+ 3 - 1
app/Services/Simulation/SimulationTaskService.php

@@ -13,6 +13,8 @@ use Illuminate\Support\Facades\DB;
  */
 final class SimulationTaskService
 {
+    /** 下注分类:体育、彩票 PC28、第三方游戏(本地模拟)、混合(三种随机)。 */
+    public const BET_CATEGORIES = ['sport', 'lottery', 'third', 'mixed'];
     public const STEP_TYPES = ['register', 'login', 'recharge', 'bet', 'settle', 'withdraw', 'logout'];
     /** 每个会员只做一次的步骤,count 不生效。 */
     public const ONCE = ['register', 'login', 'logout', 'settle'];
@@ -28,7 +30,7 @@ final class SimulationTaskService
             $row = ['type' => $type, 'count' => in_array($type, self::ONCE, true) ? 1 : min(50, max(1, (int) ($step['count'] ?? 1))),
                 'interval' => min(3600, max(0, (int) ($step['interval'] ?? 2)))];
             if (in_array($type, ['recharge', 'bet', 'withdraw'], true)) $row['amount'] = SimulationSettings::assertAmount($step['amount'] ?? 100);
-            if ($type === 'bet') $row['category'] = ($step['category'] ?? 'lottery') === 'sport' ? 'sport' : 'lottery';
+            if ($type === 'bet') $row['category'] = in_array($step['category'] ?? '', self::BET_CATEGORIES, true) ? $step['category'] : 'mixed';
             if ($type === 'settle') $row['settle_mode'] = in_array($step['settle_mode'] ?? 'random', ['random', 'win', 'lose'], true) ? ($step['settle_mode'] ?? 'random') : 'random';
             if (in_array($type, ['register', 'login'], true)) $row['platform'] = substr((string) ($step['platform'] ?? 'H5'), 0, 16);
             $out[] = $row;

+ 368 - 0
app/Services/TelegramChannelService.php

@@ -0,0 +1,368 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Database\Query\Builder;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
+use RuntimeException;
+use Telegram\Bot\Api;
+use Telegram\Bot\Exceptions\TelegramResponseException;
+use Telegram\Bot\FileUpload\InputFile;
+
+class TelegramChannelService
+{
+    public const LEGACY_FIELD = 'channel_message';
+    public const VIEW = 'admin/telegramChannel/list';
+    public const MANAGE = 'admin/telegramChannel/save';
+    public const PUBLISH = 'admin/telegramChannel/publish';
+
+    public function list(): array
+    {
+        return $this->joinedChannels()->orderBy('channels.id')
+            ->get()
+            ->map(fn ($row) => $this->present($row, (int) $row->default_channel_id === (int) $row->id))
+            ->all();
+    }
+
+    public function detail(int $id): array
+    {
+        $row = $this->find($id);
+        return $this->present($row, (int) $row->default_channel_id === $id, true);
+    }
+
+    public function save(array $input, bool $legacy = false): array
+    {
+        $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',
+            'chatId' => 'required|string|max:64',
+            'enabled' => 'sometimes|required|boolean',
+            'type' => [($legacy || $hasMessage) ? 'required' : 'sometimes', 'string', 'in:text,image,video'],
+        ])->validate();
+
+        $target = self::normalizeTarget($fields['chatId']);
+        if ($target === null) {
+            throw ValidationException::withMessages(['chatId' => '请输入频道用户名或负数频道 ID']);
+        }
+        $message = isset($fields['type']) ? self::message($input) : null;
+        $row = !$legacy && isset($fields['id']) ? $this->find((int) $fields['id']) : null;
+        $targetUnchanged = $row !== null && in_array($target, [$row->target, $row->telegram_chat_id], true);
+        $identity = $targetUnchanged
+            ? ['target' => $row->target, 'telegram_chat_id' => $row->telegram_chat_id, 'title' => $row->name]
+            : $this->resolve($target);
+
+        return DB::transaction(function () use ($fields, $identity, $message, $legacy, $row) {
+            $default = $this->settings()->lockForUpdate()->first();
+            $channels = DB::table('telegram_channels');
+            $existing = $row === null ? null : (clone $channels)->where('id', $row->id)->first();
+            if ($row !== null && ($existing === null || $existing->target !== $row->target || $existing->telegram_chat_id !== $row->telegram_chat_id)) {
+                throw ValidationException::withMessages(['id' => '频道已变更,请刷新后重试']);
+            }
+
+            $match = (clone $channels)->where(function (Builder $query) use ($identity) {
+                $query->where('target', $identity['target']);
+                if ($identity['telegram_chat_id'] !== null) {
+                    $query->orWhere('telegram_chat_id', $identity['telegram_chat_id']);
+                }
+            })->get();
+            if ($legacy && $match->count() === 1) {
+                $existing = $match->first();
+            }
+            if ($match->contains(fn ($channel) => (int) $channel->id !== (int) $existing?->id)) {
+                throw ValidationException::withMessages(['chatId' => '该频道已存在']);
+            }
+
+            $enabled = (bool) ($fields['enabled'] ?? $existing?->enabled ?? true);
+            $values = [
+                'name' => $fields['name'] ?? $existing?->name ?? $identity['title'],
+                'target' => $identity['target'],
+                'telegram_chat_id' => $identity['telegram_chat_id'],
+                'enabled' => $enabled,
+                'message' => $message === null
+                    ? ($existing?->message ?? 'null')
+                    : json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
+                'updated_at' => now(),
+            ];
+            if ($existing === null) {
+                $firstLegacy = $legacy && !$channels->exists();
+                $id = (int) $channels->insertGetId($values + ['created_at' => now()]);
+                if ($firstLegacy && $enabled) {
+                    $this->settings()->update(['default_channel_id' => $id]);
+                }
+            } else {
+                $id = (int) $existing->id;
+                $channels->where('id', $id)->update($values);
+                if (!$enabled && (int) $default->default_channel_id === $id) {
+                    $this->settings()->update(['default_channel_id' => null]);
+                }
+            }
+
+            return $this->detail($id);
+        }, 3);
+    }
+
+    public function setDefault(int $id): array
+    {
+        return DB::transaction(function () use ($id) {
+            $this->settings()->lockForUpdate()->first();
+            $row = $this->find($id);
+            if (!$row->enabled) {
+                throw ValidationException::withMessages(['id' => '请先启用频道']);
+            }
+            $this->settings()->update(['default_channel_id' => $id]);
+            return $this->detail($id);
+        }, 3);
+    }
+
+    public function delete(int $id): void
+    {
+        DB::transaction(function () use ($id) {
+            $default = $this->settings()->lockForUpdate()->first();
+            $this->find($id);
+            if ((int) $default->default_channel_id === $id) {
+                $this->settings()->update(['default_channel_id' => null]);
+            }
+            DB::table('telegram_channels')->where('id', $id)->delete();
+        }, 3);
+    }
+
+    public function legacyMessage(): ?array
+    {
+        $row = $this->defaultChannel();
+        return $row === null ? null : ['chatId' => ltrim($row->target, '@')] + $this->storedMessage($row);
+    }
+
+    public function roomNoticeTarget(): ?string
+    {
+        $row = $this->defaultChannel();
+        return $row === null ? null : ($row->telegram_chat_id ?? $row->target);
+    }
+
+    public function publish(array $channel, bool $pin): array
+    {
+        if (!$channel['enabled']) {
+            throw ValidationException::withMessages(['id' => '请先启用频道']);
+        }
+
+        if ($channel['type'] === null) {
+            throw ValidationException::withMessages(['type' => '请先保存要发布的消息']);
+        }
+
+        $params = ['chat_id' => $channel['telegram_chat_id'] ?? $channel['chatId']];
+        if ($channel['button'] !== []) {
+            $params['reply_markup'] = json_encode(['inline_keyboard' => $channel['button']], JSON_THROW_ON_ERROR);
+        }
+
+        try {
+            $telegram = app(Api::class);
+            $response = $this->send($telegram, $channel, $params);
+        } catch (TelegramResponseException $exception) {
+            if (($exception->getHttpStatusCode() ?? 500) < 500) {
+                return ['sent' => false, 'pinned' => null, 'message_id' => null, 'error' => 'Telegram 拒绝发布,请检查机器人发帖权限、频道和消息内容'];
+            }
+            return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
+        } catch (\Exception) {
+            return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
+        }
+
+        $messageId = $response['message_id'] ?? null;
+        if ($messageId === null) {
+            return ['sent' => null, 'pinned' => null, 'message_id' => null, 'error' => '未能确认发布结果,请先检查频道'];
+        }
+
+        $pinned = null;
+        $error = null;
+        if ($pin) {
+            try {
+                $pinned = $telegram->pinChatMessage(['chat_id' => $params['chat_id'], 'message_id' => $messageId]);
+                if (!$pinned) {
+                    $error = '消息已发布,置顶未成功';
+                }
+            } catch (\Exception) {
+                $pinned = false;
+                $error = '消息已发布,请在频道中检查置顶状态';
+            }
+        }
+
+        return ['sent' => true, 'pinned' => $pinned, 'message_id' => $messageId, 'error' => $error];
+    }
+
+    private function send(Api $telegram, array $channel, array $params): array
+    {
+        if ($channel['type'] === 'text') {
+            return $telegram->post('sendMessage', $params + ['text' => $channel['text']])->getResult() ?: [];
+        }
+
+        $image = $channel['type'] === 'image';
+        $media = $channel[$channel['type']];
+        $field = $image ? 'photo' : 'video';
+        $params['caption'] = $channel['text'];
+
+        // Only read files produced by the admin upload endpoints on the configured public disk.
+        $disk = Storage::disk('public');
+        $prefix = rtrim($disk->url(''), '/') . '/';
+        $path = str_starts_with($media, $prefix) ? substr($media, strlen($prefix)) : '';
+        if (preg_match('~\A(?:images|videos)/[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|mp4|mov|avi|webm)\z~i', $path) && $disk->exists($path)) {
+            $stream = $disk->readStream($path);
+            if (!is_resource($stream)) {
+                throw new RuntimeException('Unable to read uploaded media');
+            }
+            try {
+                $params[$field] = InputFile::create($stream, basename($path));
+                $response = $image ? $telegram->sendPhoto($params) : $telegram->sendVideo($params);
+                return ['message_id' => $response->get('message_id')];
+            } finally {
+                if (is_resource($stream)) {
+                    fclose($stream);
+                }
+            }
+        }
+
+        // Telegram fetches external media directly.
+        return $telegram->post($image ? 'sendPhoto' : 'sendVideo', $params + [$field => $media])->getResult() ?: [];
+    }
+
+    private function resolve(string $target): array
+    {
+        try {
+            $chat = app(Api::class)->getChat(['chat_id' => $target]);
+        } catch (\Exception) {
+            throw ValidationException::withMessages(['chatId' => '无法读取频道,请检查频道标识及机器人是否已加入']);
+        }
+        $id = (string) $chat->get('id');
+        if ($chat->get('type') !== 'channel' || !preg_match('/^-[1-9]\d{1,19}$/D', $id)) {
+            throw ValidationException::withMessages(['chatId' => '请输入 Telegram 频道,群组和私聊无法使用']);
+        }
+        $username = $chat->get('username');
+        return [
+            'target' => $username ? self::normalizeTarget($username) ?? $id : $id,
+            'telegram_chat_id' => $id,
+            'title' => mb_substr((string) ($chat->get('title') ?: $target), 0, 255),
+        ];
+    }
+
+    public static function legacyChannel(mixed $value): ?array
+    {
+        if (!is_array($value) || ($target = self::normalizeTarget($value['chatId'] ?? null)) === null) {
+            return null;
+        }
+        $value['type'] ??= !empty($value['video']) ? 'video' : (!empty($value['image']) ? 'image' : 'text');
+        try {
+            $message = self::message($value);
+        } catch (ValidationException) {
+            return null;
+        }
+        return [
+            'name' => ltrim($target, '@'),
+            'target' => $target,
+            'telegram_chat_id' => str_starts_with($target, '-') ? $target : null,
+            'enabled' => true,
+            'message' => json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
+        ];
+    }
+
+    private static function normalizeTarget(mixed $value): ?string
+    {
+        if (!is_string($value)) {
+            return null;
+        }
+        $value = trim($value);
+        if (preg_match('/^-[1-9]\d{1,19}$/D', $value)) {
+            return $value;
+        }
+        return preg_match('/^@?[a-z][a-z0-9_]{3,31}$/iD', $value) ? '@' . strtolower(ltrim($value, '@')) : null;
+    }
+
+    private static function message(array $input): array
+    {
+        $data = Validator::make($input, [
+            'type' => 'required|in:text,image,video',
+            'text' => 'nullable|required_if:type,text|string|max:' . (($input['type'] ?? null) === 'text' ? 4096 : 1024),
+            'image' => 'nullable|required_if:type,image|string|max:2048|url:http,https',
+            'video' => 'nullable|required_if:type,video|string|max:2048|url:http,https',
+            'button' => 'sometimes|array|list',
+            'button.*' => 'required|array|list',
+            'button.*.*' => 'required|array',
+            'button.*.*.text' => 'required|string',
+            'button.*.*.url' => 'required|string|url:http,https,tg',
+        ])->validate();
+
+        foreach (['image', 'video'] as $field) {
+            $url = $data[$field] ?? '';
+            $host = rtrim(strtolower(trim(parse_url($url, PHP_URL_HOST) ?? '', '[]')), '.');
+            if ($url !== '' && (
+                parse_url($url, PHP_URL_USER) !== null
+                || $host === 'localhost'
+                || str_ends_with($host, '.localhost')
+                || (filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE))
+                || str_contains(strtolower(rawurldecode(parse_url($url, PHP_URL_PATH) ?? '')), '/_ignition/')
+            )) {
+                throw ValidationException::withMessages([$field => '媒体地址须可公开访问']);
+            }
+        }
+
+        return [
+            'type' => $data['type'],
+            'text' => $data['text'] ?? '',
+            'image' => $data['image'] ?? '',
+            'video' => $data['video'] ?? '',
+            'button' => array_map(fn ($row) => array_map(
+                fn ($button) => ['text' => $button['text'], 'url' => $button['url']],
+                $row,
+            ), $data['button'] ?? []),
+        ];
+    }
+
+    private function settings(): Builder
+    {
+        return DB::table('telegram_channel_settings')->where('id', 1);
+    }
+
+    private function defaultChannel(): ?object
+    {
+        return $this->joinedChannels()->where('channels.enabled', true)
+            ->whereColumn('channels.id', 'settings.default_channel_id')->first();
+    }
+
+    private function find(int $id): object
+    {
+        return $this->joinedChannels()->where('channels.id', $id)->first()
+            ?? throw ValidationException::withMessages(['id' => '频道不存在']);
+    }
+
+    private function joinedChannels(): Builder
+    {
+        return DB::table('telegram_channels as channels')
+            ->crossJoin('telegram_channel_settings as settings')
+            ->select('channels.*', 'settings.default_channel_id')
+            ->where('settings.id', 1);
+    }
+
+    private function present(object $row, bool $default, bool $withMessage = false): array
+    {
+        $data = [
+            'id' => (int) $row->id,
+            'name' => $row->name,
+            'chatId' => $row->target,
+            'telegram_chat_id' => $row->telegram_chat_id,
+            'enabled' => (bool) $row->enabled,
+            'is_default' => $default,
+        ];
+        return $withMessage ? $data + $this->storedMessage($row) : $data;
+    }
+
+    private function storedMessage(object $row): array
+    {
+        return json_decode($row->message, true) ?? [
+            'type' => null,
+            'text' => '',
+            'image' => '',
+            'video' => '',
+            'button' => [],
+        ];
+    }
+}

+ 1 - 1
database/migrations/2026_11_11_130000_create_simulation_tables.php

@@ -19,7 +19,7 @@ return new class extends Migration {
         'simulation_default_domain' => ['sim.local', '模拟会员默认注册/登录域名'],
         'simulation_task_members' => ['5', '一键任务:注册的模拟会员数'],
         'simulation_task_recharge' => ['1000', '一键任务:每人充值金额'],
-        'simulation_task_bet_category' => ['sport', '一键任务:下注分类 sport 体育 / lottery 彩票'],
+        'simulation_task_bet_category' => ['mixed', '一键任务:下注分类 sport 体育 / lottery 彩票 / third 第三方游戏 / mixed 混合'],
         'simulation_task_bet_amount' => ['100', '一键任务:单次下注金额'],
         'simulation_task_bet_count' => ['3', '一键任务:每人下注次数'],
         'simulation_task_withdraw' => ['200', '一键任务:每人提现金额,0 为不提现'],

+ 1 - 1
database/migrations/2026_11_11_140000_add_simulation_admin_menus.php

@@ -19,7 +19,7 @@ return new class extends Migration {
         'SimulationFunds' => ['模拟资金', ['admin/simulation/funds' => '查看', 'admin/simulation/funds/recharge' => '模拟充值',
             'admin/simulation/funds/withdraw' => '模拟提现', 'admin/simulation/funds/withdraw/audit' => '提现审核', 'admin/simulation/funds/adjust' => '人工调账',
             'admin/simulation/funds/freeze' => '冻结', 'admin/simulation/funds/unfreeze' => '解冻']],
-        'SimulationBets' => ['模拟注单', ['admin/simulation/bets' => '查看', 'admin/simulation/bets/lottery' => '彩票下注', 'admin/simulation/bets/sport' => '体育下注',
+        'SimulationBets' => ['模拟注单', ['admin/simulation/bets' => '查看', 'admin/simulation/bets/lottery' => '彩票下注', 'admin/simulation/bets/sport' => '体育下注', 'admin/simulation/bets/third' => '第三方游戏下注',
             'admin/simulation/bets/sport/settle' => '体育结算', 'admin/simulation/bets/sport/cancel' => '体育撤单']],
         'SimulationTasks' => ['自动任务', ['admin/simulation/tasks' => '查看', 'admin/simulation/tasks/templates' => '保存模板',
             'admin/simulation/tasks/templates/delete' => '删除模板', 'admin/simulation/tasks/runs' => '启动任务', 'admin/simulation/tasks/quick-start' => '一键启动', 'admin/simulation/tasks/runs/status' => '暂停/继续/停止']],

+ 34 - 0
database/migrations/2026_11_13_140000_add_simulation_third_game_bets.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 模拟注单支持第三方游戏(本地模拟):补“第三方游戏下注”按钮权限;
+ * 一键任务的下注分类从原默认值“体育”改为“混合”(体育、彩票、第三方游戏随机),否则自动任务只会下体育。
+ */
+return new class extends Migration {
+    private const URI = 'admin/simulation/bets/third';
+
+    public function up(): void
+    {
+        if (Schema::hasTable('config')) {
+            DB::table('config')->where('field', 'simulation_task_bet_category')->where('val', 'sport')->update(['val' => 'mixed']);
+        }
+        if (!Schema::hasTable('menus') || DB::table('menus')->where('uri', self::URI)->exists()) return;
+        $menu = DB::table('menus')->where('uri', 'SimulationBets')->where('type', 1)->first(['id']);
+        if (!$menu) return;
+        $now = date('Y-m-d H:i:s');
+        DB::table('menus')->insert(['parent_id' => $menu->id, 'title' => '模拟注单-第三方游戏下注', 'icon' => null, 'uri' => self::URI,
+            'permission_name' => str_replace('/', '.', self::URI), 'sort' => 9, 'status' => 1, 'type' => 2, 'created_at' => $now, 'updated_at' => $now]);
+    }
+
+    public function down(): void
+    {
+        if (!Schema::hasTable('menus')) return;
+        $ids = DB::table('menus')->where('uri', self::URI)->pluck('id');
+        if (Schema::hasTable('role_menu')) DB::table('role_menu')->whereIn('menu_id', $ids)->delete();
+        DB::table('menus')->whereIn('id', $ids)->delete();
+    }
+};

+ 52 - 0
database/migrations/2026_11_13_150000_create_telegram_channels.php

@@ -0,0 +1,52 @@
+<?php
+
+use App\Services\TelegramChannelService;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('telegram_channels')) {
+            Schema::create('telegram_channels', function (Blueprint $table) {
+                $table->id();
+                $table->string('name', 255);
+                $table->string('target', 64)->unique();
+                $table->string('telegram_chat_id', 24)->nullable()->unique();
+                $table->boolean('enabled');
+                $table->json('message');
+                $table->timestamps();
+            });
+        }
+
+        if (!Schema::hasTable('telegram_channel_settings')) {
+            Schema::create('telegram_channel_settings', function (Blueprint $table) {
+                $table->unsignedTinyInteger('id')->primary();
+                $table->unsignedBigInteger('default_channel_id')->nullable();
+            });
+        }
+
+        DB::transaction(function () {
+            $settings = DB::table('telegram_channel_settings');
+            if ($settings->where('id', 1)->exists()) {
+                return;
+            }
+
+            $defaultId = null;
+            $legacy = Schema::hasTable('config') ? DB::table('config')->where('field', 'channel_message')->value('val') : null;
+            $channel = TelegramChannelService::legacyChannel(json_decode($legacy ?? '', true));
+            if ($channel !== null) {
+                $defaultId = DB::table('telegram_channels')->insertGetId($channel + ['created_at' => now(), 'updated_at' => now()]);
+            }
+            $settings->insert(['id' => 1, 'default_channel_id' => $defaultId]);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('telegram_channel_settings');
+        Schema::dropIfExists('telegram_channels');
+    }
+};

+ 70 - 0
database/migrations/2026_11_13_150100_add_telegram_channel_permissions.php

@@ -0,0 +1,70 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    private const BUTTONS = [
+        'admin/telegramChannel/list' => ['查看频道', 'admin/config/get'],
+        'admin/telegramChannel/save' => ['管理频道', 'admin/config/sendChannelMessage'],
+        'admin/telegramChannel/publish' => ['发布频道消息', 'admin/config/sendChannelMessage'],
+    ];
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('menus')) {
+            return;
+        }
+
+        DB::transaction(function () {
+            $parent = DB::table('menus')->where(['uri' => 'Message', 'type' => 1])->value('id');
+            if ($parent === null) {
+                $configMenu = DB::table('menus')->where(['uri' => 'config', 'type' => 1])->value('id');
+                $parent = DB::table('menus')->insertGetId($this->menu('Message', '频道管理', 1, $configMenu ?? 0));
+            }
+
+            foreach (self::BUTTONS as $uri => [$title, $legacyUri]) {
+                $button = DB::table('menus')->where(['uri' => $uri, 'type' => 2])->value('id')
+                    ?? DB::table('menus')->insertGetId($this->menu($uri, $title, 2, $parent));
+                if (!Schema::hasTable('role_menu')) {
+                    continue;
+                }
+
+                $legacyButtons = DB::table('menus')->where(['uri' => $legacyUri, 'type' => 2, 'status' => 1])->pluck('id');
+                $roleIds = DB::table('role_menu')->whereIn('menu_id', $legacyButtons)->distinct()->pluck('role_id');
+                foreach ($roleIds as $roleId) {
+                    DB::table('role_menu')->insertOrIgnore(['menu_id' => $button, 'role_id' => $roleId]);
+                }
+            }
+        });
+    }
+
+    private function menu(string $uri, string $title, int $type, int $parent): array
+    {
+        return [
+            'uri' => $uri,
+            'title' => $title,
+            'type' => $type,
+            'parent_id' => $parent,
+            'status' => 1,
+            'sort' => 0,
+            'permission_name' => str_replace('/', '.', $uri),
+            'created_at' => now(),
+            'updated_at' => now(),
+        ];
+    }
+
+    public function down(): void
+    {
+        if (!Schema::hasTable('menus')) {
+            return;
+        }
+
+        $ids = DB::table('menus')->where('type', 2)->whereIn('uri', array_keys(self::BUTTONS))->pluck('id');
+        if (Schema::hasTable('role_menu')) {
+            DB::table('role_menu')->whereIn('menu_id', $ids)->delete();
+        }
+        DB::table('menus')->whereIn('id', $ids)->delete();
+    }
+};

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

@@ -8,6 +8,7 @@
 | 登录态:后台 token 改为滑动续期,有操作不再中途掉线;前端一般无需改动,勿按 exp/session_expires_at 主动登出(2026-11-09) | [后台登录态滑动续期-前端说明](后台登录态滑动续期-前端说明.md) |
 | 公告:多语言、展示平台、发布时段、启停和日志 | [公告系统-后台接口](公告系统-后台接口.md) |
 | 信息:发送对象、多语言、定时发送、撤回和日志 | [信息管理-后台接口](信息管理-后台接口.md) |
+| Telegram 频道管理:多频道配置、默认通知频道、发布与置顶 | [Telegram频道管理-前端接入](Telegram频道管理-前端接入.md) |
 | 维护:整站、游戏大类与子类(三方平台、F1 彩种、F1 体育)、时区、起止时间、开关和日志 | [整站与游戏维护-后台接口](整站与游戏维护-后台接口.md) |
 | F1 体育列表:新增 `sportList`,增删改体育项(足球、篮球、乒乓球、排球、板球、网球) | [F1体育列表-后台接口](F1体育列表-后台接口.md) |
 | 三个独立活动的公共约定和目录 | [独立活动-后台接口](独立活动-后台接口.md) |

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

@@ -0,0 +1,65 @@
+# Telegram 频道管理
+
+在现有「频道管理」页面(路由名 `Message`)接入频道列表和单频道编辑。请求带 `Authorization: Bearer <管理员 token>`,POST 使用 JSON,文件上传使用表单。业务校验失败时展示 `msg`。发布接口在 `code=0` 时还需检查 `data.sent` 和 `data.pinned`。
+
+## 接口与权限
+
+| 方法与地址 | 请求参数 | 返回 `data` | 按钮权限 |
+| --- | --- | --- | --- |
+| `GET /admin/telegramChannel/list` | 无 | `{ "list": [...] }` | `admin/telegramChannel/list` |
+| `GET /admin/telegramChannel/detail?id=12` | `id` | 频道详情 | `admin/telegramChannel/list` |
+| `POST /admin/telegramChannel/save` | 频道资料和可选消息内容 | 保存后的频道详情 | `admin/telegramChannel/save` |
+| `POST /admin/telegramChannel/setDefault` | `{ "id": 12 }` | 更新后的频道详情 | `admin/telegramChannel/save` |
+| `POST /admin/telegramChannel/delete` | `{ "id": 12 }` | `[]` | `admin/telegramChannel/save` |
+| `POST /admin/telegramChannel/publish` | `{ "id": 12, "isTop": true }` | 发布及置顶结果 | `admin/telegramChannel/publish` |
+
+列表返回 `id`、`name`、`chatId`、`telegram_chat_id`、`enabled`、`is_default`。详情额外返回 `type`、`text`、`image`、`video`、`button`。所有编辑、删除、设默认和发布操作使用后台返回的 `id`。频道的 Telegram 数字 ID 以字符串处理,`telegram_chat_id` 在旧频道尚未解析时可为 `null`。
+
+## 上传、保存与回读
+
+- 图片上传用 `POST /admin/upload/uploadFile`,表单字段为 `image`。视频上传用 `POST /admin/upload/uploadVideo`,表单字段为 `file`。把返回的 `data.path` 填入保存请求的 `image` 或 `video`。
+- `chatId` 必填,可用 `@频道用户名` 或 `"-100…"`。新增或变更目标时需要机器人能读取该频道。频道复用系统 Bot,页面无需 Bot Token。
+- `name` 可省略,新增时默认用频道名称。新增默认启用。只改名称或启停时提交 `id`、当前 `chatId` 和要修改的字段,原消息会保留。
+- 消息 `type` 支持 `text`、`image`、`video`。纯文字需要 `text`,图片需要 `image`,视频需要 `video`。图片和视频的说明也用 `text`。`button` 是按行排列的二维数组,每个按钮填写 `text` 和 `url`。编辑消息时提交完整消息字段,清空按钮传 `[]`。
+
+新增图片菜单示例。编辑同一频道时加上 `"id": 12`。
+
+```json
+{
+  "chatId": "@example_channel",
+  "name": "活动频道",
+  "enabled": true,
+  "type": "image",
+  "text": "欢迎加入",
+  "image": "https://example.com/storage/images/menu.jpg",
+  "button": [
+    [
+      {"text": "进入网站", "url": "https://example.com"}
+    ]
+  ]
+}
+```
+
+保存成功后用返回的频道详情更新表单,再刷新列表。新建频道时可先仅提交 `chatId` 和 `name`,详情的 `type` 为 `null` 时提示先保存内容再发布。只有启用的频道可以设为默认。停用或删除默认频道会清除默认状态,房间通知暂停,直到重新设定。删除频道配置后,Telegram 上已发布的帖文仍可查看。
+
+## 发布与置顶
+
+保存只更新后台内容。点击发布时提交 `id`,`isTop` 省略时默认为 `false`。请求期间禁用发布按钮。机器人需要有频道发帖权限,置顶还需要相应权限。
+
+```json
+{
+  "code": 0,
+  "data": {
+    "sent": true,
+    "pinned": false,
+    "message_id": 37,
+    "error": "消息已发布,置顶未成功"
+  }
+}
+```
+
+- `sent=true` 表示已发帖,`message_id` 是帖子 ID。若 `pinned=false`,提示检查置顶状态,避免重复发布。`pinned=null` 表示未请求置顶。
+- `sent=false` 表示 Telegram 拒绝发布,展示 `error`。
+- `sent=null` 表示发布结果未知,先检查频道再决定是否重试。发布请求超时也按结果未知处理。
+
+旧 `GET /admin/config/get?field=channel_message` 和 `getAll` 只显示当前默认频道。多频道页面使用列表和详情接口。

+ 19 - 1
routes/admin.php

@@ -14,6 +14,8 @@ use App\Http\Controllers\admin\Room;
 use App\Http\Controllers\admin\User;
 use App\Http\Controllers\admin\Balance;
 use App\Http\Controllers\admin\Config;
+use App\Http\Controllers\admin\TelegramChannel;
+use App\Services\TelegramChannelService;
 use App\Http\Controllers\admin\Sync;
 use App\Http\Controllers\admin\Menu;
 use App\Http\Controllers\admin\Role;
@@ -338,13 +340,24 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/get', [Config::class, 'get']);
             Route::post('/set', [Config::class, 'set']);
             Route::post('/sendChannelMessage', [Config::class, 'sendChannelMessage']);
-            // Route::post('/sendChannelVideo', [Config::class, 'sendChannelVideo']);
             Route::get('/', [Config::class, 'index']);
             Route::post('/submit', [Config::class, 'store']); // 
             Route::post('/delete', [Config::class, 'destroy']); // 删除
             Route::post('/setField', [Config::class, 'setField']);
         });
 
+        Route::prefix('/telegramChannel')->group(function () {
+            Route::get('/list', [TelegramChannel::class, 'index'])->defaults('require_active_button', true);
+            Route::get('/detail', [TelegramChannel::class, 'detail'])
+                ->defaults('permission_uri', TelegramChannelService::VIEW)->defaults('require_active_button', true);
+            Route::post('/save', [TelegramChannel::class, 'save'])->defaults('require_active_button', true);
+            Route::post('/setDefault', [TelegramChannel::class, 'setDefault'])
+                ->defaults('permission_uri', TelegramChannelService::MANAGE)->defaults('require_active_button', true);
+            Route::post('/delete', [TelegramChannel::class, 'delete'])
+                ->defaults('permission_uri', TelegramChannelService::MANAGE)->defaults('require_active_button', true);
+            Route::post('/publish', [TelegramChannel::class, 'publish'])->defaults('require_active_button', true);
+        });
+
         Route::prefix('/balance')->group(function () {
             Route::get('/log', [Balance::class, 'log']);
         });
@@ -613,6 +626,7 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/bets/sport/settle', [\App\Http\Controllers\admin\Simulation::class, 'sportSettle']);
             Route::post('/bets/sport/cancel', [\App\Http\Controllers\admin\Simulation::class, 'sportCancel']);
             Route::get('/bets/third', [\App\Http\Controllers\admin\Simulation::class, 'thirdBets'])->defaults('permission_uri', 'admin/simulation/bets');
+            Route::post('/bets/third', [\App\Http\Controllers\admin\Simulation::class, 'thirdBet'])->defaults('permission_uri', 'admin/simulation/bets/third');
             Route::get('/tasks/templates', [\App\Http\Controllers\admin\Simulation::class, 'templates'])->defaults('permission_uri', 'admin/simulation/tasks');
             Route::post('/tasks/templates', [\App\Http\Controllers\admin\Simulation::class, 'saveTemplate']);
             Route::post('/tasks/templates/delete', [\App\Http\Controllers\admin\Simulation::class, 'deleteTemplate']);
@@ -626,6 +640,10 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/reports/home', [\App\Http\Controllers\admin\Simulation::class, 'reportHome'])->defaults('permission_uri', 'admin/simulation/reports');
             Route::get('/reports/games', [\App\Http\Controllers\admin\Simulation::class, 'reportGames'])->defaults('permission_uri', 'admin/simulation/reports');
             Route::get('/reports/fund-flow', [\App\Http\Controllers\admin\Simulation::class, 'reportFundFlow'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::get('/reports/options', [\App\Http\Controllers\admin\Simulation::class, 'reportOptions'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::get('/reports/details', [\App\Http\Controllers\admin\Simulation::class, 'reportDetails'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::get('/reports/orders', [\App\Http\Controllers\admin\Simulation::class, 'reportOrders'])->defaults('permission_uri', 'admin/simulation/reports');
+            Route::get('/reports/events', [\App\Http\Controllers\admin\Simulation::class, 'reportEvents'])->defaults('permission_uri', 'admin/simulation/reports');
             Route::get('/reports/registrations', [\App\Http\Controllers\admin\Simulation::class, 'reportRegistrations'])->defaults('permission_uri', 'admin/simulation/reports');
             Route::get('/reports/domains', [\App\Http\Controllers\admin\Simulation::class, 'reportDomains'])->defaults('permission_uri', 'admin/simulation/reports');
             Route::post('/reports/refresh', [\App\Http\Controllers\admin\Simulation::class, 'reportRefresh']);

+ 43 - 3
tests/Integration/SimulationTest.php

@@ -101,7 +101,7 @@ class SimulationTest extends TestCase
         Schema::create('payment_orders', function (Blueprint $t) {
             $t->id(); $t->integer('type'); $t->string('order_no')->nullable(); $t->string('member_id'); $t->decimal('amount', 24, 10);
             $t->string('channel')->nullable(); $t->string('bank_name')->nullable(); $t->string('account')->nullable(); $t->string('card_no')->nullable();
-            $t->integer('status'); $t->string('state')->nullable(); $t->text('callback_data')->nullable(); $t->string('remark')->nullable();
+            $t->integer('status'); $t->integer('state')->nullable(); $t->text('callback_data')->nullable(); $t->string('remark')->nullable();
             $t->decimal('fee', 24, 8)->default(0); $t->decimal('fee_rate', 12, 6)->default(0); $t->decimal('fee_fixed', 12, 4)->default(0);
             $t->decimal('frozen_amount', 24, 10)->default(0); $t->text('recharge_bonus_snapshot')->nullable(); $t->timestamps();
         });
@@ -139,6 +139,14 @@ class SimulationTest extends TestCase
             $t->string('odd_value')->nullable(); $t->string('remark')->nullable(); $t->integer('create_time')->default(0); $t->integer('update_time')->default(0);
             $t->integer('settlement_time')->nullable();
         });
+        Schema::create('third_game_orders', function (Blueprint $t) {
+            $t->id(); $t->string('order_key', 64)->unique(); $t->unsignedBigInteger('user_id')->nullable(); $t->string('member_id')->nullable();
+            $t->string('username')->default(''); $t->string('first_name')->default(''); $t->string('player_id'); $t->string('platform');
+            $t->string('currency')->default(''); $t->integer('game_type'); $t->string('game_name')->default(''); $t->string('round_no')->default('');
+            $t->string('table_no')->default(''); $t->string('seat')->default(''); $t->decimal('bet_amount', 30, 10)->default(0);
+            $t->decimal('valid_amount', 30, 10)->default(0); $t->decimal('settled_amount', 30, 10)->default(0); $t->text('bet_content')->nullable();
+            $t->integer('status'); $t->string('game_order_id'); $t->dateTime('bet_time')->nullable(); $t->dateTime('last_update_time')->nullable(); $t->timestamps();
+        });
         Schema::create('user_login', function (Blueprint $t) {
             $t->id(); $t->string('user_id')->nullable(); $t->string('login_account')->default(''); $t->string('login_ip')->default('');
             $t->string('login_domain')->default(''); $t->string('platform')->default(''); $t->integer('status')->default(1);
@@ -185,6 +193,7 @@ class SimulationTest extends TestCase
             'list_lottery' => MemberAudience::visibleMembers(DB::table('lhc_order'), 'lhc_order.member_id')->count(),
             'list_sport' => MemberAudience::visibleMembers(DB::table('sport_order'), 'user_id', 'user_id')->count(),
             'list_logins' => MemberAudience::visibleMembers(DB::table('user_login'), 'user_id', 'user_id')->count(),
+            'list_third' => MemberAudience::visibleMembers(DB::table('third_game_orders'), 'third_game_orders.user_id', 'id')->count(),
         ];
     }
 
@@ -242,7 +251,8 @@ class SimulationTest extends TestCase
         $this->assertSame([1000.0, 0.0], $this->wallet($a));
 
         // 充值:三方成功、公司入款成功、失败的不入账
-        $this->assertSame(2, (int) SimulationFundService::recharge($a, 500, 'third')->status);
+        $paid = SimulationFundService::recharge($a, 500, 'third');
+        $this->assertSame([2, 1], [(int) $paid->status, $paid->state], 'state is an integer column in production');
         $this->assertSame(2, (int) SimulationFundService::recharge($a, 200, 'company')->status);
         $this->assertSame(3, (int) SimulationFundService::recharge($a, 999, 'third', 'fail')->status);
         $this->assertSame(1, (int) SimulationFundService::recharge($a, 50, 'third', 'pending')->status);
@@ -330,8 +340,38 @@ class SimulationTest extends TestCase
 
         $this->assertSame($before, $this->realView(), 'the task run did not leak either');
 
+        // 第三方游戏:只在本地生成已完成的注单,输赢净额进出钱包
+        $balance = $this->wallet($a)[0];
+        $lost = SimulationBetService::third($a, 40, 2, 'lose');
+        $this->assertSame([1, 2, 'SIM', '-40.0000000000', 64], [(int) $lost->status, (int) $lost->game_type, $lost->platform, (string) $lost->settled_amount, strlen($lost->order_key)]);
+        $won = SimulationBetService::third($a, 40, null, 'win');
+        $this->assertGreaterThanOrEqual(20, (float) $won->settled_amount);
+        $this->assertEqualsWithDelta($balance - 40 + (float) $won->settled_amount, $this->wallet($a)[0], 0.001);
+        $this->assertSame(2, DB::table('balance_logs')->where('member_id', $a)->where('change_type', '资产转移')->count());
+        try { SimulationBetService::third('r1', 10); $this->fail('real members are refused'); }
+        catch (\Exception $e) { $this->assertSame('只能操作模拟会员', $e->getMessage()); }
+        $this->assertSame($before, $this->realView());
+
+        // 混合下注:体育、彩票、第三方游戏都会下
+        $mixId = SimulationTaskService::start(['name' => '混合', 'member_ids' => [$a], 'steps' => [['type' => 'bet', 'category' => 'mixed', 'amount' => 1, 'count' => 30, 'interval' => 0]]], 1);
+        for ($i = 0; $i < 80 && in_array(DB::table('simulation_task_runs')->where('id', $mixId)->value('status'), ['queued', 'running'], true); $i++) SimulationRunner::tick();
+        $mix = DB::table('simulation_task_runs')->where('id', $mixId)->first();
+        $this->assertSame(['completed', 30, 0], [$mix->status, (int) $mix->completed, (int) $mix->failed], (string) $mix->last_error);
+        $kinds = DB::table('simulation_task_events')->where('run_id', $mixId)->distinct()->orderBy('ref_type')->pluck('ref_type')->all();
+        $this->assertSame(['lhc_order', 'sport_order', 'third_game_orders'], $kinds);
+        // 彩票没有可下注期号时,混合下注改下其他分类而不是失败
+        DB::table('issues')->update(['status' => 2]);
+        $closedId = SimulationTaskService::start(['name' => '封盘', 'member_ids' => [$a], 'steps' => [['type' => 'bet', 'category' => 'mixed', 'amount' => 1, 'count' => 12, 'interval' => 0]]], 1);
+        for ($i = 0; $i < 40 && in_array(DB::table('simulation_task_runs')->where('id', $closedId)->value('status'), ['queued', 'running'], true); $i++) SimulationRunner::tick();
+        $this->assertSame([12, 0, 0], [(int) DB::table('simulation_task_runs')->where('id', $closedId)->value('completed'), (int) DB::table('simulation_task_runs')->where('id', $closedId)->value('failed'),
+            DB::table('simulation_task_events')->where('run_id', $closedId)->where('ref_type', 'lhc_order')->count()]);
+        DB::table('issues')->update(['status' => 1]);
+        $this->assertSame($before, $this->realView());
+
         // 一键启动:按模拟设置的默认任务参数跑完整流程
-        SimulationSettings::save(['simulation_task_members' => 2, 'simulation_task_recharge' => 500, 'simulation_task_bet_amount' => 50,
+        $this->assertSame('mixed', SimulationSettings::get('simulation_task_bet_category'), 'the default task bets on every category');
+        // 这里用体育:彩票要等真实开奖,测试里不会开奖
+        SimulationSettings::save(['simulation_task_bet_category' => 'sport', 'simulation_task_members' => 2, 'simulation_task_recharge' => 500, 'simulation_task_bet_amount' => 50,
             'simulation_task_bet_count' => 2, 'simulation_task_withdraw' => 100, 'simulation_task_interval' => 0]);
         $quickId = SimulationTaskService::quickStart(1);
         for ($i = 0; $i < 60 && in_array(DB::table('simulation_task_runs')->where('id', $quickId)->value('status'), ['queued', 'running'], true); $i++) SimulationRunner::tick();

+ 340 - 0
tests/Integration/TelegramChannelTest.php

@@ -0,0 +1,340 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Config;
+use App\Http\Controllers\admin\TelegramChannel;
+use App\Http\Middleware\CheckButtonPermission;
+use App\Services\ChannelService;
+use App\Services\TelegramChannelService;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+use GuzzleHttp\Psr7\Response;
+use Illuminate\Config\Repository;
+use Illuminate\Contracts\Routing\ResponseFactory;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Filesystem\FilesystemServiceProvider;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Route;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use PHPUnit\Framework\TestCase;
+use Telegram\Bot\Api;
+use Telegram\Bot\HttpClients\GuzzleHttpClient;
+use Tests\Support\JsonResponseFactory;
+
+class TelegramChannelTest extends TestCase
+{
+    private Application $app;
+    private TelegramChannelService $service;
+    private MockHandler $telegram;
+    private array $requests = [];
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository([
+            'app' => ['locale' => 'zh'],
+            'filesystems' => ['disks' => ['public' => [
+                'driver' => 'local', 'root' => sys_get_temp_dir(), 'url' => 'https://admin.example.com/storage',
+            ]]],
+        ]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        $translator = new Translator(new ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app));
+        $this->app->instance(ResponseFactory::class, new JsonResponseFactory());
+        Request::macro('validate', function (array $rules) {
+            return app('validator')->make($this->all(), $rules)->validate();
+        });
+
+        Schema::create('config', function (Blueprint $table) {
+            $table->id();
+            $table->string('field')->unique();
+            $table->text('val');
+            $table->integer('group_id')->default(2);
+        });
+        $this->telegram = new MockHandler();
+        $stack = HandlerStack::create($this->telegram);
+        $stack->push(Middleware::history($this->requests));
+        $this->app->instance(Api::class, new Api('1:fixture', false, new GuzzleHttpClient(new Client(['handler' => $stack]))));
+        $this->service = new TelegramChannelService();
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function migrate(?array $legacy = null): void
+    {
+        if ($legacy !== null) {
+            DB::table('config')->insert(['field' => 'channel_message', 'val' => json_encode($legacy)]);
+        }
+        (require database_path('migrations/2026_11_13_150000_create_telegram_channels.php'))->up();
+    }
+
+    private function request(array $data = [], string $method = 'POST', int $userId = 1): Request
+    {
+        $request = TelegramChannelTestRequest::create('/admin/telegramChannel', $method, $data);
+        $request->user = (object) ['id' => $userId];
+        $this->app->instance('request', $request);
+        return $request;
+    }
+
+    private function telegramResult(mixed $result): void
+    {
+        $this->telegram->append(new Response(200, [], json_encode(['ok' => true, 'result' => $result])));
+    }
+
+    private function telegramChannel(int $id, ?string $username = null): void
+    {
+        $this->telegramResult(['id' => $id, 'type' => 'channel', 'username' => $username, 'title' => '频道']);
+    }
+
+    private function message(string $chatId, string $text): array
+    {
+        return [
+            'chatId' => $chatId, 'type' => 'text', 'text' => $text,
+            'button' => [[['text' => '网站', 'url' => 'https://example.com']]],
+        ];
+    }
+
+    public function test_migrates_old_image_message_and_does_not_overwrite_it_when_saving_another_channel(): void
+    {
+        $legacy = [
+            'chatId' => 'OriginalChannel', 'image' => 'https://example.com/menu.jpg', 'text' => '旧文案',
+            'button' => [[['text' => '网站', 'url' => 'https://example.com']]],
+        ];
+        $this->migrate($legacy);
+        $this->assertSame([], $this->requests);
+        $default = $this->service->list()[0];
+        $this->assertTrue($default['is_default']);
+
+        $this->telegramChannel(-1001234567890);
+        $this->request($this->message('-1001234567890', '第二个频道') + ['isSend' => false]);
+        $this->assertSame(0, (new Config())->sendChannelMessage($this->service)->getData(true)['code']);
+        $this->assertCount(1, $this->requests);
+        $this->assertCount(2, $this->service->list());
+
+        $this->request(['field' => 'channel_message'], 'GET');
+        $controller = new Config();
+        $value = $controller->get()->getData(true)['data']['channel_message'];
+        $this->assertSame('originalchannel', $value['chatId']);
+        $this->assertSame('image', $value['type']);
+        $this->assertSame($legacy['button'], $value['button']);
+        $this->assertSame($value, $controller->getAll()->getData(true)['data']['channel_message']);
+        $this->assertSame(json_encode($legacy), DB::table('config')->value('val'));
+    }
+
+    public function test_quarantines_local_debug_url_and_denies_generic_write_without_channel_permission(): void
+    {
+        $legacy = [
+            'chatId' => 'test', 'type' => 'image', 'text' => 'test',
+            'image' => 'http://127.0.0.1/_ignition/execute-solution', 'video' => '',
+        ];
+        $this->migrate($legacy);
+        $this->assertSame([], $this->service->list());
+        $this->assertNull(ChannelService::createRoomNotice('missing'));
+        $this->request(['field' => 'channel_message', 'val' => json_encode($legacy)]);
+        $this->assertNotSame(0, (new Config())->setField()->getData(true)['code']);
+        $this->installPermissions();
+        $this->request(['field' => 'channel_message', 'val' => $this->message('@another', '禁止')], userId: 2);
+        $this->assertSame(101009, (new Config())->setField()->getData(true)['code']);
+        $this->assertSame(json_encode($legacy), DB::table('config')->value('val'));
+        $this->assertSame([], $this->requests);
+    }
+
+    public function test_edits_one_channel_and_routes_room_notices_only_to_the_enabled_default(): void
+    {
+        $this->migrate();
+        Schema::create('rooms', function (Blueprint $table) {
+            $table->string('room_id');
+            $table->string('game_name');
+            $table->integer('rounds');
+            $table->integer('base_score');
+            $table->integer('participants');
+        });
+        DB::table('rooms')->insert(['room_id' => '123', 'game_name' => '游戏', 'rounds' => 3, 'base_score' => 10, 'participants' => 4]);
+
+        $this->telegramChannel(-1001000000001, 'first_channel');
+        $first = $this->service->save($this->message('@first_channel', '一'));
+        $this->telegramChannel(-1001000000002, 'second_channel');
+        $second = $this->service->save(['chatId' => '@second_channel']);
+        $this->assertNull($second['type']);
+        $request = $this->request(['id' => $second['id']]);
+        $this->assertSame(-3, (new TelegramChannel())->publish($request, $this->service)->getData(true)['code']);
+
+        $this->service->setDefault($second['id']);
+        $this->assertSame('-1001000000002', ChannelService::createRoomNotice('123')['chat_id']);
+        $this->service->save($this->message('@second_channel', '二') + ['id' => $second['id'], 'enabled' => false]);
+        $this->assertSame('一', $this->service->detail($first['id'])['text']);
+        $this->assertSame('二', $this->service->detail($second['id'])['text']);
+        $this->assertNull(ChannelService::createRoomNotice('123'));
+
+        $this->service->setDefault($first['id']);
+        $this->service->delete($first['id']);
+        $this->assertNull(ChannelService::createRoomNotice('123'));
+        $this->assertCount(2, $this->requests);
+    }
+
+    public function test_publishes_only_to_selected_channel_and_reports_pin_failure_separately(): void
+    {
+        $this->migrate();
+        $this->telegramChannel(-1001000000001, 'first_channel');
+        $first = $this->service->save($this->message('@first_channel', '一'));
+        $this->service->setDefault($first['id']);
+        $this->telegramChannel(-1001000000002);
+        $second = $this->service->save($this->message('-1001000000002', '二'));
+
+        $this->telegramResult(['message_id' => 37]);
+        $this->telegram->append(new Response(400, [], '{"ok":false,"error_code":400,"description":"pin rejected"}'));
+        $request = $this->request(['id' => $second['id'], 'isTop' => true]);
+        $result = (new TelegramChannel())->publish($request, $this->service)->getData(true);
+
+        $this->assertSame(0, $result['code']);
+        $this->assertSame(['sent' => true, 'pinned' => false, 'message_id' => 37], array_intersect_key($result['data'], array_flip(['sent', 'pinned', 'message_id'])));
+        $this->assertCount(4, $this->requests);
+        parse_str((string) $this->requests[2]['request']->getBody(), $sent);
+        $this->assertSame('-1001000000002', $sent['chat_id']);
+        $this->assertSame('二', $sent['text']);
+        $this->assertSame($second['button'], json_decode($sent['reply_markup'], true)['inline_keyboard']);
+        parse_str((string) $this->requests[3]['request']->getBody(), $pin);
+        $this->assertSame(['chat_id' => '-1001000000002', 'message_id' => '37'], $pin);
+    }
+
+    private function installPermissions(): void
+    {
+        Schema::create('menus', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('parent_id')->default(0);
+            $table->string('title')->default('');
+            $table->string('uri');
+            $table->string('permission_name')->nullable();
+            $table->integer('type');
+            $table->integer('sort')->default(0);
+            $table->integer('status')->default(1);
+            $table->timestamps();
+        });
+        Schema::create('role_menu', function (Blueprint $table) {
+            $table->unsignedBigInteger('menu_id');
+            $table->unsignedBigInteger('role_id');
+            $table->primary(['menu_id', 'role_id']);
+        });
+        Schema::create('roles', function (Blueprint $table) {
+            $table->id();
+        });
+        Schema::create('role_user', function (Blueprint $table) {
+            $table->unsignedBigInteger('user_id');
+            $table->unsignedBigInteger('role_id');
+        });
+        $previous = DB::table('menus')->insertGetId(['uri' => 'admin/config/sendChannelMessage', 'type' => 2]);
+        DB::table('role_menu')->insert(['menu_id' => $previous, 'role_id' => 8]);
+        (require database_path('migrations/2026_11_13_150100_add_telegram_channel_permissions.php'))->up();
+    }
+
+    public function test_old_permission_is_carried_forward_and_channel_ids_are_deduplicated(): void
+    {
+        $this->migrate();
+        $this->installPermissions();
+        $role = DB::table('role_menu')->join('menus', 'menus.id', '=', 'role_menu.menu_id')->where('role_id', 8);
+        $this->assertContains(TelegramChannelService::MANAGE, $role->pluck('uri')->all());
+        $this->assertContains(TelegramChannelService::PUBLISH, $role->pluck('uri')->all());
+        DB::table('role_user')->insert(['user_id' => 2, 'role_id' => 8]);
+
+        $this->telegramChannel(-1001000000001, 'first_channel');
+        $this->request(['field' => 'channel_message', 'val' => $this->message('@first_channel', '允许')], userId: 2);
+        $this->assertSame(0, (new Config())->setField()->getData(true)['code']);
+        $this->telegramChannel(-1001000000001, 'first_channel');
+        $request = $this->request(['chatId' => '-1001000000001']);
+        $this->assertSame(-3, (new TelegramChannel())->save($request, $this->service)->getData(true)['code']);
+        $this->assertCount(1, $this->service->list());
+    }
+
+    public function test_publish_permission_is_independent_from_save_permission(): void
+    {
+        $this->migrate();
+        $this->installPermissions();
+        DB::table('roles')->insert(['id' => 9]);
+        DB::table('role_user')->insert(['user_id' => 2, 'role_id' => 9]);
+        $saveButton = DB::table('menus')->where('uri', TelegramChannelService::MANAGE)->value('id');
+        DB::table('role_menu')->insert(['menu_id' => $saveButton, 'role_id' => 9]);
+
+        $request = $this->request(userId: 2);
+        $middleware = new CheckButtonPermission();
+        $save = (new Route('POST', TelegramChannelService::MANAGE, fn () => null))->defaults('require_active_button', true);
+        $request->setRouteResolver(fn () => $save);
+        $this->assertTrue($middleware->handle($request, fn () => true));
+
+        $publish = (new Route('POST', TelegramChannelService::PUBLISH, fn () => null))->defaults('require_active_button', true);
+        $request->setRouteResolver(fn () => $publish);
+        $this->assertSame(-1, $middleware->handle($request, fn () => true)->getData(true)['code']);
+        $this->assertSame([], $this->requests);
+    }
+
+    public function test_external_media_url_is_passed_to_telegram_without_downloading_it(): void
+    {
+        $this->migrate();
+        $this->app->register(FilesystemServiceProvider::class);
+        $this->telegramChannel(-1001000000001, 'first_channel');
+        $media = 'https://files.example.test/photo.jpg';
+        $channel = $this->service->save(['chatId' => '@first_channel', 'type' => 'image', 'image' => $media]);
+        $this->telegramResult(['message_id' => 18]);
+
+        $this->assertTrue($this->service->publish($channel, false)['sent']);
+        $this->assertStringEndsWith('/sendPhoto', $this->requests[1]['request']->getUri()->getPath());
+        parse_str((string) $this->requests[1]['request']->getBody(), $sent);
+        $this->assertSame($media, $sent['photo']);
+    }
+    public function test_admin_upload_is_sent_as_a_file_and_migration_can_be_retried(): void
+    {
+        $this->migrate();
+        $this->app->register(FilesystemServiceProvider::class);
+        $disk = Storage::disk('public');
+        $file = 'videos/' . bin2hex(random_bytes(16)) . '.mp4';
+        $disk->put($file, 'test video');
+        try {
+            $this->telegramChannel(-1001000000001, 'first_channel');
+            $channel = $this->service->save([
+                'chatId' => '@first_channel', 'type' => 'video', 'video' => $disk->url($file),
+            ]);
+            $this->service->setDefault($channel['id']);
+            (require database_path('migrations/2026_11_13_150000_create_telegram_channels.php'))->up();
+            $this->assertTrue($this->service->detail($channel['id'])['is_default']);
+            $this->telegram->append(function ($request) {
+                $this->assertStringEndsWith('/sendVideo', $request->getUri()->getPath());
+                $this->assertStringStartsWith('multipart/form-data;', $request->getHeaderLine('Content-Type'));
+                $this->assertStringContainsString('test video', (string) $request->getBody());
+
+                return new Response(200, [], '{"ok":true,"result":{"message_id":18}}');
+            });
+            $this->assertTrue($this->service->publish($channel, false)['sent']);
+        } finally {
+            $disk->delete($file);
+        }
+    }
+
+}
+
+class TelegramChannelTestRequest extends Request
+{
+    public object $user;
+}