doge 2 дней назад
Родитель
Сommit
9e113068d6

+ 1 - 11
app/Http/Controllers/admin/Config.php

@@ -148,17 +148,7 @@ class Config extends Controller
     private function sortedList(string $field): JsonResponse
     {
         try {
-            $list = ConfigModel::where('group_id', 5)->where('field', $field)->value('val');
-            $list = $list ? json_decode($list, true) : [];
-            $data = [];
-            $i = count($list);
-            foreach($list as $v) {
-                $i--;
-                $temp_key = ($v['sort'] ?? 0)*10+$i;
-                $data[$temp_key] = $v;
-            }
-            krsort($data);
-            $data = array_values($data);
+            $data = ConfigService::sortedList($field);
         } catch (Exception $e) {
             return $this->error(intval($e->getCode()));
         }

+ 27 - 29
app/Http/Controllers/admin/GameplayRule.php

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\admin;
 use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Services\GameplayRuleService;
+use App\Services\LotteryOdds;
 use Exception;
 
 use Illuminate\Support\Facades\DB;
@@ -70,28 +71,18 @@ class GameplayRule extends Controller
      */
     public function store()
     {
-        // try {
-            $params = request()->all();
-
-            $validator = [
-                'keywords' => 'required|string|max:50',
-                'groups'    => 'required|string|max:50',
-                'maxinum'  => 'required|integer|min:1',
-                'mininum'  => 'required|integer|min:0',
-                'odds'     => 'required|numeric|min:0',
-            ];
-
-            request()->validate($validator);
-
-            $ret = GameplayRuleService::submit($params);
-            if ($ret['code'] == GameplayRuleService::NOT) {
-                return $this->error($ret['code'], $ret['msg']);
-            }
-        // } catch (ValidationException $e) {
-        //     return $this->error(HttpStatus::VALIDATION_FAILED, '', $e->errors());
-        // } catch (Exception $e) {
-        //     return $this->error(intval($e->getCode()));
-        // }
+        $params = request()->validate([
+            'id' => 'nullable|integer|min:1',
+            'keywords' => 'required|string|max:50',
+            'groups' => 'required|string|max:50',
+            'maxinum' => 'required_without:id|integer|gte:mininum',
+            'mininum' => 'required_without:id|integer|min:0',
+            'odds' => ['required_without:id', 'regex:/^(?=.{1,10}$)\d{1,8}(\.\d{1,2})?$/'],
+        ]);
+        $ret = GameplayRuleService::submit($params);
+        if ($ret['code'] == GameplayRuleService::NOT) {
+            return $this->error($ret['code'], $ret['msg']);
+        }
         return $this->success([], $ret['msg']);
 
     }
@@ -108,14 +99,21 @@ class GameplayRule extends Controller
      */
     public function destroy()
     {
-        $id = request()->post('id');
-        // 示例:通过 ID 删除菜单
-        $info = GameplayRuleService::findOne(['id' => $id]);
-        if (!$info) {
+        ['id' => $id] = request()->validate(['id' => 'required|integer|min:1']);
+        $keywords = DB::transaction(function () use ($id) {
+            $templates = LotteryOdds::lockPc28Templates();
+            $info = GameplayRuleService::model()::whereKey($id)->lockForUpdate()->first();
+            if (!$info) {
+                return null;
+            }
+            LotteryOdds::pc28CatalogChanged($templates, $info->id, deleted: true);
+            $info->delete();
+            return $info->keywords;
+        }, 3);
+        if ($keywords === null) {
             return $this->error(0, '玩法不存在');
         }
-        GameplayRuleService::clearCache($info->keywords);
-        $info->delete();
+        GameplayRuleService::clearCache($keywords);
 
         return $this->success([], '删除成功');
     }
@@ -127,4 +125,4 @@ class GameplayRule extends Controller
         return $this->success($result);
     }
 
-}
+}

+ 73 - 39
app/Http/Controllers/admin/JisuGame.php

@@ -2,63 +2,97 @@
 
 namespace App\Http\Controllers\admin;
 
+use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Models\JisuGameplay;
-use Exception;
-use App\Constants\HttpStatus;
+use App\Services\LotteryOdds;
+use DomainException;
 
 class JisuGame extends Controller
 {
-    /**
-     * 足球游戏列表,树形结构
-     */
     public function list()
     {
+        $input = request()->validate($this->scopeRules());
         try {
-            $params = request()->validate([
-                'type' => ['required','integer'],
-            ]);
-            $list = JisuGameplay::getGame($params['type']);
-        } catch (Exception $e) {
-            return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
+            $result = LotteryOdds::list($this->key($input), $input['odds_template_id'] ?? null);
+            if (!isset($input['game'])) {
+                // The old Jisu screen expects gameplay at the parent and type at the leaf.
+                $result['data'] = array_map(function ($node) use ($input) {
+                    unset($node['name']);
+                    foreach ($node['children'] as &$item) {
+                        $item['type'] = (int) $input['type'];
+                    }
+                    return $node;
+                }, $result['data']);
+            }
+            return $this->success($result);
+        } catch (DomainException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
         }
-        return $this->success([ 'data' => $list]);
+    }
 
+    public function saveTemplate()
+    {
+        $input = request()->validate(array_merge($this->scopeRules(), [
+            'game' => ['required', 'in:jisu,lhc,canada28,jisu28'],
+            'names' => ['required_without:odds_template_id', 'array:zh,en,vi'],
+            'names.zh' => ['required_with:names', 'string', 'max:50'],
+            'names.en' => ['nullable', 'string', 'max:50'],
+            'names.vi' => ['nullable', 'string', 'max:50'],
+            'enabled' => ['sometimes', 'boolean'],
+            'make_default' => ['sometimes', 'boolean'],
+            'sort' => ['sometimes', 'integer', 'min:0', 'max:2147483647'],
+        ]));
+        try {
+            return $this->success(LotteryOdds::saveTemplate($this->key($input), $input));
+        } catch (DomainException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
     }
 
-    //设置赔率
     public function setOdds()
     {
+        $pc28 = in_array(request()->input('game'), ['canada28', 'jisu28'], true);
+        $input = request()->validate(array_merge($this->scopeRules(), [
+            'odds_template_id' => ['required_with:game', 'nullable', 'integer', 'min:1'],
+            'id' => ['nullable', 'integer', 'min:1'],
+            'group_game' => ['nullable', 'string', 'min:1', 'max:100'],
+            'gameplay' => ['nullable', 'string', 'min:1', 'max:100'],
+            'odds' => ['required', 'regex:' . ($pc28 ? '/^\d{1,8}(\.\d{1,2})?$/' : '/^\d+(\.\d+)?$/')],
+            'mininum' => ['required', 'integer', 'min:0'],
+            'maxinum' => ['required', 'integer', 'gte:mininum'],
+        ]));
         try {
-            $params = request()->validate([
-                'id' => ['nullable','integer'],
-                'gameplay' => ['nullable','string'],
-                'type' => ['nullable','integer'],
-                'odds' => ['required','numeric'],
-                'maxinum' => ['required','numeric'],
-                'mininum' => ['required','numeric'],
-            ]);
-            if (!empty($params['id'])) {
-                $info = JisuGameplay::where('id', $params['id'])->first();
-                if (!$info) throw new Exception('数据不存在');
-                $info->odds = $params['odds'];
-                $info->maxinum = $params['maxinum'];
-                $info->mininum = $params['mininum'];
-                $info->save();
-            } elseif (!empty($params['gameplay']) && !empty($params['type'])) {
-                JisuGameplay::where('gameplay', $params['gameplay'])->where('type', $params['type'])->update([
-                    'odds' => $params['odds'],
-                    'maxinum' => $params['maxinum'],
-                    'mininum' => $params['mininum'],
-                ]);
-            } else {
-                throw new Exception('参数错误');
+            if (strlen((string) $input['odds']) > 10) {
+                throw new DomainException('赔率长度超过限制');
             }
+            if (!isset($input['id'])) {
+                $groupField = ($input['game'] ?? '') === 'lhc' ? 'group_game' : 'gameplay';
+                if (!isset($input[$groupField])) {
+                    throw new DomainException('请选择投注项或玩法分组');
+                }
+            }
+            if (!isset($input['game']) && !isset($input['type']) && isset($input['id'])) {
+                $input['type'] ??= JisuGameplay::whereKey($input['id'])->value('type');
+            }
+            LotteryOdds::setOdds($this->key($input), $input['odds_template_id'] ?? null, $input);
             return $this->success();
-        } catch (Exception $e) {
-            return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
+        } catch (DomainException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
         }
     }
 
+    private function scopeRules(): array
+    {
+        return [
+            'game' => ['nullable', 'in:jisu,lhc,canada28,jisu28'],
+            'type' => ['nullable', 'integer', 'min:1'],
+            'odds_template_id' => ['nullable', 'integer', 'min:1'],
+        ];
+    }
 
-}
+    private function key(array $input): string
+    {
+        return LotteryOdds::gameKey($input['game'] ?? 'jisu', isset($input['type']) ? (int) $input['type'] : null);
+    }
+}

+ 2 - 33
app/Http/Controllers/admin/LhcNumber.php

@@ -52,38 +52,7 @@ class LhcNumber extends Controller
     //设置赔率
     public function setOdds()
     {
-        try {
-            $params = request()->validate([
-                'id' => ['nullable','integer'],
-                'game' => ['nullable','string'],
-                'gameplay' => ['nullable','string'],
-                'odds' => ['required','numeric'],
-                'maxinum' => ['required','numeric'],
-                'mininum' => ['required','numeric'],
-            ]);
-            $id = $params['id'] ?? 0;
-            if ($id) {
-                $info = LhcNumberModel::where('id', $id)->first();
-                if (!$info) throw new Exception('数据不存在');
-                $info->odds = $params['odds'];
-                $info->maxinum = $params['maxinum'];
-                $info->mininum = $params['mininum'];
-                $info->updated_by = auth()->id();
-                $info->save();
-            } else if (!empty($params['game']) ) {
-                $where[] = ['game', $params['game']];
-                if (!empty($params['gameplay'])) {
-                    $where[] = ['gameplay', $params['gameplay']];
-                }
-                LhcNumberModel::where($where)->update(['odds' => $params['odds'], 'maxinum' => $params['maxinum'], 'mininum' => $params['mininum'], 'updated_by' => auth()->id()]);
-            } else {
-                throw new Exception('参数错误');
-            }
-            
-            return $this->success();
-        } catch (Exception $e) {
-            return $this->error(HttpStatus::CUSTOM_ERROR,$e->getMessage());
-        }
+        return $this->error(HttpStatus::CUSTOM_ERROR, '请前往赔率管理选择具体六合彩彩种和模板');
     }
 
-}
+}

+ 44 - 32
app/Http/Middleware/CheckButtonPermission.php

@@ -2,13 +2,13 @@
 
 namespace App\Http\Middleware;
 
-use App\Models\RoleUser;
+use App\Models\Menu;
+use App\Services\MenuService;
+use App\Services\RoleService;
+use App\Services\RoleUserService;
 use Closure;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\DB;
-use App\Services\RoleUserService;
-use App\Services\RoleService;
-use App\Services\MenuService;
 
 class CheckButtonPermission
 {
@@ -21,39 +21,51 @@ class CheckButtonPermission
         }
         $roleUserList = RoleUserService::findAll(['user_id' => $userId]);
         $roleIds = collect($roleUserList)->pluck('role_id')->toArray();
-        if(empty($roleIds)){
-            $roleIds = [-1];
-        }
 
-        // 当前访问的路由
-        $currentUri = $request->route()->defaults['permission_uri'] ?? ltrim($request->route()->uri(), '/');
-        
-        $menuInfo = MenuService::findOne(['uri' => $currentUri, 'type' => MenuService::model()::TYPE_BUTTON ,'status' => MenuService::model()::STATUS_SHOW]);
-        if(empty($menuInfo)){
-            return $next($request);
+        $route = $request->route();
+        $currentUri = $route->defaults['permission_uri'] ?? ltrim($route->uri(), '/');
+        $lotteryPermission = $route->defaults['lottery_odds_permission'] ?? null;
+        $requiresActiveButton = $lotteryPermission === 'write' || ($route->defaults['require_active_button'] ?? false);
+        if ($lotteryPermission !== null) {
+            $currentUri = match ($request->input('game')) {
+                'lhc' => $lotteryPermission === 'read' ? 'admin/lhcNumber/list' : 'admin/lhcNumber/setOdds',
+                'canada28', 'jisu28' => $lotteryPermission === 'read' ? 'admin/gameplayRule' : 'admin/gameplayRule/submit',
+                'jisu', null => $lotteryPermission === 'read' ? 'admin/jisuGame/list' : 'admin/jisuGame/setOdds',
+                default => $lotteryPermission === 'read' ? 'admin/jisuGame/list' : null,
+            };
+        }
+        if ($currentUri === null) {
+            return $this->denied();
         }
-        
-        $roles = RoleService::model()::with(['menus'=> function ($query) {
-            $query->where('type', MenuService::model()::TYPE_BUTTON);
-        }])->whereIn('id',$roleIds)->get();
-        $allMenusUris = [];
+        if (!$requiresActiveButton) {
+            $menu = MenuService::findOne(['uri' => $currentUri, 'type' => Menu::TYPE_BUTTON, 'status' => Menu::STATUS_SHOW]);
+            if (!$menu) {
+                return $next($request);
+            }
+        }
+
+        $roles = RoleService::model()::with(['menus' => function ($query) use ($requiresActiveButton) {
+            $query->where('type', Menu::TYPE_BUTTON);
+            if ($requiresActiveButton) {
+                $query->where('status', Menu::STATUS_SHOW);
+            }
+        }])->whereIn('id', $roleIds)->get();
 
         foreach ($roles as $role) {
-            if (!empty($role['menus_uris'])) {
-                $allMenusUris = array_merge($allMenusUris, $role['menus_uris']);
+            if (in_array($currentUri, $role->menus_uris, $requiresActiveButton)) {
+                return $next($request);
             }
         }
-        
-        if(in_array($currentUri,$allMenusUris)){
-            return $next($request);
-        }else{
-            return response()->json([
-                'code' => -1,
-                'timestamp' => time(),
-                'msg' => '无权限访问',
-                'data' => []
-            ]);
-        }
+        return $this->denied();
     }
 
+    private function denied(): JsonResponse
+    {
+        return response()->json([
+            'code' => -1,
+            'timestamp' => time(),
+            'msg' => '无权限访问',
+            'data' => [],
+        ]);
+    }
 }

+ 1 - 1
app/Models/LhcOrder.php

@@ -5,7 +5,7 @@ use App\Models\LhcOrderItem;
 class LhcOrder extends BaseModel
 {
     protected $table = 'lhc_order';
-    protected $fillable = ['issue','ordernum','member_id','game','gameplay','number','odds','amount','lottery_status','win_amount','profit_and_loss','is_faker','remark','type','total_amount','bet_source'];
+    protected $fillable = ['issue','ordernum','member_id','game','gameplay','number','odds','amount','lottery_status','win_amount','profit_and_loss','is_faker','remark','type','total_amount','bet_source','odds_template_id','odds_template_revision'];
     protected $hidden = [];
     
     public $timestamps = true;

+ 4 - 2
app/Services/BackflowService.php

@@ -106,12 +106,14 @@ class BackflowService extends BaseService
         $paginator = $query->select("backflow.*", "users.first_name", "users.account", "users.admin_note as user_admin_note")->orderByDesc('date')->orderBy('status')
             ->paginate($search['limit']);
         // 未结清的记录按最新注单和当前规则实时重算(规则修改后存储值可能过期),与点“发放”时的计算一致
-        $tiers = null;
+        $tiers = $labels = null;
         foreach ($paginator->items() as $item) {
             if (in_array((int) $item->status, Backflow::OPEN_STATUSES, true)) {
                 $tiers ??= RewardRuleService::tiers('backflow');
+                $labels ??= RewardRuleService::categories('backflow');
                 $user = RewardBasisService::user((string) $item->member_id);
-                $live = RewardBasisService::backflowReward($user ? RewardBasisService::backflowBasis($user, (string) $item->date) : [], $tiers);
+                $basis = $user ? RewardBasisService::backflowBasis($user, (string) $item->date) : [];
+                $live = RewardBasisService::backflowReward($basis, $tiers, $labels);
                 $item['amount'] = $live['amount'];
                 $item['loss_amount'] = $live['loss_amount'];
                 $item['need_flow'] = $live['need_flow'];

+ 16 - 8
app/Services/BetService.php

@@ -288,6 +288,8 @@ class BetService extends BaseService
 
         DB::beginTransaction();
         try {
+            $pc28Switch = (int) Config::where('field', 'pc28_switch')->value('val');
+            $template = LotteryOdds::template($pc28Switch === 1 ? 'jisu28' : 'canada28');
             $text = lang('下注期数') . ":{issue_no}\n";
             $text .= lang("下注内容") . "\n";
             $text .= "--------\n";
@@ -298,7 +300,7 @@ class BetService extends BaseService
                 $keywords = $item['rule'];
                 $amount = $item['amount'];
                 $error = $issueNo = "";
-                $b = static::singleNote($memberId, $keywords, $amount, $issueNo, $text, $error, $groupText, $index);
+                $b = static::singleNote($memberId, $keywords, $amount, $issueNo, $text, $error, $groupText, $index, $pc28Switch, $template);
                 if ($b) {
                     $sumAmount += $amount;
                     $text = str_replace("{issue_no}", $issueNo, $text);
@@ -346,17 +348,18 @@ class BetService extends BaseService
         return $msg;
     }
 
-    private static function singleNote($memberId, $keywords, $amount, &$issueNo, &$text, &$errText, &$groupText, $index): bool
+    private static function singleNote($memberId, $keywords, $amount, &$issueNo, &$text, &$errText, &$groupText, $index, int $pc28Switch, object $template): bool
     {
         $errText .= "【{$keywords}{$amount}】\n";
         //客服号
         $serviceAccount = Config::where('field', 'service_customer')->first()->val;
-        $gameplayRuleInfo = GameplayRuleService::getGameplayRules($keywords);
-        if ($gameplayRuleInfo == null) {
+        $price = LotteryOdds::pc28Price($template, $keywords);
+        if ($price === null) {
             $errText .= lang("玩法未配置!") . "\n";
             $errText .= lang("任何疑问都可以联系唯一财务") . ":@{$serviceAccount}";
             return false;
         }
+        $gameplayRuleInfo = (array) $price;
 
         if ($gameplayRuleInfo['odds'] <= 0) {
             $errText .= lang("赔率为0 庄家通吃 禁止投注!") . "\n";
@@ -367,7 +370,6 @@ class BetService extends BaseService
 
         // 期数验证
 
-        $pc28Switch = Config::where('field', 'pc28_switch')->first()->val;
         if ($pc28Switch == 1) {
             $issueInfo = PcIssue::where('status', PcIssue::STATUS_BETTING)->orderBy('id', 'desc')->first();
         } else {
@@ -435,6 +437,8 @@ class BetService extends BaseService
             ->where('member_id', $memberId)
             ->where('issue', $issueInfo->issue_no)
             ->where('lottery_status', LhcOrder::STATUS_STAY)
+            ->where(LotteryOdds::orderFields($template))
+            ->where('odds', $gameplayRuleInfo['odds'])
             ->whereNull('coupon_id')
             ->whereColumn('total_amount', 'amount')
             ->whereIn('ordernum', LhcOrderItem::where('member_id', $memberId)->where('number', $keywords)->select('ordernum'))
@@ -462,7 +466,7 @@ class BetService extends BaseService
                 'odds' => $gameplayRuleInfo['odds'],
                 'amount' => $amount, // 分数
                 'total_amount' => $amount,
-            ]);
+            ] + LotteryOdds::orderFields($template));
 
             $bet_id = $newBet->id;
         }
@@ -548,8 +552,12 @@ class BetService extends BaseService
             $now_date = date('Y-m-d H:i:s', time() + 38); // 提前45秒
             if ($issueInfo['end_time'] > $now_date) {
                 $fake_bet_list = Cache::get('fake_bet_' . $issueInfo->issue_no, []);
-                $gameplayRuleList = GameplayRuleService::model()::where('odds', '>', 0)->get();
-                $gameplayRuleList = $gameplayRuleList->toArray();
+                $key = $pc28Switch == 1 ? 'jisu28' : 'canada28';
+                $gameplayRuleList = DB::transaction(fn () => LotteryOdds::pricedCatalog($key, LotteryOdds::template($key)->id)
+                    ->filter(fn ($rule) => $rule->odds > 0)->values()->map(fn ($rule) => (array) $rule)->all());
+                if ($gameplayRuleList === []) {
+                    return;
+                }
                 $member_id = self::generateRandomNumber(10);
                 $betTimes = rand(1, $betNumber); // 每次下注次数
                 Cache::add("封盘后下注单数_{$issueInfo->issue_no}", rand(3, 5), 4);

+ 9 - 1
app/Services/ConfigService.php

@@ -6,6 +6,7 @@ namespace App\Services;
 use App\Constants\HttpStatus;
 use App\Services\BaseService;
 use App\Models\Config;
+use App\Support\MaintenancePolicy;
 use Exception;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Collection;
@@ -19,6 +20,13 @@ class ConfigService extends BaseService
 {
     public static string $MODEL = Config::class;
 
+    public static function sortedList(string $field): array
+    {
+        $list = MaintenancePolicy::configList(Config::where('group_id', 5)->where('field', $field)->value('val'));
+        usort($list, fn ($left, $right) => ($right['sort'] ?? 0) <=> ($left['sort'] ?? 0));
+        return $list;
+    }
+
 
     /**
      * @description: 模型
@@ -196,4 +204,4 @@ class ConfigService extends BaseService
             Cache::put('pc28_switch', 0);
         }
     }
-}
+}

+ 28 - 28
app/Services/GameplayRuleService.php

@@ -97,37 +97,37 @@ class GameplayRuleService extends BaseService
      */
     public static function submit($params = [])
     {
-        $result = false;
-        $msg['code'] = self::NOT;
-        $msg['msg'] = '';
-
-        // 2. 判断是否是更新
-        if (!empty($params['id'])) {
-            // 更新
-            $info = self::findOne(['id' => $params['id']]);
+        $result = DB::transaction(function () use ($params) {
+            $templates = LotteryOdds::lockPc28Templates();
+            if (empty($params['id'])) {
+                $info = self::model()::create($params);
+                LotteryOdds::pc28CatalogChanged($templates, $info->id, $info->only(['odds', 'mininum', 'maxinum']));
+                return ['code' => self::YES, 'msg' => '设置成功'];
+            }
+            $info = self::model()::whereKey($params['id'])->lockForUpdate()->first();
             if (!$info) {
-                $msg['msg'] = '规则不存在!';
-            } else {
-                $result = $info->update($params);
-                $id = $params['id'];
+                return ['code' => self::NOT, 'msg' => '规则不存在!'];
             }
-
-        } else {
-            // 创建
-            $result = $info = self::model()::create($params);
-            $id = $result->id;
-        }
-
-
-        if ($result) {
+            foreach (['odds', 'mininum', 'maxinum'] as $field) {
+                if (isset($params[$field]) && $params[$field] != $info->$field) {
+                    return ['code' => self::NOT, 'msg' => '请前往赔率管理选择彩种和模板'];
+                }
+            }
+            $oldKeywords = $info->keywords;
+            $info->fill(array_intersect_key($params, ['keywords' => true, 'groups' => true]));
+            if ($info->isDirty()) {
+                LotteryOdds::pc28CatalogChanged($templates, $info->id);
+                $info->save();
+            }
+            return ['code' => self::YES, 'msg' => '设置成功', 'old_keywords' => $oldKeywords];
+        }, 3);
+        if ($result['code'] === self::YES) {
             self::clearCache($params['keywords']);
-            $msg['code'] = self::YES;
-            $msg['msg'] = '设置成功';
-        } else {
-            $msg['msg'] = empty($msg['msg']) ? '操作失败' : $msg['msg'];
+            if (isset($result['old_keywords'])) {
+                self::clearCache($result['old_keywords']);
+            }
         }
-
-        return $msg;
+        return $result;
     }
 
     /**
@@ -206,4 +206,4 @@ class GameplayRuleService extends BaseService
 
         return null;
     }
-}
+}

+ 242 - 0
app/Services/LotteryOdds.php

@@ -0,0 +1,242 @@
+<?php
+
+namespace App\Services;
+
+use DomainException;
+use Illuminate\Database\Query\Builder;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
+final class LotteryOdds
+{
+    public static function gameKeys(): array
+    {
+        return [
+            'canada28', 'jisu28', 'lhc:1', 'lhc:2', 'lhc:3', 'lhc:4',
+            'jisu:1', 'jisu:2', 'jisu:3', 'jisu:4', 'jisu:5', 'jisu:6', 'jisu:7',
+        ];
+    }
+
+    public static function gameKey(string $game, ?int $type): string
+    {
+        $key = $type === null ? $game : "$game:$type";
+        if (!in_array($key, self::gameKeys(), true)) {
+            throw new DomainException('彩种无效');
+        }
+        return $key;
+    }
+
+    public static function catalog(string $key): Builder
+    {
+        [$game, $type] = array_pad(explode(':', $key, 2), 2, null);
+        return match ($game) {
+            'jisu' => DB::table('jisu_gameplay')->where('type', (int) $type),
+            'lhc' => DB::table('lhc_number'),
+            'canada28', 'jisu28' => DB::table('gameplay_rules'),
+        };
+    }
+
+    private static function templates(string $key): Builder
+    {
+        return DB::table('lottery_odds_templates')->where('game_key', $key);
+    }
+
+    public static function template(string $key, ?int $id = null, bool $write = false): object
+    {
+        $query = self::templates($key);
+        $id === null ? $query->where('is_default', 1) : $query->where('id', $id);
+        $row = ($write ? $query->lockForUpdate() : $query->sharedLock())->first();
+        return $row ?? throw new DomainException('赔率模板不存在');
+    }
+
+    public static function items(int $templateId): Builder
+    {
+        return DB::table('lottery_odds_items')->where('template_id', $templateId);
+    }
+
+    private static function configuredItems(int $templateId): Builder
+    {
+        return self::items($templateId)->whereRaw("TRIM(odds) <> ''")
+            ->whereNotNull('mininum')->whereNotNull('maxinum');
+    }
+
+    private static function publicTemplate(object $row): array
+    {
+        return [
+            'id' => (int) $row->id, 'names' => json_decode($row->names, true),
+            'is_default' => (bool) $row->is_default, 'enabled' => (bool) $row->enabled,
+            'sort' => (int) $row->sort, 'revision' => (int) $row->revision,
+        ];
+    }
+
+    public static function list(string $key, ?int $id): array
+    {
+        return DB::transaction(function () use ($key, $id) {
+            $template = self::template($key, $id);
+            return [
+                'data' => self::tree($key, self::pricedCatalog($key, (int) $template->id, includeUnconfigured: true)),
+                'odds_template_id' => (int) $template->id,
+                'odds_templates' => self::templates($key)->orderByDesc('sort')->orderByDesc('is_default')->orderBy('id')
+                    ->get()->map(self::publicTemplate(...))->all(),
+            ];
+        });
+    }
+
+    public static function pricedCatalog(string $key, int $templateId, bool $includeUnconfigured = false): Collection
+    {
+        $prices = self::configuredItems($templateId)->sharedLock()->get()->keyBy('item_id');
+        return self::catalog($key)->orderBy('id')->get()
+            ->filter(fn ($row) => $includeUnconfigured || $prices->has($row->id))
+            ->map(function ($row) use ($prices) {
+                $price = $prices->get($row->id);
+                $row->odds = $price->odds ?? null;
+                $row->mininum = $price->mininum ?? null;
+                $row->maxinum = $price->maxinum ?? null;
+                return $row;
+            });
+    }
+
+    public static function pc28Price(object $template, string $keywords): ?object
+    {
+        $itemId = self::catalog($template->game_key)->where('keywords', $keywords)->sharedLock()->value('id');
+        return $itemId === null ? null : self::configuredItems($template->id)->where('item_id', $itemId)->sharedLock()->first();
+    }
+
+    private static function tree(string $key, Collection $rows): array
+    {
+        $tree = [];
+        $isLhc = str_starts_with($key, 'lhc:');
+        foreach ($rows as $row) {
+            $group = $row->gameplay ?? $row->groups;
+            $leaf = [
+                'id' => (int) $row->id, 'name' => $row->number ?? $row->keywords,
+                'configured' => $row->odds !== null,
+                'odds' => $row->odds, 'mininum' => $row->mininum, 'maxinum' => $row->maxinum,
+            ];
+            if ($isLhc) {
+                $tree[$row->game] ??= ['name' => $row->game, 'group_game' => $row->game];
+                $tree[$row->game]['children'][$group] ??= ['name' => $group, 'group_game' => $row->game, 'gameplay' => $group];
+                $tree[$row->game]['children'][$group]['children'][] = $leaf;
+            } else {
+                $tree[$group] ??= ['name' => $group, 'gameplay' => $group];
+                $tree[$group]['children'][] = $leaf;
+            }
+        }
+        if ($isLhc) {
+            foreach ($tree as &$parent) {
+                $parent['children'] = array_values($parent['children']);
+            }
+        }
+        return array_values($tree);
+    }
+
+    public static function saveTemplate(string $key, array $input): array
+    {
+        return DB::transaction(function () use ($key, $input) {
+            // The oldest template is a stable lock even when the default changes.
+            self::templates($key)->orderBy('id')->lockForUpdate()->first(['id']);
+            $default = self::template($key, null, true);
+            $makeDefault = (bool) ($input['make_default'] ?? false);
+            if (!empty($input['odds_template_id'])) {
+                $id = (int) $input['odds_template_id'];
+                $row = (int) $default->id === $id ? $default : self::template($key, $id, true);
+                $enabled = (bool) ($input['enabled'] ?? $row->enabled);
+                if (!$enabled && ($row->is_default || $makeDefault)) {
+                    throw new DomainException('默认模板必须启用,请先启用模板或切换默认模板');
+                }
+                $values = ['enabled' => $enabled];
+                if ($enabled !== (bool) $row->enabled) {
+                    $values['revision'] = $row->revision + 1;
+                }
+                if (array_key_exists('names', $input)) {
+                    $values['names'] = self::encodeNames($input['names']);
+                }
+                if (array_key_exists('sort', $input)) {
+                    $values['sort'] = (int) $input['sort'];
+                }
+                if ($makeDefault && !$row->is_default) {
+                    self::templates($key)->where('id', $default->id)->update(['is_default' => null]);
+                    $values['is_default'] = true;
+                }
+                self::templates($key)->where('id', $row->id)->update($values);
+                return self::publicTemplate(self::template($key, $id));
+            }
+
+            if ($makeDefault) {
+                throw new DomainException('请先创建并启用模板');
+            }
+            $id = self::templates($key)->insertGetId([
+                'game_key' => $key,
+                'names' => self::encodeNames($input['names']),
+                'enabled' => false,
+                'sort' => (int) ($input['sort'] ?? 0),
+            ]);
+            $items = self::configuredItems($default->id)->selectRaw('? as new_template_id', [$id])
+                ->addSelect('item_id', 'odds', 'mininum', 'maxinum');
+            DB::table('lottery_odds_items')->insertUsing(['template_id', 'item_id', 'odds', 'mininum', 'maxinum'], $items);
+            return self::publicTemplate(self::template($key, $id));
+        }, 3);
+    }
+
+    private static function encodeNames(array $names): string
+    {
+        $names = array_map(fn ($name) => trim($name ?? ''), $names);
+        if (($names['zh'] ?? '') === '') {
+            throw new DomainException('请输入中文模板名称');
+        }
+        return json_encode(array_filter($names, fn ($name) => $name !== ''), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+    }
+
+    public static function setOdds(string $key, ?int $id, array $input): void
+    {
+        DB::transaction(function () use ($key, $id, $input) {
+            $template = self::template($key, $id, true);
+            $catalog = self::catalog($key);
+            $isLhc = str_starts_with($key, 'lhc:');
+            if (isset($input['id'])) {
+                $catalog->where('id', $input['id']);
+            } else {
+                if ($isLhc && isset($input['group_game'])) {
+                    $catalog->where('game', $input['group_game']);
+                }
+                if (isset($input['gameplay'])) {
+                    $group = in_array($key, ['canada28', 'jisu28'], true) ? 'groups' : 'gameplay';
+                    $catalog->where($group, $input['gameplay']);
+                }
+            }
+            $itemIds = $catalog->orderBy('id')->sharedLock()->pluck('id');
+            if ($itemIds->isEmpty()) {
+                throw new DomainException('投注项不存在');
+            }
+            $prices = ['odds' => $input['odds'], 'mininum' => $input['mininum'], 'maxinum' => $input['maxinum']];
+            foreach ($itemIds->chunk(100) as $ids) {
+                $rows = $ids->map(fn ($itemId) => ['template_id' => $template->id, 'item_id' => $itemId] + $prices)->all();
+                DB::table('lottery_odds_items')->upsert($rows, ['template_id', 'item_id'], array_keys($prices));
+            }
+            self::templates($key)->where('id', $template->id)->increment('revision');
+        }, 3);
+    }
+
+    public static function orderFields(object $template): array
+    {
+        return ['odds_template_id' => (int) $template->id, 'odds_template_revision' => (int) $template->revision];
+    }
+
+    public static function lockPc28Templates(): Collection
+    {
+        return DB::table('lottery_odds_templates')->whereIn('game_key', ['canada28', 'jisu28'])
+            ->orderBy('id')->lockForUpdate()->pluck('id');
+    }
+
+    public static function pc28CatalogChanged(Collection $templates, int $itemId, ?array $initial = null, bool $deleted = false): void
+    {
+        foreach ($templates as $id) {
+            if ($deleted) {
+                self::items($id)->where('item_id', $itemId)->delete();
+            } elseif ($initial !== null) {
+                DB::table('lottery_odds_items')->insert(['template_id' => $id, 'item_id' => $itemId] + $initial);
+            }
+        }
+        DB::table('lottery_odds_templates')->whereIn('id', $templates)->increment('revision');
+    }
+}

+ 6 - 4
app/Services/RewardBaseSyncService.php

@@ -48,13 +48,15 @@ class RewardBaseSyncService
             }
         });
         $users = [];
+        $backflowLabels = null;
         foreach (['rebate' => $days, 'backflow' => $months] as $type => $keys) {
             foreach ($keys as [$userId, $period]) {
                 try {
                     $user = $users[$userId] ??= DB::table('users')->where('id', $userId)->first(['id', 'member_id', 'level', 'username', 'first_name']);
                     if (!$user) continue;
                     if (\App\Support\MemberAudience::isSimulatedMember($user->member_id)) continue; // 模拟会员不产生返水、回水
-                    if ($type === 'rebate' ? $this->rebate($user, $period) : $this->backflow($user, $period)) {
+                    if ($type === 'rebate' ? $this->rebate($user, $period)
+                        : $this->backflow($user, $period, $backflowLabels ??= RewardRuleService::categories('backflow'))) {
                         ++$result[$type === 'rebate' ? 'rebates' : 'backflow'];
                     }
                 } catch (Throwable $e) {
@@ -92,13 +94,13 @@ class RewardBaseSyncService
         }, 3);
     }
 
-    private function backflow(object $user, string $month): bool
+    private function backflow(object $user, string $month, array $labels): bool
     {
-        return DB::transaction(function () use ($user, $month) {
+        return DB::transaction(function () use ($user, $month, $labels) {
             $row = DB::table('backflow')->where('member_id', $user->member_id)->where('date', $month)->lockForUpdate()->first();
             // 部分发放(status 2)的记录继续累计,之后可再发差额
             if ($row && !in_array((int) $row->status, Backflow::OPEN_STATUSES, true)) return false;
-            $reward = RewardBasisService::backflowReward(RewardBasisService::backflowBasis($user, $month));
+            $reward = RewardBasisService::backflowReward(RewardBasisService::backflowBasis($user, $month), labels: $labels);
             if (!$reward['has_facts'] && !$row) return false;
             $values = ['loss_amount' => $reward['loss_amount'], 'amount' => $reward['amount'], 'need_flow' => $reward['need_flow'],
                 'details' => json_encode($reward['details'], JSON_UNESCAPED_UNICODE), 'updated_at' => now()];

+ 70 - 19
app/Services/RewardBasisService.php

@@ -2,8 +2,11 @@
 
 namespace App\Services;
 
+use App\Models\LhcOrder;
 use App\Services\Sharing\EffectiveBet;
 use Carbon\Carbon;
+use Illuminate\Database\Query\Builder;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
 
 /**
@@ -22,17 +25,55 @@ class RewardBasisService
     {
         $start = Carbon::parse($nyDate . ' 00:00:00', 'America/New_York')->setTimezone('Asia/Shanghai');
         $end = $start->copy()->addDay();
-        return self::sum(DB::table('share_bet_facts')->where('user_id', $user->id)
+        $query = self::factsFor($user)
             ->whereBetween('bet_date', [$start->toDateString(), $end->toDateString()])
-            ->where('bet_at', '>=', $start->format('Y-m-d H:i:s'))->where('bet_at', '<', $end->format('Y-m-d H:i:s')));
+            ->where('bet_at', '>=', $start->format('Y-m-d H:i:s'))->where('bet_at', '<', $end->format('Y-m-d H:i:s'));
+        $basis = [];
+        foreach (self::totals($query, ['category']) as $row) $basis[$row->category] = self::amounts($row);
+        return $basis;
     }
 
     /** Category totals for one Asia/Shanghai month (Y-m). */
     public static function backflowBasis(object $user, string $month): array
     {
         $start = Carbon::parse($month . '-01', 'Asia/Shanghai');
-        return self::sum(DB::table('share_bet_facts')->where('user_id', $user->id)
-            ->whereBetween('bet_date', [$start->toDateString(), $start->copy()->endOfMonth()->toDateString()]));
+        // Historical facts identify a child bet. A missing or duplicated parent must not attribute its loss to a game.
+        $orderType = DB::table('lhc_order_item')
+            ->join('lhc_order', 'lhc_order.ordernum', '=', 'lhc_order_item.ordernum')
+            ->whereColumn('lhc_order_item.id', 'share_bet_facts.source_id')
+            ->whereColumn('lhc_order_item.member_id', 'share_bet_facts.member_id');
+        $grammar = $orderType->getGrammar();
+        $type = $grammar->wrap('lhc_order.type');
+        $orderMember = $grammar->wrap('lhc_order.member_id');
+        $itemMember = $grammar->wrap('lhc_order_item.member_id');
+        $orderType->selectRaw("CASE WHEN COUNT(*) = 1 AND MIN($orderMember) = MIN($itemMember)
+            AND MIN($type) BETWEEN 1 AND 13 THEN MIN($type) END");
+
+        $facts = self::factsFor($user)
+            ->whereBetween('bet_date', [$start->toDateString(), $start->copy()->endOfMonth()->toDateString()])
+            ->select('category', 'bet_amount', 'effective_amount')
+            ->selectRaw('CASE WHEN category = ? AND settled = 0 THEN 0 ELSE net_profit END AS net_profit', ['self:lottery'])
+            ->selectRaw("CASE WHEN category = ? AND source = ? THEN ({$orderType->toSql()}) END AS lottery_order_type",
+                ['self:lottery', 'lhc_order_item']);
+
+        $basis = [];
+        foreach (self::totals(DB::query()->fromSub($facts, 'monthly_facts'), ['category', 'lottery_order_type']) as $row) {
+            $amounts = self::amounts($row);
+            $category = $row->category;
+            if ($row->lottery_order_type !== null) {
+                $type = (int) $row->lottery_order_type;
+                // lhc_order uses 1–4 for Mark Six, 5–6 for the two 28 games, and 7–13 for jisu types 1–7.
+                $category = match (true) {
+                    $type <= 4 => 'self:lottery:lhc:' . $type,
+                    $type === 5 => 'self:lottery:pc28',
+                    $type === 6 => 'self:lottery:jisu28',
+                    default => 'self:lottery:jisu:' . ($type - 6),
+                };
+                $amounts['label'] = LhcOrder::getRemark($type);
+            }
+            $basis[$category] = $amounts;
+        }
+        return $basis;
     }
 
     /** Highest tier of the member's level whose threshold the category's effective bet reaches. */
@@ -57,16 +98,19 @@ class RewardBasisService
     }
 
     /** Highest tier whose loss threshold the category's monthly net loss reaches; the reward is a fixed amount. */
-    public static function backflowReward(array $basis, ?array $tiers = null): array
+    public static function backflowReward(array $basis, ?array $tiers = null, ?array $labels = null): array
     {
         $tiers ??= RewardRuleService::tiers('backflow');
         $result = self::empty($basis) + ['loss_amount' => '0.00'];
         foreach ($basis as $category => $row) {
             $loss = bccomp($row['profit'], '0', 2) < 0 ? bcsub('0', $row['profit'], 2) : '0.00';
-            $detail = self::detail($category, $row) + ['loss_amount' => $loss];
+            $labels ??= RewardRuleService::categories('backflow');
+            $label = $category === 'self:lottery' ? '未识别彩种' : ($labels[$category] ?? $row['label'] ?? $category);
+            $detail = self::detail($category, $row, $label) + ['loss_amount' => $loss];
             if (bccomp($loss, '0', 2) > 0) {
                 $result['loss_amount'] = bcadd($result['loss_amount'], $loss, 2);
-                foreach ($tiers[$category] ?? [] as $tier) {
+                $categoryTiers = $category === 'self:lottery' ? [] : ($tiers[$category] ?? []);
+                foreach ($categoryTiers as $tier) {
                     if (bccomp($loss, $tier['min_loss_amount'], 2) < 0) continue;
                     $detail = self::matched($detail, $tier, $tier['reward_amount']) + ['min_loss_amount' => $tier['min_loss_amount']];
                     break;
@@ -95,18 +139,25 @@ class RewardBasisService
         return self::backflowReward($user ? self::backflowBasis($user, (string) $reward->date) : []);
     }
 
-    private static function sum($query): array
+    private static function factsFor(object $user): Builder
     {
         $currency = strtoupper(trim((string) config('third_game.currency', 'CNY')));
-        $rows = $query->where('voided', 0)->where('currency', $currency)->groupBy('category')
-            ->selectRaw('category, SUM(bet_amount) AS bet_amount, SUM(effective_amount) AS effective_amount, SUM(net_profit) AS profit')
-            ->orderBy('category')->get();
-        $basis = [];
-        foreach ($rows as $row) {
-            $basis[$row->category] = ['bet_amount' => self::money($row->bet_amount),
-                'effective_amount' => self::money($row->effective_amount), 'profit' => self::money($row->profit)];
-        }
-        return $basis;
+        return DB::table('share_bet_facts')->where('user_id', $user->id)->where('voided', 0)->where('currency', $currency);
+    }
+
+    private static function totals(Builder $query, array $groups): Collection
+    {
+        $query->select($groups)
+            ->selectRaw('SUM(bet_amount) AS bet_amount, SUM(effective_amount) AS effective_amount, SUM(net_profit) AS profit')
+            ->groupBy($groups);
+        foreach ($groups as $group) $query->orderBy($group);
+        return $query->get();
+    }
+
+    private static function amounts(object $row): array
+    {
+        return ['bet_amount' => self::money($row->bet_amount), 'effective_amount' => self::money($row->effective_amount),
+            'profit' => self::money($row->profit)];
     }
 
     private static function empty(array $basis): array
@@ -115,9 +166,9 @@ class RewardBasisService
             'amount' => '0.00', 'need_flow' => '0.00', 'details' => [], 'has_facts' => $basis !== []];
     }
 
-    private static function detail(string $category, array $row): array
+    private static function detail(string $category, array $row, ?string $label = null): array
     {
-        return ['category' => $category, 'label' => RewardRuleService::categories()[$category] ?? $category,
+        return ['category' => $category, 'label' => $label ?? RewardRuleService::categories()[$category] ?? $category,
             'bet_amount' => $row['bet_amount'], 'effective_amount' => $row['effective_amount'], 'profit' => $row['profit'],
             'rule_id' => null, 'turnover_multiple' => '0.00', 'amount' => '0.00', 'need_flow' => '0.00'];
     }

+ 7 - 2
app/Services/RewardGrantService.php

@@ -39,6 +39,7 @@ class RewardGrantService
             }
             if ($scheduledCutoff !== null && (string) $reward->date >= $scheduledCutoff) return false;
 
+            $backflowDetails = '';
             if ($type === 'rebate') {
                 $result = RewardBasisService::rebate($reward);
                 $basis = $result['effective_amount'];
@@ -60,7 +61,7 @@ class RewardGrantService
                 $remark = "日期:{$reward->date};亏损:{$result['loss_amount']}";
                 foreach ($result['details'] as $detail) {
                     if ($detail['rule_id'] === null) continue;
-                    $remark .= ";{$detail['label']}亏损{$detail['loss_amount']}奖励{$detail['amount']}";
+                    $backflowDetails .= ";{$detail['label']}亏损{$detail['loss_amount']}奖励{$detail['amount']}";
                 }
             }
             $amount = $result['amount'];
@@ -75,7 +76,11 @@ class RewardGrantService
             if (!$closed && bccomp($pay, '0', 2) <= 0) {
                 throw new RuntimeException("暂无可发放金额(按当前规则应发 {$amount},已发放 " . bcadd((string) $reward->granted_amount, '0', 2) . ')', HttpStatus::CUSTOM_ERROR);
             }
-            $remark .= ";本次发放:{$pay};累计发放:" . bcadd((string) $reward->granted_amount, $pay, 2);
+            $paymentRemark = ";本次发放:{$pay};累计发放:" . bcadd((string) $reward->granted_amount, $pay, 2);
+            if ($type === 'backflow' && mb_strlen($remark . $backflowDetails . $paymentRemark) > 255) {
+                $backflowDetails = ';档位明细见回水记录';
+            }
+            $remark .= $backflowDetails . $paymentRemark;
 
             $credit = null;
             if (bccomp($pay, '0', 2) > 0) {

+ 19 - 5
app/Services/RewardRuleService.php

@@ -3,6 +3,7 @@
 namespace App\Services;
 
 use App\Services\Sharing\ShareDefaults;
+use App\Support\MaintenancePolicy;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\Rule;
 use Illuminate\Validation\ValidationException;
@@ -13,16 +14,27 @@ class RewardRuleService
 {
     private const TABLES = ['rebate' => 'rebate_rules', 'backflow' => 'backflow_rules'];
 
-    public static function categories(): array
+    public static function categories(string $type = 'rebate'): array
     {
-        return ShareDefaults::categories();
+        $categories = ShareDefaults::categories();
+        if ($type === 'backflow') {
+            unset($categories['self:lottery']);
+            foreach (ConfigService::sortedList('game_list') as $item) {
+                if (!is_string($item['game'] ?? null)) continue;
+                $category = MaintenancePolicy::lotterySubcategory($item);
+                if ($category === null || isset($categories[$category])) continue;
+                $label = is_string($item['name'] ?? null) ? trim($item['name']) : '';
+                $categories[$category] = $label === '' ? $category : $label;
+            }
+        }
+        return $categories;
     }
 
     public static function get(string $type): array
     {
         $table = self::table($type);
         $categories = $rules = [];
-        foreach (self::categories() as $value => $label) {
+        foreach (self::categories($type) as $value => $label) {
             $categories[] = ['value' => $value, 'label' => $label];
             $rules[$value] = [];
         }
@@ -47,7 +59,7 @@ class RewardRuleService
         $plain = 'regex:/^\d{1,12}(?:\.\d{1,4})?$/D';
         $money = 'regex:/^\d{1,12}(?:\.\d{1,2})?$/D';
         $amount = ['required', 'numeric', $money, 'min:0'];
-        $rules = ['category' => ['required', 'string', Rule::in(array_keys(self::categories()))],
+        $rules = ['category' => ['required', 'string', Rule::in(array_keys(self::categories($type)))],
             'rules' => ['present', 'array', 'max:200'],
             'rules.*' => ['array'],
             'rules.*.turnover_multiple' => ['required', 'numeric', $money, 'min:0', 'max:999999']];
@@ -96,7 +108,9 @@ class RewardRuleService
     {
         $tiers = [];
         $threshold = $type === 'rebate' ? 'min_effective_amount' : 'min_loss_amount';
-        foreach (DB::table(self::table($type))->orderByDesc($threshold)->get() as $row) {
+        $query = DB::table(self::table($type))->orderByDesc($threshold);
+        if ($type === 'backflow') $query->where('category', '<>', 'self:lottery');
+        foreach ($query->get() as $row) {
             $tiers[$row->category][] = self::present($type, $row);
         }
         return $tiers;

+ 17 - 12
app/Services/Simulation/SimulationBetService.php

@@ -10,8 +10,8 @@ use App\Models\Order;
 use App\Models\PcIssue;
 use App\Models\Wallet;
 use App\Services\BalanceLogService;
-use App\Services\GameplayRuleService;
 use App\Services\IssueService;
+use App\Services\LotteryOdds;
 use App\Support\MemberAudience;
 use Exception;
 use Illuminate\Support\Facades\DB;
@@ -29,8 +29,11 @@ final class SimulationBetService
     /** 当前可下注的 PC28 期号与可用玩法。 */
     public static function options(): array
     {
-        [$type, $issue] = self::currentIssue();
-        $rules = GameplayRuleService::model()::query()->where('odds', '>', 0)->get(['keywords', 'odds', 'mininum', 'maxinum']);
+        [$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)]];
     }
@@ -41,7 +44,7 @@ final class SimulationBetService
         $issue = $switch === 1
             ? PcIssue::where('status', PcIssue::STATUS_BETTING)->orderByDesc('id')->first()
             : IssueService::model()::where('status', IssueService::model()::STATUS_BETTING)->orderByDesc('id')->first();
-        return [$switch === 1 ? 6 : 5, $issue];
+        return $switch === 1 ? [6, 'jisu28', $issue] : [5, 'canada28', $issue];
     }
 
     /** PC28 下注,校验与 BetService::singleNote 一致,但不发任何群消息或私聊。 */
@@ -50,15 +53,16 @@ final class SimulationBetService
         SimulationSettings::assertEnabled();
         $user = SimulationSettings::member($memberId);
         $amount = SimulationSettings::assertAmount($amount);
-        $rule = GameplayRuleService::getGameplayRules($keywords);
-        if (!$rule) throw new Exception('玩法未配置:' . $keywords, HttpStatus::CUSTOM_ERROR);
-        if ($rule['odds'] <= 0) throw new Exception('该玩法赔率为 0,禁止投注', HttpStatus::CUSTOM_ERROR);
-        [$type, $issue] = self::currentIssue();
+        [$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);
-        if ($amount < $rule['mininum']) throw new Exception('低于该玩法最小金额 ' . $rule['mininum'], HttpStatus::CUSTOM_ERROR);
-        if ($amount > $rule['maxinum']) throw new Exception('超过该玩法最大金额 ' . $rule['maxinum'], HttpStatus::CUSTOM_ERROR);
-        return DB::transaction(function () use ($user, $keywords, $amount, $rule, $type, $issue) {
+        return DB::transaction(function () use ($user, $keywords, $amount, $key, $type, $issue) {
+            $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 ($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();
             if (!$wallet) throw new Exception('钱包不存在', HttpStatus::CUSTOM_ERROR);
             $before = (string) $wallet->available_balance;
@@ -66,7 +70,8 @@ final class SimulationBetService
             $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,
-                'member_id' => $user->member_id, 'odds' => $rule['odds'], 'amount' => $amount, 'total_amount' => $amount]);
+                'member_id' => $user->member_id, 'odds' => $rule->odds, 'amount' => $amount, 'total_amount' => $amount]
+                + LotteryOdds::orderFields($template));
             $after = bcsub($before, $amount, 10);
             $wallet->available_balance = $after;
             $wallet->save();

+ 69 - 0
database/migrations/2026_11_11_120000_create_lottery_odds_templates.php

@@ -0,0 +1,69 @@
+<?php
+
+use App\Services\LotteryOdds;
+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('lottery_odds_templates')) {
+            Schema::create('lottery_odds_templates', function (Blueprint $table) {
+                $table->id();
+                $table->string('game_key', 16);
+                $table->json('names');
+                $table->boolean('enabled')->default(false);
+                $table->boolean('is_default')->nullable();
+                $table->unsignedInteger('revision')->default(1);
+                $table->unique(['game_key', 'is_default']);
+            });
+        }
+        if (!Schema::hasTable('lottery_odds_items')) {
+            Schema::create('lottery_odds_items', function (Blueprint $table) {
+                $table->unsignedBigInteger('template_id');
+                $table->unsignedBigInteger('item_id');
+                $table->string('odds', 13)->nullable();
+                $table->bigInteger('mininum')->nullable();
+                $table->bigInteger('maxinum')->nullable();
+                $table->primary(['template_id', 'item_id']);
+            });
+        }
+        DB::transaction(function () {
+            foreach (LotteryOdds::gameKeys() as $key) {
+                if (DB::table('lottery_odds_templates')->where('game_key', $key)->where('is_default', 1)->exists()) {
+                    continue;
+                }
+                $id = DB::table('lottery_odds_templates')->insertGetId([
+                    'game_key' => $key,
+                    'names' => json_encode(['zh' => '默认', 'en' => 'Default', 'vi' => 'Mặc định'], JSON_UNESCAPED_UNICODE),
+                    'enabled' => true,
+                    'is_default' => true,
+                ]);
+                foreach (LotteryOdds::catalog($key)->get(['id', 'odds', 'mininum', 'maxinum'])->chunk(200) as $rows) {
+                    DB::table('lottery_odds_items')->insert($rows->map(fn ($row) => [
+                        'template_id' => $id, 'item_id' => $row->id, 'odds' => $row->odds,
+                        'mininum' => $row->mininum, 'maxinum' => $row->maxinum,
+                    ])->all());
+                }
+            }
+        });
+        if (!Schema::hasColumn('lhc_order', 'odds_template_id')) {
+            Schema::table('lhc_order', function (Blueprint $table) {
+                $table->unsignedBigInteger('odds_template_id')->nullable();
+                $table->unsignedInteger('odds_template_revision')->nullable();
+            });
+        }
+    }
+
+    public function down(): void
+    {
+        Schema::table('lhc_order', function (Blueprint $table) {
+            $table->dropColumn(['odds_template_id', 'odds_template_revision']);
+        });
+        Schema::dropIfExists('lottery_odds_items');
+        Schema::dropIfExists('lottery_odds_templates');
+    }
+};

+ 53 - 0
database/migrations/2026_11_11_150000_register_lottery_odds_write_permissions.php

@@ -0,0 +1,53 @@
+<?php
+
+use App\Models\Menu;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    private const BUTTONS = [
+        'admin/jisuGame/setOdds' => ['jisuGame', '编辑极速彩票赔率', 'lottery-odds.jisu'],
+        'admin/lhcNumber/setOdds' => ['DigitalNumberManage', '编辑六合彩赔率', 'lottery-odds.lhc'],
+        'admin/gameplayRule/submit' => ['playing', '编辑 28 玩法和赔率', 'lottery-odds.pc28'],
+        'admin/gameplayRule/delete' => ['playing', '删除 28 玩法', 'lottery-odds.pc28-delete'],
+    ];
+
+    public function up(): void
+    {
+        if (!Schema::hasTable('menus')) {
+            return;
+        }
+        DB::transaction(function () {
+            foreach (self::BUTTONS as $uri => [$parent, $title, $name]) {
+                $existing = DB::table('menus')->where('uri', $uri)->where('type', Menu::TYPE_BUTTON)->pluck('uri');
+                if ($existing->containsStrict($uri)) {
+                    continue;
+                }
+                $parentId = DB::table('menus')->where('uri', $parent)->where('type', Menu::TYPE_MENU)
+                    ->where('status', Menu::STATUS_SHOW)->value('id') ?? 0;
+                DB::table('menus')->insert([
+                    'parent_id' => $parentId, 'title' => $title, 'uri' => $uri, 'permission_name' => $name,
+                    'status' => Menu::STATUS_SHOW, 'type' => Menu::TYPE_BUTTON,
+                    'created_at' => now(), 'updated_at' => now(),
+                ]);
+            }
+        });
+    }
+
+    public function down(): void
+    {
+        if (!Schema::hasTable('menus')) {
+            return;
+        }
+        DB::transaction(function () {
+            $ids = DB::table('menus')->where('type', Menu::TYPE_BUTTON)->whereIn('uri', array_keys(self::BUTTONS))
+                ->whereIn('permission_name', array_column(self::BUTTONS, 2))->pluck('id');
+            if (Schema::hasTable('role_menu')) {
+                DB::table('role_menu')->whereIn('menu_id', $ids)->delete();
+            }
+            DB::table('menus')->whereIn('id', $ids)->delete();
+        });
+    }
+};

+ 22 - 0
database/migrations/2026_11_11_160000_add_sort_to_lottery_odds_templates.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('lottery_odds_templates', function (Blueprint $table) {
+            $table->unsignedInteger('sort')->default(0);
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('lottery_odds_templates', function (Blueprint $table) {
+            $table->dropColumn('sort');
+        });
+    }
+};

+ 31 - 54
docs/总后台/回水规则配置.md

@@ -1,20 +1,15 @@
-# 回水:按游戏分类配置规则
+# 回水规则配置
 
 本文供后台管理前端对接。接口由 **`bot-28` 后台**提供,使用后台 API 域名和管理员登录态,成功码 **`code=0`**。笔笔返见 `笔笔返规则配置.md`。
 
-## 1. 变更概览
-
-- 系统配置中的「回水比例」「输钱达到限制值」已不再使用,前端不再展示。
-- 回水页面新增规则配置:按游戏分类设置「亏损金额 → 奖励金额」档位。
-
-## 2. 接口与权限
+## 接口与权限
 
 | 功能 | 请求方式 | 地址 | 权限 |
 | --- | --- | --- | --- |
 | 读取规则 | GET | `/admin/backflow/rules` | `admin/backflow/grant` |
-| 保存规则(单个分类) | POST | `/admin/backflow/rules` | `admin/backflow/grant` |
+| 保存一个分类或彩种的规则 | POST | `/admin/backflow/rules` | `admin/backflow/grant` |
 
-## 3. 读取规则
+## 读取规则
 
 ```http
 GET /admin/backflow/rules
@@ -25,51 +20,39 @@ GET /admin/backflow/rules
   "code": 0,
   "data": {
     "type": "backflow",
-    "categories": [{"value": "third:1", "label": "真人"}, "..."],
+    "categories": [
+      {"value": "third:3", "label": "彩票"},
+      {"value": "self:lottery:pc28", "label": "加拿大28"},
+      {"value": "self:lottery:jisu28", "label": "极速28"}
+    ],
     "rules": {
-      "third:1": [
-        {"id": 1, "category": "third:1", "min_loss_amount": "1000.00", "reward_amount": "20.00", "turnover_multiple": "0.00"},
-        {"id": 2, "category": "third:1", "min_loss_amount": "2000.00", "reward_amount": "60.00", "turnover_multiple": "2.00"}
+      "third:3": [],
+      "self:lottery:pc28": [
+        {"id": 1, "category": "self:lottery:pc28", "min_loss_amount": "1000.00", "reward_amount": "20.00", "turnover_multiple": "0.00"}
       ],
-      "third:2": []
+      "self:lottery:jisu28": []
     }
   }
 }
 ```
 
-下拉选项直接用这个接口的返回,不需要另外调用接口:
-
 | 返回字段 | 用途 |
 | --- | --- |
-| `categories` | 游戏分类选项(分类 Tab / 下拉),`value` 用于保存,`label` 用于显示 |
-| `rules` | 已配置的档位,以分类 `value` 为键;每个分类都会返回,没有规则时为空数组 |
+| `categories` | 配置页签选项,`value` 用于保存,`label` 用于显示 |
+| `rules` | 以选项的 `value` 为键,每个选项都有对应数组,未配置时为空数组 |
 
-目前的分类:
+F1 彩票选项沿用 `/admin/config/gameList` 的名称和排序。加拿大 28 用 `self:lottery:pc28`,极速 28 用 `self:lottery:jisu28`,六合彩用 `self:lottery:lhc:<params.type>`,极速系列用 `self:lottery:jisu:<params.type>`。第三方彩票使用 `third:3`。保存接口以 `categories` 中的选项为准,提交旧的 `self:lottery` 会返回校验错误。
 
-| `value` | `label` |
-| --- | --- |
-| `third:1` | 真人 |
-| `third:2` | 电子 |
-| `third:3` | 彩票 |
-| `third:4` | 体育 |
-| `third:5` | 电竞 |
-| `third:6` | 捕鱼 |
-| `third:7` | 棋牌 |
-| `self:sport` | F1体育 |
-| `self:lottery` | F1彩票 |
+## 保存规则
 
-建议页面以分类为 Tab,每个 Tab 内展示该分类的档位,保存时提交当前 Tab 的全部档位。
-
-## 4. 保存规则
-
-一次保存**一个分类的完整列表**,会整体替换该分类原有规则;传空数组即清空该分类。
+一次提交当前分类或彩种的完整档位列表。保存会整体替换该选项的规则,提交 `rules: []` 即清空。
 
 ```http
 POST /admin/backflow/rules
 Content-Type: application/json
 
 {
-  "category": "third:1",
+  "category": "self:lottery:pc28",
   "rules": [
     {"min_loss_amount": 1000, "reward_amount": 20, "turnover_multiple": 0},
     {"min_loss_amount": 2000, "reward_amount": 60, "turnover_multiple": 2}
@@ -79,36 +62,31 @@ Content-Type: application/json
 
 | 字段 | 含义 | 校验 |
 | --- | --- | --- |
-| `category` | 游戏分类,取 `categories[].value` | 必填 |
+| `category` | 游戏分类或彩种,取 `categories[].value` | 必填 |
 | `min_loss_amount` | 亏损金额门槛 | ≥0,最多 2 位小数 |
 | `reward_amount` | 奖励金额(固定金额) | ≥0,最多 2 位小数 |
 | `turnover_multiple` | 需要打码量倍数,`0` 表示没有 | ≥0,最多 2 位小数 |
 
 同一分类的亏损门槛不能重复。返回值与读取接口相同。
 
-## 5. 计算方式
-
-- 每月按游戏分类分别计算,再相加。
-- 每个分类取亏损达到门槛的最高一档,奖励为该档的固定金额。
-- 盈利或未达到最低档的分类不给。
-- 需要打码量 = 奖励 × 倍数,填 0 表示没有。
+## 计算与列表
 
-## 6. 列表新增字段
+以北京自然月为周期。F1 彩票按彩种独立计算净亏,每个彩种命中达到的最高档。未配置、净赢或未达到最低档时,奖励为 0。需要打码量为奖励乘以该档倍数。各分类和彩种的奖励及打码量分别汇总。
 
-`GET /admin/backflow` 每行新增:
+`GET /admin/backflow` 每行包含以下字段。
 
 | 字段 | 含义 |
 | --- | --- |
-| `loss_amount` | 各分类亏损合计 |
-| `details` | 分类明细数组,每项见下表 |
+| `loss_amount` | 各分类和彩种的净亏合计 |
+| `details` | 分类和彩种明细数组,每项见下表 |
 | `need_flow` | 需要打码量合计 |
-| `can_grant` | 是否可点“发放”(2026-11-05 新增):未发放即为 `true`,包括当月 |
+| `can_grant` | 是否可点「发放」。当月有待发差额才可发放,往月未结清可发放或结清 |
 
-`details` 每项:
+`details` 每项的字段如下。
 
 | 字段 | 含义 |
 | --- | --- |
-| `category` / `label` | 游戏分类标识 / 名称 |
+| `category` / `label` | 游戏分类或彩种的标识和显示名称 |
 | `bet_amount` | 该分类投注额 |
 | `effective_amount` | 该分类有效投注 |
 | `profit` | 该分类当月输赢 |
@@ -119,7 +97,6 @@ Content-Type: application/json
 | `amount` | 该分类奖励 |
 | `need_flow` | 该分类需要打码量 |
 
-- `recharge_amount`、`withdrawal_amount` 仍会记录,仅供展示,不再参与回水计算。
-- 未发放记录的 `amount` 是按当前规则的预估值,发放时会按最新注单和规则重新计算。
-- **2026-11-05 起列表包含当月**(上海时间):投注结算后约 1~2 分钟出现当月记录,`amount` 为实时预估,随注单变化更新;当月记录也可以在后台**手动**发放;发放后记录继续累计,之后可继续发放差额,详见《回水-已发放与继续发放-前端接入》。**自动发放**仍只发上个月及更早的记录。
-- 规则按游戏分类匹配。F1 自有彩票(六合彩、极速赛车、极速飞艇、28 等)都归在 **F1彩票**(`self:lottery`)分类,需在该分类下配置亏损档位;配在第三方“彩票”(`third:3`)下不会命中。
+- `recharge_amount`、`withdrawal_amount` 仅供展示。
+- 当月及往月未结清记录的 `amount` 按当前规则预估,发放时重新计算。当月可手动发放差额,自动发放处理往月记录。
+- 已结清记录显示发放时保存的明细,旧记录可能包含 `self:lottery`。未结清记录里无法确认彩种的历史注单以 `category: self:lottery`、`label: 未识别彩种` 展示,奖励为 0。

+ 50 - 0
docs/总后台/彩票赔率模板-前端接入.md

@@ -0,0 +1,50 @@
+# 彩票赔率模板前端接入
+
+## 管理后台
+
+用 `GET /admin/config/gameList` 的顺序和名称显示彩种。极速和六合彩传 `game` 及该彩种的 `params.type`,例如澳门六合彩为 `game=lhc&type=3`,香港六合彩为 `game=lhc&type=2`。两个 28 分别传 `game=canada28` 或 `game=jisu28`。
+
+| 接口 | 请求 | 响应 |
+| --- | --- | --- |
+| `GET /admin/jisuGame/list` | 彩种,可选 `odds_template_id` | `data.odds_templates` 模板列表、`data.odds_template_id` 所选 ID、`data.data` 玩法树 |
+| `POST /admin/jisuGame/saveTemplate` | 彩种,创建时传 `names`,修改时传 `odds_template_id`。可传 `enabled`、`sort`,以及用于切换默认模板的 `make_default: true` | `data` 为模板信息 |
+| `POST /admin/jisuGame/setOdds` | 彩种、`odds_template_id`、投注项或分组、`odds`、`mininum`、`maxinum` | `code=0` 表示成功,随后刷新所选模板 |
+
+模板包含 `id`、`names`、`is_default`、`enabled`、`sort` 和 `revision`。原有赔率作为各彩种的初始默认模板。新模板复制本彩种默认模板已配置的投注项,初始停用。可将已启用的模板设为默认,切换后可停用原默认模板。排序数字越大越靠前,同分时默认模板在先,之后按 ID 递增。Telegram 使用当前 28 彩种的默认模板。
+
+赔率树的叶节点包含 `id` 和 `configured`,未配置的项显示「未配置」,赔率及限额返回 `null`。配置单项时传投注项 ID。批量编辑时传分组节点的 `gameplay`,六合彩分组同时传 `group_game`,编辑六合彩一级分类时只传 `group_game`。保存的赔率和限额只用于当前彩种及模板。
+
+新增香港六合彩模板的请求体如下。中文名称 `names.zh` 为必填项。
+
+```json
+{
+  "game": "lhc",
+  "type": 2,
+  "names": {"zh": "标准盘", "en": "Standard", "vi": "Tiêu chuẩn"}
+}
+```
+
+## 会员前台
+
+| 彩种 | 读取游戏和模板 | 读取赔率 | 提交下注 |
+| --- | --- | --- | --- |
+| 极速或 SG | `GET /api/jisuGame/index?type=1` | 同一响应的 `data.games` | `POST /api/jisuGameOrder/submit` |
+| 香港六合彩示例 | `GET /api/lhcGame/index?type=2` | `GET /api/lhcNumber/index?id=8&type=2&odds_template_id=42&odds_template_revision=7` | `POST /api/lhcOrder/submit`,请求体含 `type: 2` |
+| 加拿大 28 和极速 28 | `GET /api/game/index?type=1` 或 `type=2` | 同一响应的 `data.games` | `POST /api/game/bet`,请求体分别含 `pc28: 0` 或 `pc28: 1` |
+
+游戏响应里的 `data.odds_templates` 是当前彩种已启用的模板列表,按 `sort` 从大到小返回。`data.odds_template` 是所选模板。模板项包含 `id`、`name`、`is_default`、`sort` 和 `revision`。按现有 `lang` 请求头返回名称,缺少当前语言时使用中文。进入彩种时使用响应中的 `odds_template` 作为默认选择。切换模板时把所选 `id` 作为 `odds_template_id` 重读游戏接口,并清空原投注选择。六合彩随后用返回的模板 ID 和版本读取各玩法的号码与赔率。
+
+会员接口只返回所选模板已配置的投注项。旧版六合彩号码请求省略彩种时,只返回四个六合彩默认模板均已配置的号码。
+
+以香港六合彩为例,`data[].id` 取自号码接口,整张注单使用同一套模板。
+
+```json
+{
+  "type": 2,
+  "odds_template_id": 42,
+  "odds_template_revision": 7,
+  "data": [{"id": 150, "amount": "10"}]
+}
+```
+
+会员接口以 `code=1` 表示成功。收到 `data.reason` 为 `ODDS_CHANGED` 或 `ODDS_TEMPLATE_UNAVAILABLE` 时,重新读取当前彩种的模板和赔率,让会员确认后再提交。旧版六合彩若收到 `ODDS_CLIENT_UPDATE_REQUIRED`,提示更新页面。

+ 5 - 4
routes/admin.php

@@ -282,9 +282,9 @@ Route::middleware(['admin.jwt'])->group(function () {
         // 玩法规则路由
         Route::prefix('/gameplayRule')->group(function () {
             Route::get('/', [GameplayRule::class, 'index']);
-            Route::post('/submit', [GameplayRule::class, 'store']); // 游戏规则写入
+            Route::post('/submit', [GameplayRule::class, 'store'])->defaults('require_active_button', true);
             Route::get('/cache', [GameplayRule::class, 'info']); // 游戏规则缓存读取
-            Route::post('/delete', [GameplayRule::class, 'destroy']); // 删除
+            Route::post('/delete', [GameplayRule::class, 'destroy'])->defaults('require_active_button', true);
         });
 
         Route::prefix('/withdraw')->group(function () {
@@ -633,8 +633,9 @@ Route::middleware(['admin.jwt'])->group(function () {
         });
         
         Route::prefix('/jisuGame')->group(function () {
-            Route::get('/list', [JisuGame::class, 'list']);
-            Route::post('/setOdds', [JisuGame::class, 'setOdds']);
+            Route::get('/list', [JisuGame::class, 'list'])->defaults('lottery_odds_permission', 'read');
+            Route::post('/setOdds', [JisuGame::class, 'setOdds'])->defaults('lottery_odds_permission', 'write');
+            Route::post('/saveTemplate', [JisuGame::class, 'saveTemplate'])->defaults('lottery_odds_permission', 'write');
         });
         Route::prefix('/jisuGameOrder')->group(function () {
             Route::get('/list', [JisuGameOrder::class, 'list']);

+ 17 - 3
tests/Integration/AdminListAccountTest.php

@@ -30,6 +30,9 @@ class AdminListAccountTest extends TestCase
         $app->instance('db', $db->getDatabaseManager());
         $app->bind('db.schema', fn () => $db->schema());
         $schema = $db->schema();
+        $schema->create('config', function (Blueprint $t) {
+            $t->id(); $t->string('field'); $t->text('val'); $t->integer('group_id');
+        });
         $schema->create('users', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->string('account')->nullable(); $t->string('username')->nullable();
             $t->string('first_name')->nullable(); $t->string('admin_note')->nullable(); $t->integer('status')->default(0);
@@ -49,9 +52,16 @@ class AdminListAccountTest extends TestCase
         });
         $schema->create('share_bet_facts', function (Blueprint $t) {
             $t->id(); $t->unsignedBigInteger('user_id'); $t->string('category'); $t->date('bet_date'); $t->dateTime('bet_at');
+            $t->string('source'); $t->string('source_id'); $t->string('member_id'); $t->boolean('settled')->default(true);
             $t->decimal('bet_amount', 24, 10); $t->decimal('effective_amount', 24, 10); $t->decimal('net_profit', 24, 10);
             $t->boolean('voided')->default(false); $t->string('currency')->default('CNY');
         });
+        $schema->create('lhc_order', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('type');
+        });
+        $schema->create('lhc_order_item', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('ordernum');
+        });
         $schema->create('recharges', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->string('coin')->default('CNY'); $t->decimal('amount', 20, 2)->default(0);
             $t->integer('status')->default(0); $t->timestamps();
@@ -114,12 +124,16 @@ class AdminListAccountTest extends TestCase
         \Carbon\Carbon::setTestNow(\Carbon\Carbon::parse('2026-11-05 15:00:00', 'Asia/Shanghai'));
         try {
             DB::table('backflow_rules')->insert([
-                ['category' => 'self:lottery', 'min_loss_amount' => 10, 'reward_amount' => 8],
-                ['category' => 'self:lottery', 'min_loss_amount' => 300, 'reward_amount' => 50],
+                ['category' => 'self:lottery:pc28', 'min_loss_amount' => 10, 'reward_amount' => 8],
+                ['category' => 'self:lottery:pc28', 'min_loss_amount' => 300, 'reward_amount' => 50],
             ]);
             foreach ([1 => '-50', 2 => '-350', 3 => '-50'] as $userId => $profit) {
+                $order = ['member_id' => "m{$userId}", 'ordernum' => "backflow-{$userId}"];
+                DB::table('lhc_order')->insert($order + ['type' => 5]);
+                $itemId = DB::table('lhc_order_item')->insertGetId($order);
                 DB::table('share_bet_facts')->insert(['user_id' => $userId, 'category' => 'self:lottery', 'bet_date' => '2026-11-03',
-                    'bet_at' => '2026-11-03 12:00:00', 'bet_amount' => 100, 'effective_amount' => 100, 'net_profit' => $profit]);
+                    'bet_at' => '2026-11-03 12:00:00', 'bet_amount' => 100, 'effective_amount' => 100, 'net_profit' => $profit,
+                    'member_id' => $order['member_id'], 'source' => 'lhc_order_item', 'source_id' => (string) $itemId]);
             }
             // 存储的 amount=20 是旧规则算出的过期值;列表应按当前规则显示
             DB::table('backflow')->insert([

+ 142 - 6
tests/Integration/AutomaticRewardGrantTest.php

@@ -4,6 +4,7 @@ namespace Tests\Integration;
 
 use App\Console\Commands\AutoGrantRewards;
 use App\Http\Controllers\admin\Backflow as BackflowController;
+use App\Http\Controllers\admin\Config as ConfigController;
 use App\Http\Controllers\admin\Rebate as RebateController;
 use App\Http\Controllers\admin\RewardGrantSettings;
 use App\Http\Controllers\admin\RewardRules;
@@ -23,6 +24,7 @@ use Illuminate\Events\Dispatcher;
 use Illuminate\Foundation\Application;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Pagination\PaginationState;
 use Illuminate\Routing\Router;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Facade;
@@ -31,6 +33,8 @@ use Illuminate\Translation\ArrayLoader;
 use Illuminate\Translation\Translator;
 use Illuminate\Validation\Factory;
 use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\PreserveGlobalState;
+use PHPUnit\Framework\Attributes\RunInSeparateProcess;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\Console\Tester\CommandTester;
 
@@ -61,7 +65,7 @@ class AutomaticRewardGrantTest extends TestCase
         Request::macro('validate', function (array $rules) {
             return app('validator')->make($this->all(), $rules)->validate();
         });
-        Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val'); });
+        Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val'); $t->integer('group_id')->default(0); });
         DB::table('config')->insert([
             ['field' => 'rebate', 'val' => '0.005'],
             ['field' => 'huishui_restriction', 'val' => '100'],
@@ -71,6 +75,7 @@ class AutomaticRewardGrantTest extends TestCase
             $t->id(); $t->string('member_id')->unique(); $t->integer('level')->default(1);
             $t->string('username')->default(''); $t->string('first_name')->default('');
             $t->string('language')->nullable()->default('zh');
+            $t->string('account')->nullable(); $t->string('admin_note')->nullable();
         });
         Schema::create('agents', function (Blueprint $t) { $t->id(); });
         Schema::create('operation_audits', function (Blueprint $t) {
@@ -89,6 +94,12 @@ class AutomaticRewardGrantTest extends TestCase
             $t->string('currency', 8)->default('CNY'); $t->string('source_version', 64)->default(''); $t->timestamps();
         });
         Schema::create('share_system_state', function (Blueprint $t) { $t->string('key')->primary(); $t->text('value'); $t->timestamps(); });
+        Schema::create('lhc_order', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('member_id'); $t->string('ordernum'); $t->integer('type');
+        });
+        Schema::create('lhc_order_item', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('member_id'); $t->string('ordernum');
+        });
         Schema::create('wallet_bonus', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->integer('type')->default(0); $t->unsignedBigInteger('activity_id')->default(0);
             $t->unsignedBigInteger('related_id'); foreach (['amount', 'need_flow', 'remaind_flow'] as $c) $t->decimal($c, 24, 2);
@@ -120,7 +131,7 @@ class AutomaticRewardGrantTest extends TestCase
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_05_160000_add_backflow_granted_amounts.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_120000_add_rebate_granted_amounts.php')->up();
         DB::table('rebate_rules')->insert(['category' => 'self:lottery', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0]);
-        DB::table('backflow_rules')->insert(['category' => 'self:lottery', 'min_loss_amount' => 100, 'reward_amount' => 35, 'turnover_multiple' => 0]);
+        DB::table('backflow_rules')->insert(['category' => 'self:lottery:pc28', 'min_loss_amount' => 100, 'reward_amount' => 35, 'turnover_multiple' => 0]);
         $this->request('/admin/rebate');
     }
 
@@ -167,7 +178,7 @@ class AutomaticRewardGrantTest extends TestCase
         $values += ['member_id' => '1001', 'date' => '2026-09', 'recharge_amount' => '1000.00', 'withdrawal_amount' => '-300.00'];
         if (($values['status'] ?? 0) === 0) {
             $loss = bcadd((string) $values['recharge_amount'], (string) $values['withdrawal_amount'], 2);
-            $this->fact($values['member_id'], 'self:lottery', $values['date'] . '-10 12:00:00', '0', bcsub('0', $loss, 2));
+            $this->lotteryFact($values['member_id'], 5, $values['date'] . '-10 12:00:00', '0', bcsub('0', $loss, 2));
         }
         return DB::table('backflow')->insertGetId($values);
     }
@@ -182,6 +193,22 @@ class AutomaticRewardGrantTest extends TestCase
             'created_at' => now()->format('Y-m-d H:i:s'), 'updated_at' => now()->format('Y-m-d H:i:s')]);
     }
 
+    private function lotteryFact(string $memberId, int $type, string $betAt, string $effective, string $profit, array $values = []): string
+    {
+        $orderNumber = 'lottery-' . DB::table('lhc_order')->count();
+        $order = ['member_id' => $memberId, 'ordernum' => $orderNumber];
+        DB::table('lhc_order')->insert($order + ['type' => $type]);
+        $itemId = DB::table('lhc_order_item')->insertGetId($order);
+        $this->fact($memberId, 'self:lottery', $betAt, $effective, $profit,
+            ['source' => 'lhc_order_item', 'source_id' => (string) $itemId] + $values);
+        return $orderNumber;
+    }
+
+    private function setGameList(array $items): void
+    {
+        DB::table('config')->insert(['field' => 'game_list', 'group_id' => 5, 'val' => json_encode($items, JSON_THROW_ON_ERROR)]);
+    }
+
     private function enable(string $type, string $time = '14:30'): void
     {
         RewardGrantSettingsService::save($type, ['auto_enabled' => 1, 'grant_time' => $time]);
@@ -611,6 +638,43 @@ class AutomaticRewardGrantTest extends TestCase
         $this->assertSame(1, DB::table('wallet_bonus')->count());
     }
 
+    public function test_backflow_rules_follow_the_game_list_and_save_each_lottery_independently(): void
+    {
+        $this->setGameList([
+            ['type' => 1, 'game' => 'canada28', 'name' => '加拿大 28', 'sort' => 1],
+            ['type' => 4, 'game' => 'lhc', 'params' => ['type' => 3], 'name' => '澳门六合彩', 'sort' => 6],
+            ['type' => 2, 'game' => 'jisu28', 'name' => '极速 28', 'sort' => 9],
+            ['type' => 12, 'game' => 'jisu', 'params' => ['type' => 7], 'name' => 'SG快3', 'sort' => 3, 'status' => 0],
+        ]);
+        $rule = ['min_loss_amount' => 10, 'reward_amount' => 20, 'turnover_multiple' => 2];
+        DB::table('backflow_rules')->insert($rule + ['category' => 'self:lottery']);
+
+        $controller = new RewardRules();
+        $gameList = (new ConfigController())->gameList()->getData(true)['data'];
+        $this->request('/admin/backflow/rules');
+        $data = $controller->backflow()->getData(true)['data'];
+        $categories = array_column($data['categories'], 'label', 'value');
+        $this->assertSame('彩票', $categories['third:3']);
+        $this->assertArrayNotHasKey('self:lottery', $data['rules']);
+        $this->assertSame(array_column($gameList, 'name'), array_slice(array_values($categories), 8));
+        $this->assertSame(['self:lottery:jisu28', 'self:lottery:lhc:3', 'self:lottery:jisu:7', 'self:lottery:pc28'],
+            array_slice(array_keys($categories), 8));
+
+        $this->request('/admin/backflow/rules', ['category' => 'self:lottery:jisu28', 'rules' => [$rule]], 'POST');
+        $saved = $controller->saveBackflow()->getData(true);
+        $this->assertSame(0, $saved['code']);
+        $this->assertSame('20.00', $saved['data']['rules']['self:lottery:jisu28'][0]['reward_amount']);
+        $this->assertSame('35.00', $saved['data']['rules']['self:lottery:pc28'][0]['reward_amount']);
+        $this->request('/admin/backflow/rules', ['category' => 'self:lottery:pc28', 'rules' => []], 'POST');
+        $cleared = $controller->saveBackflow()->getData(true);
+        $this->assertSame([], $cleared['data']['rules']['self:lottery:pc28']);
+        $this->assertSame($saved['data']['rules']['self:lottery:jisu28'], $cleared['data']['rules']['self:lottery:jisu28']);
+        foreach (['self:lottery', 'self:lottery:jisu:6'] as $category) {
+            $this->request('/admin/backflow/rules', ['category' => $category, 'rules' => [$rule]], 'POST');
+            $this->assertSame(-3, $controller->saveBackflow()->getData(true)['code']);
+        }
+    }
+
     public function test_backflow_pays_fixed_rewards_for_each_categorys_monthly_net_loss(): void
     {
         $this->user('2001');
@@ -636,12 +700,84 @@ class AutomaticRewardGrantTest extends TestCase
         $this->assertEquals(120, DB::table('backflow')->where('id', $row->id)->value('need_flow'));
     }
 
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_backflow_recalculates_an_unpaid_month_by_lottery_and_keeps_the_paid_snapshot(): void
+    {
+        $longLabel = str_repeat('澳门六合彩', 30);
+        $this->setGameList([
+            ['game' => 'canada28', 'name' => '加拿大28'],
+            ['game' => 'jisu28', 'name' => '极速28'],
+            ['game' => 'lhc', 'params' => ['type' => 3], 'name' => $longLabel],
+            ['game' => 'jisu', 'params' => ['type' => 7], 'name' => 'SG快3'],
+        ]);
+        DB::table('backflow_rules')->insert([
+            ['category' => 'self:lottery', 'min_loss_amount' => 0, 'reward_amount' => 999, 'turnover_multiple' => 0],
+            ['category' => 'self:lottery:pc28', 'min_loss_amount' => 150, 'reward_amount' => 50, 'turnover_multiple' => 2],
+            ['category' => 'self:lottery:pc28', 'min_loss_amount' => 170, 'reward_amount' => 70, 'turnover_multiple' => 2],
+            ['category' => 'self:lottery:jisu28', 'min_loss_amount' => 0, 'reward_amount' => 99, 'turnover_multiple' => 0],
+            ['category' => 'self:lottery:lhc:3', 'min_loss_amount' => 120, 'reward_amount' => 7, 'turnover_multiple' => 3],
+            ['category' => 'self:lottery:jisu:7', 'min_loss_amount' => 200, 'reward_amount' => 11, 'turnover_multiple' => 2],
+            ['category' => 'third:3', 'min_loss_amount' => 100, 'reward_amount' => 4, 'turnover_multiple' => 0],
+        ]);
+        $september = '2026-09-30 23:59:59';
+        $this->lotteryFact('1001', 5, $september, '200', '-200');
+        $this->lotteryFact('1001', 5, $september, '40', '40');
+        $this->lotteryFact('1001', 5, $september, '1000', '-1000', ['voided' => 1]);
+        $this->lotteryFact('1001', 5, $september, '0', '-900', ['settled' => 0, 'bet_amount' => '900']);
+        $this->lotteryFact('1001', 6, $september, '500', '500');
+        $this->lotteryFact('1001', 3, $september, '120', '-120');
+        $this->lotteryFact('1001', 13, $september, '200', '-200');
+        $this->lotteryFact('1001', 7, $september, '1000', '-1000');
+        $this->fact('1001', 'third:3', $september, '100', '-100');
+        $duplicate = $this->lotteryFact('1001', 5, $september, '90', '-90');
+        DB::table('lhc_order')->insert(['ordernum' => $duplicate, 'member_id' => '1002', 'type' => 3]);
+        $this->fact('1001', 'self:lottery', $september, '35', '-35', ['source' => 'lhc_order_item', 'source_id' => '999']);
+        $this->lotteryFact('1001', 5, '2026-10-01 00:00:00', '1000', '-1000');
+        $this->lotteryFact('1001', 5, '2026-08-20 00:00:00', '1000', '-1000');
+        $id = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-09', 'amount' => '1']);
+        $paid = DB::table('backflow')->insertGetId(['member_id' => '1001', 'date' => '2026-08', 'status' => 1,
+            'amount' => '35', 'granted_amount' => '35', 'details' => '[{"category":"self:lottery"}]']);
+        $paidSnapshot = DB::table('backflow')->where('id', $paid)->first();
+
+        $this->request('/admin/backflow', ['page' => 1, 'limit' => 10, 'date' => '2026-09']);
+        PaginationState::resolveUsing($this->app);
+        $list = (new BackflowController())->index()->getData(true);
+        $this->assertSame(0, $list['code'], $list['msg']);
+        $preview = $list['data']['data'][0];
+        $this->assertSame(['72.00', '1705.00', '143.00'], [$preview['amount'], $preview['loss_amount'], $preview['need_flow']]);
+        $details = array_column($preview['details'], null, 'category');
+        $this->assertSame('160.00', $details['self:lottery:pc28']['loss_amount']);
+        $this->assertSame('1140.00', $details['self:lottery:pc28']['bet_amount']);
+        $this->assertSame('0.00', $details['self:lottery:jisu28']['amount']);
+        $this->assertSame($longLabel, $details['self:lottery:lhc:3']['label']);
+        $this->assertSame('11.00', $details['self:lottery:jisu:7']['amount']);
+        $this->assertSame('0.00', $details['self:lottery:jisu:1']['amount']);
+        $this->assertSame('未识别彩种', $details['self:lottery']['label']);
+        $this->assertSame('125.00', $details['self:lottery']['loss_amount']);
+        $this->assertNull($details['self:lottery']['rule_id']);
+
+        $this->assertSame(2, (new RewardBaseSyncService())->run()['backflow']);
+        $row = DB::table('backflow')->where('id', $id)->first();
+        $this->assertEquals(72, $row->amount);
+        $this->assertSame($preview['details'], json_decode($row->details, true));
+        DB::statement("CREATE TRIGGER backflow_remark_limit BEFORE INSERT ON bot_balance_logs
+            WHEN length(NEW.remark) > 255 BEGIN SELECT RAISE(ABORT, 'remark too long'); END");
+        $this->enable('backflow');
+        $this->assertSame(1, $this->runRewards()['backflow']['granted']);
+        $this->assertSame(0, $this->runRewards()['backflow']['granted']);
+        $this->assertEquals(92, DB::table('wallets')->value('available_balance'));
+        $this->assertEquals(143, DB::table('wallet_bonus')->value('need_flow'));
+        $this->assertSame('日期:2026-09;亏损:1705.00;档位明细见回水记录;本次发放:72.00;累计发放:72.00', DB::table('balance_logs')->value('remark'));
+        $this->assertEquals($paidSnapshot, DB::table('backflow')->where('id', $paid)->first());
+    }
+
     public function test_backflow_can_be_granted_again_for_the_remainder_until_the_month_is_closed(): void
     {
         // now = 2026-10-25 14:30 上海;当月 2026-10。钱包初始 20。
         DB::table('backflow_rules')->insert([
-            ['category' => 'self:lottery', 'min_loss_amount' => 10, 'reward_amount' => 8, 'turnover_multiple' => 1],
-            ['category' => 'self:lottery', 'min_loss_amount' => 300, 'reward_amount' => 50, 'turnover_multiple' => 1],
+            ['category' => 'self:lottery:pc28', 'min_loss_amount' => 10, 'reward_amount' => 8, 'turnover_multiple' => 1],
+            ['category' => 'self:lottery:pc28', 'min_loss_amount' => 300, 'reward_amount' => 50, 'turnover_multiple' => 1],
         ]);
         $id = $this->backflow(['date' => '2026-10', 'recharge_amount' => '50.00', 'withdrawal_amount' => '0']); // 亏 50
         $this->enable('backflow');
@@ -660,7 +796,7 @@ class AutomaticRewardGrantTest extends TestCase
         catch (\RuntimeException $e) { $this->assertSame('暂无可发放金额(按当前规则应发 8.00,已发放 8.00)', $e->getMessage()); }
 
         // 继续亏到 350:同步后应发 50,已发 8,待发 42
-        $this->fact('1001', 'self:lottery', '2026-10-20 12:00:00', '300', '-300');
+        $this->lotteryFact('1001', 5, '2026-10-20 12:00:00', '300', '-300');
         (new RewardBaseSyncService())->run('2026-10-20', '2026-10-20');
         $fields = BackflowService::grantFields((array) DB::table('backflow')->where('id', $id)->first());
         $this->assertSame(['50.00', '8.00', '42.00', true], [$fields['amount'], $fields['granted_amount'], $fields['pending_amount'], $fields['can_grant']]);

+ 260 - 2
tests/Integration/LotteryBetSourceTest.php

@@ -3,9 +3,13 @@
 namespace Tests\Integration;
 
 use App\Http\Controllers\admin\LhcOrder;
+use App\Http\Controllers\admin\JisuGame;
+use App\Http\Controllers\admin\GameplayRule;
+use App\Http\Middleware\CheckButtonPermission;
 use App\Jobs\SendTelegramGroupMessageJob;
 use App\Services\AdminRealtime\AdminRealtimeRows;
 use App\Services\BetService;
+use App\Services\LotteryOdds;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Foundation\Testing\TestCase;
 use Illuminate\Http\Request;
@@ -32,7 +36,9 @@ class LotteryBetSourceTest extends TestCase
         DB::statement('CREATE TABLE bot_users (id INTEGER PRIMARY KEY, member_id TEXT, first_name TEXT, "from" INTEGER, is_banned INTEGER)');
         DB::statement('CREATE TABLE bot_wallets (id INTEGER PRIMARY KEY, member_id TEXT, available_balance NUMERIC, updated_at TEXT)');
         DB::statement('CREATE TABLE bot_config (id INTEGER PRIMARY KEY, field TEXT, val TEXT)');
-        DB::statement('CREATE TABLE bot_gameplay_rules (id INTEGER PRIMARY KEY, keywords TEXT, odds NUMERIC, mininum NUMERIC, maxinum NUMERIC)');
+        DB::statement('CREATE TABLE bot_gameplay_rules (id INTEGER PRIMARY KEY, keywords TEXT, groups TEXT, odds NUMERIC, mininum NUMERIC, maxinum NUMERIC, created_at TEXT, updated_at TEXT)');
+        DB::statement('CREATE TABLE bot_jisu_gameplay (id INTEGER PRIMARY KEY, type INTEGER, gameplay TEXT, number TEXT, odds TEXT, mininum INTEGER, maxinum INTEGER)');
+        DB::statement('CREATE TABLE bot_lhc_number (id INTEGER PRIMARY KEY, game TEXT, gameplay TEXT, number TEXT, odds TEXT, mininum INTEGER, maxinum INTEGER)');
         DB::statement('CREATE TABLE bot_keyboard (id INTEGER PRIMARY KEY, button TEXT)');
         foreach (['issues', 'pc_issues'] as $table) {
             DB::statement("CREATE TABLE bot_{$table} (id INTEGER PRIMARY KEY, issue_no TEXT, status INTEGER, end_time TEXT, winning_numbers TEXT, combo TEXT)");
@@ -46,7 +52,13 @@ class LotteryBetSourceTest extends TestCase
 
         DB::table('users')->insert(['member_id' => '1001', 'first_name' => 'Alice', 'from' => -1, 'is_banned' => 0]);
         DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => 100]);
-        DB::table('gameplay_rules')->insert(['keywords' => '大', 'odds' => 2, 'mininum' => 1, 'maxinum' => 100]);
+        DB::table('gameplay_rules')->insert(['keywords' => '大', 'groups' => '大小单双', 'odds' => 2, 'mininum' => 1, 'maxinum' => 100]);
+        foreach (range(1, 7) as $type) {
+            DB::table('jisu_gameplay')->insert(['type' => $type, 'gameplay' => '第一球', 'number' => '大', 'odds' => '1.95', 'mininum' => 1, 'maxinum' => 100]);
+        }
+        DB::table('lhc_number')->insert(['game' => '特码', 'gameplay' => '号码', 'number' => '07', 'odds' => '48.0', 'mininum' => 1, 'maxinum' => 100]);
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_120000_create_lottery_odds_templates.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_160000_add_sort_to_lottery_odds_templates.php')->up();
         DB::table('config')->insert([
             ['field' => 'service_customer', 'val' => 'test'],
             ['field' => 'maintenance_switch', 'val' => '0'],
@@ -71,6 +83,252 @@ class LotteryBetSourceTest extends TestCase
         return $response['data'];
     }
 
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_odds_and_28_catalog_writes_require_a_visible_granted_button(): void
+    {
+        DB::statement('CREATE TABLE bot_menus (id INTEGER PRIMARY KEY, uri TEXT, type INTEGER, status INTEGER,
+            parent_id INTEGER, title TEXT, permission_name TEXT, created_at TEXT, updated_at TEXT)');
+        DB::statement('CREATE TABLE bot_roles (id INTEGER PRIMARY KEY, name TEXT)');
+        DB::statement('CREATE TABLE bot_role_user (id INTEGER PRIMARY KEY, role_id INTEGER, user_id INTEGER)');
+        DB::statement('CREATE TABLE bot_role_menu (id INTEGER PRIMARY KEY, role_id INTEGER, menu_id INTEGER)');
+        DB::table('roles')->insert(['id' => 5, 'name' => 'operator']);
+        DB::table('role_user')->insert(['role_id' => 5, 'user_id' => 7]);
+
+        $check = function (string $path, array $input = [], int $userId = 7): int {
+            $request = Request::create($path, 'POST', $input);
+            $request->user = (object) ['id' => $userId];
+            $route = $this->app['router']->getRoutes()->match($request);
+            $request->setRouteResolver(fn () => $route);
+            $this->assertContains('admin.jwt', $route->middleware());
+            $this->assertContains('check.button.uri', $route->middleware());
+            return (new CheckButtonPermission())->handle($request, fn () => response()->json(['code' => 0]))->getData(true)['code'];
+        };
+        $odds = '/admin/jisuGame/setOdds';
+        $templates = '/admin/jisuGame/saveTemplate';
+        $lhc = ['game' => 'lhc'];
+        foreach ([[$odds, $lhc], [$templates, $lhc], [$odds, ['game' => 'canada28']], [$odds, []],
+            ['/admin/gameplayRule/submit', []], ['/admin/gameplayRule/delete', []]] as [$route, $input]) {
+            $this->assertSame(-1, $check($route, $input), $route . ' must reject missing permission');
+            $this->assertSame(0, $check($route, $input, 1));
+        }
+
+        $button = ['uri' => 'admin/lhcNumber/setOdds', 'type' => 2];
+        $hidden = DB::table('menus')->insertGetId($button + ['status' => 2]);
+        DB::table('role_menu')->insert(['role_id' => 5, 'menu_id' => $hidden]);
+        $this->assertSame(-1, $check($odds, $lhc));
+        $visible = DB::table('menus')->insertGetId($button + ['status' => 1]);
+        $this->assertSame(-1, $check($templates, $lhc), 'A visible duplicate does not grant a hidden button');
+        DB::table('role_menu')->insert(['role_id' => 5, 'menu_id' => $visible]);
+        $this->assertSame(0, $check($templates . '?game=jisu', $lhc));
+        $this->assertSame(-1, $check($odds . '?game=lhc', ['game' => 'jisu28']));
+        $this->assertSame(-1, $check($odds, ['game' => ['lhc']]));
+        $this->assertSame(-1, $check($odds));
+
+        $jisu = DB::table('menus')->insertGetId(['uri' => 'admin/jisuGame/setOdds', 'type' => 2, 'status' => 1]);
+        DB::table('role_menu')->insert(['role_id' => 5, 'menu_id' => $jisu]);
+        $this->assertSame(0, $check($odds), 'Authorized legacy Jisu requests may omit the game');
+        $pc28 = DB::table('menus')->insertGetId(['uri' => 'admin/gameplayRule/submit', 'type' => 2, 'status' => 1]);
+        DB::table('role_menu')->insert(['role_id' => 5, 'menu_id' => $pc28]);
+        $this->assertSame(0, $check($templates, ['game' => 'jisu28']));
+        $this->assertSame(0, $check('/admin/gameplayRule/submit'));
+        $this->assertSame(-1, $check('/admin/gameplayRule/delete'), 'Deletion requires its own grant');
+
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_150000_register_lottery_odds_write_permissions.php';
+        $migration->up();
+        $migration->up();
+        $deleteButtons = DB::table('menus')->where('uri', 'admin/gameplayRule/delete')->get();
+        $this->assertCount(1, $deleteButtons);
+        $this->assertEquals(0, $deleteButtons[0]->parent_id, 'Older installations can grant the button without the page menu');
+        $this->assertEquals(2, DB::table('menus')->where('id', $hidden)->value('status'));
+        $this->assertSame(-1, $check('/admin/gameplayRule/delete'), 'Provisioning does not grant a role permission');
+        DB::table('role_menu')->insert(['role_id' => 5, 'menu_id' => $deleteButtons[0]->id]);
+        $this->assertSame(0, $check('/admin/gameplayRule/delete'));
+    }
+
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_odds_templates_copy_and_edit_only_the_selected_game(): void
+    {
+        $this->assertSame(13, DB::table('lottery_odds_templates')->count());
+        foreach (LotteryOdds::gameKeys() as $key) {
+            $template = LotteryOdds::template($key);
+            $actual = LotteryOdds::items($template->id)->orderBy('item_id')->get();
+            $legacy = LotteryOdds::catalog($key)->orderBy('id')->get(['id', 'odds', 'mininum', 'maxinum']);
+            $this->assertSame($legacy->pluck('id')->all(), $actual->pluck('item_id')->all());
+            foreach (['odds', 'mininum', 'maxinum'] as $field) {
+                $this->assertEquals($legacy->pluck($field)->all(), $actual->pluck($field)->all());
+            }
+        }
+
+        $scope = ['game' => 'lhc', 'type' => 3];
+        $call = function (string $action, array $input) {
+            $this->app->instance('request', Request::create("/admin/jisuGame/{$action}", $action === 'list' ? 'GET' : 'POST', $input));
+            return (new JisuGame())->{$action}()->getData(true);
+        };
+        $created = $call('saveTemplate', $scope + ['names' => ['zh' => '测试', 'en' => 'Test']]);
+        $this->assertSame(0, $created['code'], $created['msg']);
+        $template = $created['data'];
+        $this->assertFalse($template['enabled']);
+        $selection = ['odds_template_id' => $template['id']];
+        $prices = ['group_game' => '特码', 'gameplay' => '号码', 'odds' => '49', 'mininum' => '2', 'maxinum' => '200'];
+        $this->assertNotSame(0, $call('setOdds', ['game' => 'lhc', 'type' => 2] + $selection + $prices)['code']);
+        $this->assertSame(0, $call('setOdds', $scope + $selection + $prices)['code']);
+        $enabled = $call('saveTemplate', $scope + $selection + ['enabled' => true, 'names' => ['zh' => '澳门盘']]);
+        $this->assertSame(['zh' => '澳门盘'], $enabled['data']['names']);
+        $this->assertTrue($enabled['data']['enabled']);
+        $this->assertGreaterThan($template['revision'], $enabled['data']['revision']);
+        $this->assertSame('49', $call('list', $scope + $selection)['data']['data'][0]['children'][0]['children'][0]['odds']);
+        $this->assertSame('48.0', $call('list', $scope)['data']['data'][0]['children'][0]['children'][0]['odds']);
+        $this->assertSame('48.0', $call('list', ['game' => 'lhc', 'type' => 2])['data']['data'][0]['children'][0]['children'][0]['odds']);
+    }
+
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_new_plays_stay_unpriced_until_the_selected_template_is_saved(): void
+    {
+        $draft = LotteryOdds::saveTemplate('lhc:2', ['names' => ['zh' => '香港测试']]);
+        $jisu = LotteryOdds::template('jisu:2');
+        $hongKong = LotteryOdds::template('lhc:2');
+        $newJisu = DB::table('jisu_gameplay')->insertGetId([
+            'type' => 2, 'gameplay' => '后三', 'number' => '杂六', 'odds' => '2.8', 'mininum' => 10, 'maxinum' => 500,
+        ]);
+        $newMarkSix = DB::table('lhc_number')->insertGetId([
+            'game' => '特码', 'gameplay' => '号码', 'number' => '08', 'odds' => '47', 'mininum' => 2, 'maxinum' => 300,
+        ]);
+        $this->assertSame([
+            'id' => $newJisu, 'name' => '杂六', 'configured' => false, 'odds' => null, 'mininum' => null, 'maxinum' => null,
+        ], LotteryOdds::list('jisu:2', null)['data'][1]['children'][0]);
+        $markSixItems = LotteryOdds::list('lhc:2', $draft['id'])['data'][0]['children'][0]['children'];
+        $this->assertTrue($markSixItems[0]['configured']);
+        $this->assertFalse($markSixItems[1]['configured']);
+        $this->assertNull($markSixItems[1]['odds']);
+        $this->assertSame([$markSixItems[0]['id']], LotteryOdds::pricedCatalog('lhc:2', $draft['id'])->pluck('id')->all());
+        $this->assertSame($jisu->revision, LotteryOdds::template('jisu:2')->revision);
+
+        $fresh = LotteryOdds::saveTemplate('lhc:2', ['names' => ['zh' => '香港新模板']]);
+        $this->assertFalse(LotteryOdds::items($fresh['id'])->where('item_id', $newMarkSix)->exists());
+        LotteryOdds::setOdds('jisu:2', null, ['id' => $newJisu, 'odds' => '2.9', 'mininum' => 3, 'maxinum' => 400]);
+        LotteryOdds::setOdds('lhc:2', $draft['id'], [
+            'group_game' => '特码', 'gameplay' => '号码', 'odds' => '49', 'mininum' => 5, 'maxinum' => 200,
+        ]);
+
+        $this->assertSame('2.9', LotteryOdds::items($jisu->id)->where('item_id', $newJisu)->value('odds'));
+        $this->assertFalse(LotteryOdds::items(LotteryOdds::template('jisu:3')->id)->where('item_id', $newJisu)->exists());
+        $this->assertSame($jisu->revision + 1, LotteryOdds::template('jisu:2')->revision);
+        $this->assertSame(['49', '49'], LotteryOdds::items($draft['id'])->orderBy('item_id')->pluck('odds')->all());
+        $this->assertSame($draft['revision'] + 1, LotteryOdds::template('lhc:2', $draft['id'])->revision);
+        $this->assertFalse(LotteryOdds::items($hongKong->id)->where('item_id', $newMarkSix)->exists());
+        $this->assertFalse(LotteryOdds::items(LotteryOdds::template('lhc:3')->id)->where('item_id', $newMarkSix)->exists());
+        $this->assertSame('48.0', LotteryOdds::items($hongKong->id)->where('item_id', 1)->value('odds'));
+    }
+
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_equivalent_enabled_values_do_not_invalidate_quotes(): void
+    {
+        $default = LotteryOdds::template('lhc:2');
+        $draft = LotteryOdds::saveTemplate('lhc:2', ['names' => ['zh' => '香港测试']]);
+        foreach ([true, 1, '1'] as $value) {
+            $this->assertSame($default->revision, LotteryOdds::saveTemplate('lhc:2', [
+                'odds_template_id' => $default->id, 'enabled' => $value,
+            ])['revision']);
+        }
+        foreach ([false, 0, '0'] as $value) {
+            $this->assertSame($draft['revision'], LotteryOdds::saveTemplate('lhc:2', [
+                'odds_template_id' => $draft['id'], 'enabled' => $value,
+            ])['revision']);
+        }
+        $this->assertSame($draft['revision'] + 1, LotteryOdds::saveTemplate('lhc:2', [
+            'odds_template_id' => $draft['id'], 'enabled' => '1',
+        ])['revision']);
+    }
+
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_28_catalog_add_rename_and_delete_update_every_template(): void
+    {
+        $extra = LotteryOdds::saveTemplate('jisu28', ['names' => ['zh' => '测试']]);
+        $setRequest = fn ($input) => $this->app->instance('request', Request::create('/admin/gameplayRule', 'POST', $input));
+        $setRequest(['keywords' => '小', 'groups' => '大小单双', 'odds' => '1.95', 'mininum' => 1, 'maxinum' => 20]);
+        $this->assertSame(0, (new GameplayRule())->store()->getData(true)['code']);
+        $itemId = DB::table('gameplay_rules')->where('keywords', '小')->value('id');
+        $templates = [LotteryOdds::template('canada28')->id, LotteryOdds::template('jisu28')->id, $extra['id']];
+        foreach ($templates as $id) {
+            $this->assertSame('1.95', LotteryOdds::items($id)->where('item_id', $itemId)->value('odds'));
+        }
+
+        $setRequest(['id' => $itemId, 'keywords' => '小单', 'groups' => '大小单双']);
+        $this->assertSame(0, (new GameplayRule())->store()->getData(true)['code']);
+        $this->assertSame('小单', DB::table('gameplay_rules')->where('id', $itemId)->value('keywords'));
+        $this->assertSame($extra['revision'] + 2, LotteryOdds::template('jisu28', $extra['id'])->revision);
+
+        $setRequest(['id' => $itemId]);
+        $this->assertSame(0, (new GameplayRule())->destroy()->getData(true)['code']);
+        $this->assertFalse(DB::table('lottery_odds_items')->whereIn('template_id', $templates)->where('item_id', $itemId)->exists());
+        $this->assertTrue(LotteryOdds::items(LotteryOdds::template('jisu:2')->id)->where('item_id', $itemId)->exists());
+    }
+
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_telegram_uses_the_selected_default_regardless_of_template_sort(): void
+    {
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_130000_add_bet_source_to_lhc_order.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_120000_create_maintenance_settings.php')->up();
+        $original = LotteryOdds::template('canada28');
+        $jisu = LotteryOdds::template('jisu28');
+        $save = function (array $input) {
+            $this->app->instance('request', Request::create('/admin/jisuGame/saveTemplate', 'POST', ['game' => 'canada28'] + $input));
+            return (new JisuGame())->saveTemplate()->getData(true);
+        };
+        $created = $save(['names' => ['zh' => '新模板'], 'sort' => 10]);
+        $this->assertSame(0, $created['code'], $created['msg']);
+        $alternate = $created['data'];
+        $this->assertFalse($alternate['enabled']);
+        $this->assertSame(10, $alternate['sort']);
+        $select = ['odds_template_id' => $alternate['id']];
+        LotteryOdds::setOdds('canada28', $alternate['id'], ['id' => 1, 'odds' => '3.25', 'mininum' => 1, 'maxinum' => 100]);
+        $this->assertNotSame(0, $save($select + ['make_default' => true])['code']);
+        $this->assertNotSame(0, $save(['odds_template_id' => $original->id, 'enabled' => false])['code']);
+        $originalList = LotteryOdds::list('canada28', null);
+        $this->assertSame([$alternate['id'], $original->id], array_column($originalList['odds_templates'], 'id'));
+        $this->assertSame($original->id, $originalList['odds_template_id']);
+        $this->assertStringContainsString('下注成功', BetService::bet('1001', '大10')['text']);
+
+        $this->assertSame(0, $save($select + ['enabled' => true])['code']);
+        $revision = LotteryOdds::template('canada28', $alternate['id'])->revision;
+        $result = $save($select + ['make_default' => true, 'sort' => 2]);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        $this->assertTrue($result['data']['is_default']);
+        $this->assertSame($revision, $result['data']['revision']);
+        $this->assertSame(1, DB::table('lottery_odds_templates')->where('game_key', 'canada28')->where('is_default', 1)->count());
+        $this->assertSame($jisu->id, LotteryOdds::template('jisu28')->id);
+        $this->assertSame(0, $save(['odds_template_id' => $original->id, 'enabled' => false])['code']);
+        $this->assertStringContainsString('下注成功', BetService::bet('1001', '大10')['text']);
+        $orders = DB::table('lhc_order')->orderBy('id')->get();
+        $this->assertEquals([2, 3.25], $orders->pluck('odds')->all());
+        $this->assertSame([$original->id, $alternate['id']], $orders->pluck('odds_template_id')->all());
+    }
+
+    #[RunInSeparateProcess]
+    #[PreserveGlobalState(false)]
+    public function test_telegram_default_price_changes_create_a_separate_order(): void
+    {
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_06_130000_add_bet_source_to_lhc_order.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_120000_create_maintenance_settings.php')->up();
+        $this->assertStringContainsString('下注成功', BetService::bet('1001', '大10')['text']);
+        LotteryOdds::setOdds('canada28', null, ['id' => 1, 'odds' => '2.50', 'mininum' => 1, 'maxinum' => 100]);
+        $this->assertStringContainsString('下注成功', BetService::bet('1001', '大15')['text']);
+        $this->assertStringContainsString('下注成功', BetService::bet('1001', '大20')['text']);
+        DB::table('config')->where('field', 'pc28_switch')->update(['val' => '1']);
+        $this->assertStringContainsString('下注成功', BetService::bet('1001', '大5')['text']);
+        $orders = DB::table('lhc_order')->orderBy('id')->get();
+        $this->assertEquals([2, 2.5, 2], $orders->pluck('odds')->all());
+        $this->assertEquals([10, 35, 5], $orders->pluck('total_amount')->all());
+        $this->assertSame([1, 2, 1], $orders->pluck('odds_template_revision')->all());
+        $this->assertEquals(50, DB::table('wallets')->value('available_balance'));
+    }
+
     #[RunInSeparateProcess]
     #[PreserveGlobalState(false)]
     public function test_orders_are_tagged_and_telegram_merges_only_its_own_bets(): void

+ 49 - 5
tests/Integration/SimulationTest.php

@@ -4,6 +4,7 @@ namespace Tests\Integration;
 
 use App\Http\Controllers\admin\Simulation as SimulationController;
 use App\Models\User;
+use App\Services\LotteryOdds;
 use App\Services\Simulation\SimulationBetService;
 use App\Services\Simulation\SimulationFundService;
 use App\Services\Simulation\SimulationMemberService;
@@ -106,12 +107,19 @@ class SimulationTest extends TestCase
         });
         Schema::create('level', function (Blueprint $t) { $t->id(); $t->integer('level'); $t->decimal('recharge', 20, 2); });
         Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val')->nullable(); });
-        Schema::create('issues', function (Blueprint $t) {
-            $t->id(); $t->string('issue_no'); $t->dateTime('start_time'); $t->dateTime('end_time'); $t->integer('status'); $t->timestamps();
-        });
+        foreach (['issues', 'pc_issues'] as $table) {
+            Schema::create($table, function (Blueprint $t) {
+                $t->id(); $t->string('issue_no'); $t->dateTime('start_time'); $t->dateTime('end_time'); $t->integer('status'); $t->timestamps();
+            });
+        }
         Schema::create('gameplay_rules', function (Blueprint $t) {
-            $t->id(); $t->string('keywords'); $t->decimal('odds', 10, 2); $t->decimal('mininum', 12, 2); $t->decimal('maxinum', 12, 2); $t->timestamps();
+            $t->id(); $t->string('keywords'); $t->string('groups'); $t->decimal('odds', 10, 2); $t->decimal('mininum', 12, 2); $t->decimal('maxinum', 12, 2); $t->timestamps();
         });
+        foreach (['jisu_gameplay', 'lhc_number'] as $table) {
+            Schema::create($table, function (Blueprint $t) {
+                $t->id(); $t->integer('type')->nullable(); $t->string('odds'); $t->bigInteger('mininum'); $t->bigInteger('maxinum');
+            });
+        }
         Schema::create('lhc_order', function (Blueprint $t) {
             $t->id(); $t->string('issue'); $t->string('ordernum'); $t->string('member_id'); $t->decimal('odds', 10, 2)->default(0);
             $t->decimal('amount', 22, 2); $t->decimal('total_amount', 22, 2)->default(0); $t->integer('lottery_status')->default(0);
@@ -140,7 +148,9 @@ class SimulationTest extends TestCase
         DB::table('config')->insert([['field' => 'pc28_switch', 'val' => '0'], ['field' => 'recharge_rate', 'val' => '0']]);
         DB::table('config')->where('field', 'simulation_enabled')->update(['val' => '1']);
         DB::table('issues')->insert(['issue_no' => '3300001', 'start_time' => now(), 'end_time' => now()->addMinutes(5), 'status' => 1]);
-        DB::table('gameplay_rules')->insert(['keywords' => '大', 'odds' => 2, 'mininum' => 1, 'maxinum' => 100000]);
+        DB::table('gameplay_rules')->insert(['keywords' => '大', 'groups' => '大小单双', 'odds' => 2, 'mininum' => 1, 'maxinum' => 100000]);
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_120000_create_lottery_odds_templates.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_160000_add_sort_to_lottery_odds_templates.php')->up();
     }
 
     /** 一个真实会员和一个前台游客,各有充值、投注、订单。 */
@@ -184,6 +194,40 @@ class SimulationTest extends TestCase
         return [(float) $w->available_balance, (float) $w->frozen_balance];
     }
 
+    public function test_lottery_options_and_simulated_bets_use_the_active_games_default_template(): void
+    {
+        [$member] = array_column(SimulationMemberService::create(1, ['initial_balance' => 1000]), 'member_id');
+        $ledgerCount = DB::table('balance_logs')->count();
+        LotteryOdds::setOdds('canada28', null, ['id' => 1, 'odds' => '0', 'mininum' => 1, 'maxinum' => 100]);
+        $this->assertSame([], SimulationBetService::options()['lottery']['rules']->all());
+        try {
+            SimulationBetService::lottery($member, '大', 10);
+            $this->fail('The active default template has disabled this play');
+        } catch (\Exception $e) {
+            $this->assertStringContainsString('禁止投注', $e->getMessage());
+        }
+        $this->assertSame([1000.0, 0.0], $this->wallet($member));
+        $this->assertSame($ledgerCount, DB::table('balance_logs')->count());
+        $this->assertSame(0, DB::table('lhc_order')->where('member_id', $member)->count());
+
+        DB::table('config')->where('field', 'pc28_switch')->update(['val' => '1']);
+        DB::table('pc_issues')->insert(['issue_no' => '2200001', 'start_time' => now(), 'end_time' => now()->addMinutes(5), 'status' => 1]);
+        LotteryOdds::setOdds('jisu28', null, ['id' => 1, 'odds' => '3.25', 'mininum' => 10, 'maxinum' => 20]);
+        $rules = SimulationBetService::options()['lottery']['rules'];
+        $this->assertSame(['keywords' => '大', 'odds' => '3.25', 'mininum' => 10, 'maxinum' => 20], $rules->first());
+        try {
+            SimulationBetService::lottery($member, '大', 21);
+            $this->fail('The selected template caps the stake at 20');
+        } catch (\Exception $e) {
+            $this->assertStringContainsString('超过该玩法最大金额', $e->getMessage());
+        }
+        $order = SimulationBetService::lottery($member, '大', 20);
+        $template = LotteryOdds::template('jisu28');
+        $this->assertEquals(['2200001', 6, '3.25', $template->id, $template->revision],
+            [$order->issue, $order->type, $order->odds, $order->odds_template_id, $order->odds_template_revision]);
+        $this->assertSame([980.0, 0.0], $this->wallet($member));
+    }
+
     public function test_simulated_money_and_bets_use_real_tables_but_stay_out_of_the_real_view(): void
     {
         $before = $this->realView();